feat(ql3): add bounded panel bootstrap

This commit is contained in:
whyour
2026-09-02 11:30:42 +08:00
parent 83966a1287
commit 882ce07d7e
20 changed files with 1095 additions and 134 deletions
+20
View File
@@ -114,6 +114,25 @@ jobs:
node:${{ matrix.node }}-bookworm-slim node:${{ matrix.node }}-bookworm-slim
node --test test/back/completionReceiptOrphanAuditor.test.cjs node --test test/back/completionReceiptOrphanAuditor.test.cjs
legacy-panel-compatibility:
name: Legacy panel compatibility build (Node 20 migration toolchain)
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
- uses: actions/setup-node@v6
with:
node-version: '20.20.2'
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install workspace dependencies without lifecycle scripts
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Build the capability-gated legacy panel source
run: pnpm build:front
service-manager-bridge: service-manager-bridge:
name: systemd/OpenRC dual-authority bridge name: systemd/OpenRC dual-authority bridge
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -2082,6 +2101,7 @@ jobs:
if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'local' || inputs.alpha_artifact_scope == 'all') if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'local' || inputs.alpha_artifact_scope == 'all')
needs: needs:
- backend - backend
- legacy-panel-compatibility
- service-manager-bridge - service-manager-bridge
- linux-resource-envelopes - linux-resource-envelopes
- linux-resource-release-evidence - linux-resource-release-evidence
+3 -1
View File
@@ -1,6 +1,8 @@
# QingLong 3.0 Architecture RFC # QingLong 3.0 Architecture RFC
- D-427/ADR-0529(源码候选,等待双架构阶段实物):开始把现有 2.x 面板从“不能零改直连”推进为受控页面复用,而不是把旧 Express Service 或数据库写 authority 搬回 3.0。第一切片只在既有 `@qinglong/local-api/panel-compatibility` 子域开放认证后的 `GET /api/crons`:正式 Bearer credential`task.read` Policy、durable auditcredential reconfirm 后,按 Edge 64/Standalone 256 行上限读取 `qinglong/cron@v1` Trigger 前缀,并以 Trigger 固定的 `taskId/revision/contentDigest` 读取 pinned Task revision。每个 Trigger 投影成一条旧 Cron 包络,字符串 `triggerId` 保持稳定身份,schedule/timezone/misfire 来自规范化 Triggercommand 只返回 `ql3:<kind>:<taskId>@<revision>` 描述符,不暴露 argv、环境、Secret、label、mutation 或 digest。缺失 pinned Task、identity 漂移、未知 Trigger schema、异常页和超预算均整体失败关闭。首切片只接受旧页面初始空 search/filter 与有界 page/size搜索排序、View query 和全部写操作明确拒绝。它不新增 package、dependency、migration、连接、listener、timerwatcherheadlessCluster 路径不变,也不提供 2.x login/JWT、静态面板、WebSocket、Cron View、Subscription、Script、Env 或 Run/Log 兼容。本地真实 SQLite/credential/Policy/HTTP 集成、Local API 83/83、18-package clean build/test 和 Edge/Standalone Application API 资源门已通过;仍须完成双架构 artifact 门,并继续实现 capability shell 与 Run/Log read,才能把改造后的现有页面称为阶段性可用 - D-428/ADR-0530(源码候选,等待可下载同源装配与双架构阶段实物):在 D-427 的只读 Cron Adapter 上增加显式 Local 面板能力发现和启动边界,而不是伪造完整 2.x 后端。公开、无敏感数据的 exact `GET /api/health``GET /api/system``GET /api/v3/capabilities` 只接受无 body 与可选单个数字 `t`;认证后的 `GET /api/user``GET /api/system/config` 仍经过 Bearer`task.read` Policy、durable auditcredential reconfirm,并只接受 User principal。capability v1 区分 Edge/Standalone 的 64/256 Cron 行与 16/32 KiB 日志块预算,并明确关闭 legacy login/mutation、Subscription、Script、Env 和 WebSocket。现有面板源码只在严格发现 `qinglong3` capability 后进入 3.0 模式:登录页改收 `ql3c_` API Credential,凭据仅驻留模块内存且刷新即丢失;认证成功只进入 `/crontab`;菜单、请求和 Modal 降级为名称/命令描述符/状态/schedule 的有界只读列表,不请求 View、Subscription、非空搜索/排序,也不建立 WebSocket。2.x 模式保持原行为。该切片不新增 package、production dependency、migration、数据库连接、listener、timerwatcher 或后台进程;默认 headlessCluster authority 与 Node 24 runtime 均不变,旧 Umi 暂用 Node 20 只作为 legacy migration build toolchain。Local API 12-package closure 与 89/89、18-package 完整测试、package/import/dependency audit、旧面板 production build 均通过;Playwright 同源源码旅程验证了登录、只读列表和刷新清凭据,最终 0 console error。仍须把改造静态资源装入可下载 Console、在 exact Console + Local API + SQLite 上重跑真实 credential journey、通过远端 CI 和双架构 artifact,才能声明阶段页面产物可直接部署
- D-427/ADR-0529(双架构阶段实物已交付):开始把现有 2.x 面板从“不能零改直连”推进为受控页面复用,而不是把旧 Express Service 或数据库写 authority 搬回 3.0。第一切片只在既有 `@qinglong/local-api/panel-compatibility` 子域开放认证后的 `GET /api/crons`:正式 Bearer credential、`task.read` Policy、durable audit、credential reconfirm 后,按 Edge 64/Standalone 256 行上限读取 `qinglong/cron@v1` Trigger 前缀,并以 Trigger 固定的 `taskId/revision/contentDigest` 读取 pinned Task revision。每个 Trigger 投影成一条旧 Cron 包络,字符串 `triggerId` 保持稳定身份,schedule/timezone/misfire 来自规范化 Triggercommand 只返回 `ql3:<kind>:<taskId>@<revision>` 描述符,不暴露 argv、环境、Secret、label、mutation 或 digest。缺失 pinned Task、identity 漂移、未知 Trigger schema、异常页和超预算均整体失败关闭。首切片只接受旧页面初始空 search/filter 与有界 page/size;搜索、排序、View query 和全部写操作明确拒绝。它不新增 package、dependency、migration、连接、listener、timer 或 watcherheadless 与 Cluster 路径不变,也不提供 2.x login/JWT、静态面板、WebSocket、Cron View、Subscription、Script、Env 或 Run/Log 兼容。本地真实 SQLite/credential/Policy/HTTP 集成、Local API 83/83、18-package clean build/test 和 Edge/Standalone Application API 资源门已通过。提交 `83966a128705f6ef0ae54367c8f656e3d578e9c6` 的显式 Local headless [run 33576693917](https://github.com/whyour/qinglong/actions/runs/33576693917) 与 Local Console [run 33578754251](https://github.com/whyour/qinglong/actions/runs/33578754251) 均成功;headless amd64/arm64/milestone artifact `9827434236`/`9827259756`/`9827553099`Console `9828792297`/`9828714089`/`9828941802`,均未过期并保留至 2026-10-02。D-427 因此闭合为后端阶段实物;它仍不包含改造后的 2.x 静态面板,页面交付由 D-428 独立闭合。
- D-426c3/ADR-0528(源码候选,等待 exact 双架构阶段实物):在 D-426c2 的三阶段回滚链上保留 `apply-rollback`,新增显式 `apply-plan``complete`。完成型 CI 使用完整 2.x schema、空 Apps/Auths、无未知插件表的独立 fixture;外部 completion review 对 blocked 事实直接拒绝,对 Legacy/Target Run History 分别授权 `retain_both/retain_target`,对 Secret/Config 只授权 `manual_external``apply-plan` 消费互相独立的 Automation/review decision,依次完成 Automation apply/verify、双侧终态 Run History preservation/verify、Secret/Config plan/verify 和 decision prepare,停在 `secret_config_decision_required``complete` 再消费独立 Secret/Config decision,完成 decision/apply/verify,并以 completion v3 同时绑定三类 adapter,要求 `reconciliation_completed``adapterCount=3`。Run History 必须位于 Automation apply 与会推进 head 的 Secret/Config plan 之间。completion 后 target/Legacy 仍 stopped,两个 restart 都是 `not_authorized`。针对 readiness 要求 Apps/Auths 必须存在而旧诊断又无条件阻塞 identity 域的矛盾,仅把“Legacy 已知 identity 表全部为空”收窄为 `informational/catalog_evidence`;任一身份行、未知表、目标 identity 或异常仍 fail-closed。Trial Kit/verification/auditor 升为 `@v11/@v9/@v8`、manifest schemaVersion 12Local milestone 升为 `@v7`/schemaVersion 7,并新增 required `legacyUpgradeReconciliationCompletion=passed`CI 仍独立保留 Automation apply→rollback 实证,completion 不覆盖 recovery 证据。本切片不增加 package、production dependency、daemon/listener/timer/watcher/连接或稳态资源,默认低配 headless 与 Cluster authority 边界不变。 - D-426c3/ADR-0528(源码候选,等待 exact 双架构阶段实物):在 D-426c2 的三阶段回滚链上保留 `apply-rollback`,新增显式 `apply-plan``complete`。完成型 CI 使用完整 2.x schema、空 Apps/Auths、无未知插件表的独立 fixture;外部 completion review 对 blocked 事实直接拒绝,对 Legacy/Target Run History 分别授权 `retain_both/retain_target`,对 Secret/Config 只授权 `manual_external``apply-plan` 消费互相独立的 Automation/review decision,依次完成 Automation apply/verify、双侧终态 Run History preservation/verify、Secret/Config plan/verify 和 decision prepare,停在 `secret_config_decision_required``complete` 再消费独立 Secret/Config decision,完成 decision/apply/verify,并以 completion v3 同时绑定三类 adapter,要求 `reconciliation_completed``adapterCount=3`。Run History 必须位于 Automation apply 与会推进 head 的 Secret/Config plan 之间。completion 后 target/Legacy 仍 stopped,两个 restart 都是 `not_authorized`。针对 readiness 要求 Apps/Auths 必须存在而旧诊断又无条件阻塞 identity 域的矛盾,仅把“Legacy 已知 identity 表全部为空”收窄为 `informational/catalog_evidence`;任一身份行、未知表、目标 identity 或异常仍 fail-closed。Trial Kit/verification/auditor 升为 `@v11/@v9/@v8`、manifest schemaVersion 12Local milestone 升为 `@v7`/schemaVersion 7,并新增 required `legacyUpgradeReconciliationCompletion=passed`CI 仍独立保留 Automation apply→rollback 实证,completion 不覆盖 recovery 证据。本切片不增加 package、production dependency、daemon/listener/timer/watcher/连接或稳态资源,默认低配 headless 与 Cluster authority 边界不变。
@@ -1,6 +1,6 @@
# ADR-0529:有界只读 Local 旧面板 Cron Adapter # ADR-0529:有界只读 Local 旧面板 Cron Adapter
- 状态:Proposed(源码候选,尚未进入双架构阶段实物) - 状态:AcceptedD-427 双架构阶段实物已交付
- 日期:2026-09-02 - 日期:2026-09-02
- 关联 RFCQL-RFC-0001 D-427、D-423、D-424、D-426c3 - 关联 RFCQL-RFC-0001 D-427、D-423、D-424、D-426c3
@@ -25,7 +25,7 @@ QingLong 2.x 面板以 `/api/crons`、数值型 Cron 行和 `{code,data}` 包络
- 接受旧页面初始读取所需的 `page``size`、空 `searchValue`、空 `filters={}` 和 Axios cache-buster `t` - 接受旧页面初始读取所需的 `page``size`、空 `searchValue`、空 `filters={}` 和 Axios cache-buster `t`
- 非空搜索、排序、View query 或其他字段暂时返回 `400 invalid_panel_cron_list_query`,不能静默忽略并给出错误结果。 - 非空搜索、排序、View query 或其他字段暂时返回 `400 invalid_panel_cron_list_query`,不能静默忽略并给出错误结果。
- `size` 最大 64`page * size` Edge 最大 64、Standalone 最大 256。Adapter 用同一上限向 Trigger source 做一次有界 keyset 前缀读取,再截取所需页。 - `size` 最大 64页起点 `(page - 1) * size` 必须小于 Edge 64、Standalone 256 行预算。Adapter `min(page * size, maximumRows)` 向 Trigger source 做一次有界 keyset 前缀读取,再截取所需页,因此最后一个不足整页的窗口仍可到达,但不能越过 Profile 上限
- `total` 是当前已观察前缀加一个 `truncated` 继续标记;它足以让旧分页逐页推进,但不执行无界 COUNT 或全表扫描。 - `total` 是当前已观察前缀加一个 `truncated` 继续标记;它足以让旧分页逐页推进,但不执行无界 COUNT 或全表扫描。
### 领域映射 ### 领域映射
@@ -48,7 +48,7 @@ QingLong 2.x 面板以 `/api/crons`、数值型 Cron 行和 `{code,data}` 包络
## 验证与后续门禁 ## 验证与后续门禁
源码候选必须通过: 本切片通过:
1. Adapter 单测:分页、禁用合成、pinned identity、未知 schema、预算和 storage failure 1. Adapter 单测:分页、禁用合成、pinned identity、未知 schema、预算和 storage failure
2. HTTP 契约:编码的 `{}` 查询、正式 operation 解析、拒绝非空搜索且不进入 Admission; 2. HTTP 契约:编码的 `{}` 查询、正式 operation 解析、拒绝非空搜索且不进入 Admission;
@@ -56,4 +56,6 @@ QingLong 2.x 面板以 `/api/crons`、数值型 Cron 行和 `{code,data}` 包络
4. 真实 SQLite 集成:正式 credential、Policy、Task/Trigger revision 和 durable audit,且响应不出现真实 argv; 4. 真实 SQLite 集成:正式 credential、Policy、Task/Trigger revision 和 durable audit,且响应不出现真实 argv;
5. Local API 全包、18-package build/test、dependency/import、Console/Headless image 与双架构 artifact 门。 5. Local API 全包、18-package build/test、dependency/import、Console/Headless image 与双架构 artifact 门。
后续按 `health/system/user capability → Run/Log read → 显式写操作` 推进。只有改造版面板取消 Local Storage credential、按 capability 隐藏未实现页面,并完成真实浏览器 journey 后,才能声明“现有面板页面可复用”;完整 2.x 零改兼容不作为 3.0 目标 提交 `83966a128705f6ef0ae54367c8f656e3d578e9c6` 的显式 Local headless [run 33576693917](https://github.com/whyour/qinglong/actions/runs/33576693917) 与 Local Console [run 33578754251](https://github.com/whyour/qinglong/actions/runs/33578754251) 均成功。headless amd64/arm64/milestone artifact 为 `9827434236`/`9827259756`/`9827553099`Console 为 `9828792297`/`9828714089`/`9828941802`;均绑定同一 source revision,保留至 2026-10-02。headless 继续不携带 Local API/旧面板;Console artifact 携带本 Adapter,但仍只证明后端兼容切片,不包含改造后的 2.x 静态面板
后续按 `health/system/user capability → Run/Log read → 显式写操作` 推进。ADR-0530 已完成 capability/bootstrap 与旧面板源码的内存凭据、菜单降级、只读 Crontab 适配;仍须完成同源静态资源装配和真实浏览器 journey,才能把该页面作为可下载阶段产物交付。完整 2.x 零改兼容不作为 3.0 目标。
@@ -0,0 +1,49 @@
# ADR-0530:有界 Local 面板能力发现与启动适配
- 状态:Proposed(D-428 源码候选,等待可下载同源装配与双架构实物门)
- 日期:2026-09-02
- 关联 RFCQL-RFC-0001 D-428、D-427、D-423、D-424
## 背景
ADR-0529 已交付认证后的只读 `/api/crons` Adapter,但现有 2.x 面板启动时还会读取 `/api/system``/api/system/config``/api/user`,并假设用户名/密码登录、Local Storage JWT、完整菜单和 WebSocket 可用。仅有 Cron API 时,页面仍不能安全接入;若伪造完整 2.x 能力,则会把尚未实现的写操作、Env、Script、Subscription 和日志入口暴露给用户。
因此第二切片必须先让页面明确发现“这是 QingLong 3.0、当前 Profile 有哪些能力”,再只启用已闭合的页面和认证方式。兼容层继续是翻译边界,不恢复旧 Express Service、JWT authority 或数据库直写。
## 决策
### 能力与启动契约
- Local API 新增公开只读 `GET /api/health``GET /api/system``GET /api/v3/capabilities`。它们只接受无 body 的 exact path 与可选单个数字 `t`,仅返回版本、Local Profile、认证种类、能力开关和有界预算,不返回 principal、credential、Project 数据或存储路径。
- `GET /api/user``GET /api/system/config` 继续走正式 Bearer authentication、`task.read` Policy、durable audit 和 credential reconfirmoperation 固定为 `panel.user.get``panel.system.config.get`,且面板身份只接受 User principalservice credential 失败关闭。
- capability schema 固定为 v1。Edge 最多暴露 64 条 Cron、16 KiB 日志块;Standalone 最多 256 条 Cron、32 KiB 日志块;单页最多 64 条。Cluster 不复用该 Local capability shell,后续由独立 Panel Gateway 发布其能力。
- 明确声明 `legacyLogin=false``legacyMutations=false``subscriptions=false``scripts=false``environmentVariables=false``webSocket=false`。现有 v3 Task/Trigger/Run/Log 读 API 可以存在,但本切片的旧页面只启用 Crontab 只读投影。
### 现有面板源码降级
- 面板在登录页同源读取 `/api/v3/capabilities`;只有严格匹配 v1 契约才进入 QingLong 3.0 模式,否则保持 2.x 行为。
- QingLong 3.0 模式只接受 `ql3c_` API Credential。凭据只保存在当前 JavaScript 模块内存,通过 Authorization Bearer 发送;不写 Local Storage、Cookie、IndexedDB 或 URL,刷新和关闭页面后必须重新输入。
- 成功读取 `/api/user` 后进入 `/crontab`。菜单只保留登录、错误页和定时任务;不建立 WebSocket,不请求 Cron View、Subscription 或非空搜索/排序。
- Crontab 只展示名称、命令描述符、状态和计划;隐藏创建、选择、批量操作、详情、脚本跳转、日志和全部写 Modal。分页被 capability budget 限制,最后一个不足整页的 Edge/Standalone 窗口仍可读取。
- 2.x 模式的数据流、Local Storage token、登录接口、菜单和页面行为保持不变。
## 部署与资源边界
- 默认 headless 产物不包含 `@qinglong/local-api` 或旧面板,不新增端口、连接、timer、watcher、后台进程或稳态内存。
- 当前约 33 MiB 的旧面板源码构建结果不自动塞入 headless/Console Alpha;同源静态资源装配与体积预算必须作为独立门完成。
- 旧 Umi 前端暂用 Node 20 构建只是 legacy migration toolchain,不改变 QingLong 3.0 Node 24 runtime、双架构镜像或支持等级。该过渡门必须在 CI 中独立命名,不能让 Node 20 定义新 package 的运行时兼容性。
- Edge 与 Standalone 使用同一代码、不同预算;Cluster 节点不加载 Local SQLite/POSIX authority,也不通过本 Adapter 访问控制面。
## 验证与剩余门禁
源码候选已通过 Local API 12-package closure build、89/89 测试、真实 SQLite/credential/Policy/audit HTTP 集成、Node 20 的旧面板 production build、18-package 完整测试与 package/import/dependency audit。Playwright 同源源码旅程也已验证 capability 登录页、内存 credential、只读 Crontab、隐藏写入口与排序/过滤,以及刷新后回到登录页;最终页为 0 console error,仅保留既有国际化 warning。远端 CI 和可下载实物仍待闭合。
仍未完成:
1. 在一个可下载 Console 产物中同源装配改造后的面板静态资源,并证明 CSP、缓存和 API 路由优先级;
2. 在装配后的 exact Console + Local API + SQLite 上使用真实 `ql3c_` credential 重跑登录 → `/crontab` → 分页 → 401/刷新清凭据的浏览器 journey
3. 为旧页面增加 Run/Log 只读 adapter 后再开放日志入口;
4. 双架构资源与 artifact gate,以及对面板体积的可解释预算;
5. 写操作必须逐项映射到 3.0 revision、Policy、presence/approval、audit 和 mutation fence,不能用通配兼容路由一次性开放。
在上述门禁完成前,本 ADR 只说明“现有面板源码能受控接入”,不声明当前已发布 Console artifact 包含该页面,也不声明 2.x 面板可以零修改直连。
+2 -1
View File
@@ -532,7 +532,8 @@
| [ADR-0526](./ADR-0526-exact-post-write-reconciliation-capture.md) | Exact 写后 Reconciliation Capture | Accepted(同源 exact Console 双架构阶段实物已交付) | | [ADR-0526](./ADR-0526-exact-post-write-reconciliation-capture.md) | Exact 写后 Reconciliation Capture | Accepted(同源 exact Console 双架构阶段实物已交付) |
| [ADR-0527](./ADR-0527-reviewed-automation-reconciliation-application-and-rollback.md) | 受审核 Automation Reconciliation 应用与显式回滚 | Acceptedexact headless 双架构阶段实物已交付) | | [ADR-0527](./ADR-0527-reviewed-automation-reconciliation-application-and-rollback.md) | 受审核 Automation Reconciliation 应用与显式回滚 | Acceptedexact headless 双架构阶段实物已交付) |
| [ADR-0528](./ADR-0528-cross-domain-reconciliation-completion-rehearsal.md) | 跨域 Reconciliation Completion 演练 | AcceptedD-426c3 源码候选;双架构阶段实物待 gate) | | [ADR-0528](./ADR-0528-cross-domain-reconciliation-completion-rehearsal.md) | 跨域 Reconciliation Completion 演练 | AcceptedD-426c3 源码候选;双架构阶段实物待 gate) |
| [ADR-0529](./ADR-0529-bounded-read-only-local-panel-cron-adapter.md) | 有界只读 Local 旧面板 Cron Adapter | ProposedD-427 源码候选;双架构阶段实物待 gate | | [ADR-0529](./ADR-0529-bounded-read-only-local-panel-cron-adapter.md) | 有界只读 Local 旧面板 Cron Adapter | AcceptedD-427 双架构阶段实物已交付 |
| [ADR-0530](./ADR-0530-bounded-local-panel-capability-bootstrap.md) | 有界 Local 面板能力发现与启动适配 | Proposed(D-428 源码候选;可下载同源装配与双架构实物待 gate) |
## 规则 ## 规则
@@ -38,6 +38,7 @@ import type {
LocalApiSecretPutRoute, LocalApiSecretPutRoute,
} from '../secret/secretRoutes'; } from '../secret/secretRoutes';
import type { PanelCronListRoute } from '../panel-compatibility/panelCronListRoute'; import type { PanelCronListRoute } from '../panel-compatibility/panelCronListRoute';
import type { PanelBootstrapRoute } from '../panel-compatibility/panelBootstrapRoute';
import type { LocalApiResponse } from '../transport/contract'; import type { LocalApiResponse } from '../transport/contract';
export type LocalApiAdmissionOperation = export type LocalApiAdmissionOperation =
@@ -133,6 +134,10 @@ export type LocalApiAdmissionOperation =
page: number; page: number;
size: number; size: number;
maximumRows: number; maximumRows: number;
}>
| Readonly<{
operationId: 'panel.user.get' | 'panel.system.config.get';
projectId: 'default';
}>; }>;
export interface LocalApiAdmissionRequest { export interface LocalApiAdmissionRequest {
@@ -177,6 +182,7 @@ export interface LocalApiAdmissionOptions {
readonly secretListRoute: LocalApiSecretListRoute; readonly secretListRoute: LocalApiSecretListRoute;
readonly secretPutRoute: LocalApiSecretPutRoute; readonly secretPutRoute: LocalApiSecretPutRoute;
readonly panelCronListRoute: PanelCronListRoute; readonly panelCronListRoute: PanelCronListRoute;
readonly panelBootstrapRoute: PanelBootstrapRoute;
readonly now?: () => number; readonly now?: () => number;
readonly randomUuid?: () => string; readonly randomUuid?: () => string;
} }
@@ -263,6 +269,7 @@ export function createLocalApiAdmission(
typeof options.secretListRoute?.handle !== 'function' || typeof options.secretListRoute?.handle !== 'function' ||
typeof options.secretPutRoute?.handle !== 'function' || typeof options.secretPutRoute?.handle !== 'function' ||
typeof options.panelCronListRoute?.handle !== 'function' || typeof options.panelCronListRoute?.handle !== 'function' ||
typeof options.panelBootstrapRoute?.handle !== 'function' ||
(options.now !== undefined && typeof options.now !== 'function') || (options.now !== undefined && typeof options.now !== 'function') ||
(options.randomUuid !== undefined && (options.randomUuid !== undefined &&
typeof options.randomUuid !== 'function') typeof options.randomUuid !== 'function')
@@ -404,7 +411,9 @@ export function createLocalApiAdmission(
request.operation.operationId === 'task.get' || request.operation.operationId === 'task.get' ||
request.operation.operationId === 'trigger.list' || request.operation.operationId === 'trigger.list' ||
request.operation.operationId === 'trigger.get' || request.operation.operationId === 'trigger.get' ||
request.operation.operationId === 'panel.cron.list' request.operation.operationId === 'panel.cron.list' ||
request.operation.operationId === 'panel.user.get' ||
request.operation.operationId === 'panel.system.config.get'
? 'task.read' ? 'task.read'
: request.operation.operationId === 'secret.list' : request.operation.operationId === 'secret.list'
? 'secret.manage' ? 'secret.manage'
@@ -572,6 +581,13 @@ export function createLocalApiAdmission(
size: request.operation.size, size: request.operation.size,
maximumRows: request.operation.maximumRows, maximumRows: request.operation.maximumRows,
}); });
case 'panel.user.get':
case 'panel.system.config.get':
if (body !== null) return response(400, 'invalid_request_body');
return options.panelBootstrapRoute.handle({
operationId: request.operation.operationId,
principal: authenticated.principal,
});
case 'task.put': case 'task.put':
case 'task.authoring': case 'task.authoring':
case 'trigger.put': case 'trigger.put':
@@ -32,6 +32,7 @@ import {
createLocalApiSecretPutRoute, createLocalApiSecretPutRoute,
} from '../secret/secretRoutes'; } from '../secret/secretRoutes';
import { createPanelCronListRoute } from '../panel-compatibility/panelCronListRoute'; import { createPanelCronListRoute } from '../panel-compatibility/panelCronListRoute';
import { createPanelBootstrapRoute } from '../panel-compatibility/panelBootstrapRoute';
import { startLocalApiHttpSurface } from '../transport/httpSurface'; import { startLocalApiHttpSurface } from '../transport/httpSurface';
export interface LocalApiProductSurfaceEvent { export interface LocalApiProductSurfaceEvent {
@@ -226,6 +227,7 @@ export function createLocalApiProductSurface(
tasks: authority.taskDefinitions, tasks: authority.taskDefinitions,
triggers: authority.triggers, triggers: authority.triggers,
}); });
const panelBootstrapRoute = createPanelBootstrapRoute(authority.profile);
const admission = createLocalApiAdmission({ const admission = createLocalApiAdmission({
authenticator, authenticator,
policy, policy,
@@ -247,6 +249,7 @@ export function createLocalApiProductSurface(
secretListRoute, secretListRoute,
secretPutRoute, secretPutRoute,
panelCronListRoute, panelCronListRoute,
panelBootstrapRoute,
...(options.now === undefined ? {} : { now: options.now }), ...(options.now === undefined ? {} : { now: options.now }),
...(options.randomUuid === undefined ...(options.randomUuid === undefined
? {} ? {}
@@ -0,0 +1,169 @@
import type { LocalApplicationProfile } from '@qinglong/local-application';
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
import type { LocalApiResponse } from '../transport/contract';
const PRODUCT_VERSION = '3.0.0-alpha.2';
export type PanelBootstrapOperation =
| 'panel.user.get'
| 'panel.system.config.get';
export interface PanelBootstrapRequest {
readonly operationId: PanelBootstrapOperation;
readonly principal: Readonly<SecurityPrincipal>;
}
export interface PanelBootstrapRoute {
handle(request: Readonly<PanelBootstrapRequest>): Promise<LocalApiResponse>;
}
function response(
statusCode: number,
body: Readonly<Record<string, unknown>>,
): Readonly<LocalApiResponse> {
return Object.freeze({ statusCode, body: Object.freeze(body) });
}
function maximumRows(profile: LocalApplicationProfile): number {
return profile === 'edge' ? 64 : 256;
}
export function panelCapabilities(
profile: LocalApplicationProfile,
): Readonly<Record<string, unknown>> {
if (profile !== 'edge' && profile !== 'standalone') {
throw new TypeError('Panel capability profile is invalid');
}
const logChunkBytes = profile === 'edge' ? 16 * 1_024 : 32 * 1_024;
return Object.freeze({
schemaVersion: 1,
product: 'qinglong3',
version: PRODUCT_VERSION,
deployment: Object.freeze({ mode: 'local', profile }),
authentication: Object.freeze({
kind: 'api_credential',
transport: 'bearer',
persistence: 'memory_only',
loginEndpoint: null,
}),
project: Object.freeze({ selection: 'explicit', defaultId: 'default' }),
panel: Object.freeze({
bootstrap: true,
cronList: 'bounded_read_only',
taskRead: true,
triggerRead: true,
runRead: true,
runLogRead: true,
legacyMutations: false,
legacyLogin: false,
subscriptions: false,
scripts: false,
environmentVariables: false,
webSocket: false,
}),
limits: Object.freeze({
cronRows: maximumRows(profile),
cronPageSize: 64,
logChunkBytes,
}),
});
}
export function panelPublicResponse(
operation: 'capabilities' | 'health' | 'system',
profile: LocalApplicationProfile,
): Readonly<LocalApiResponse> {
const capabilities = panelCapabilities(profile);
if (operation === 'capabilities') {
return response(200, { capabilities });
}
if (operation === 'health') {
return response(200, {
code: 200,
data: Object.freeze({
status: 'ok',
ql3: Object.freeze({
schemaVersion: 1,
apiVersion: 'v3',
capabilitiesPath: '/api/v3/capabilities',
}),
}),
});
}
return response(200, {
code: 200,
data: Object.freeze({
branch: 'develop',
isInitialized: true,
publishTime: 0,
version: PRODUCT_VERSION,
changeLog: '',
changeLogLink: '',
ql3: Object.freeze({
schemaVersion: 1,
mode: 'local',
profile,
capabilitiesPath: '/api/v3/capabilities',
}),
}),
});
}
export function createPanelBootstrapRoute(
profile: LocalApplicationProfile,
): Readonly<PanelBootstrapRoute> {
if (profile !== 'edge' && profile !== 'standalone') {
throw new TypeError('Panel bootstrap profile is invalid');
}
return Object.freeze({
async handle(request: Readonly<PanelBootstrapRequest>) {
const principal = request?.principal;
if (
!principal ||
typeof principal !== 'object' ||
Array.isArray(principal) ||
principal.subject?.type !== 'user' ||
typeof principal.subject?.id !== 'string' ||
principal.subject.id.length < 1
) {
return response(503, {
code: 503,
message: 'QL3 面板身份暂不可用',
});
}
if (request.operationId === 'panel.user.get') {
return response(200, {
code: 200,
data: Object.freeze({
username: principal.subject.id,
ql3: Object.freeze({
schemaVersion: 1,
subjectType: principal.subject.type,
assurance: principal.assurance,
expiresAtMs: principal.expiresAtMs,
credentialPersistence: 'memory_only',
panelHome: '/crontab',
}),
}),
});
}
if (request.operationId !== 'panel.system.config.get') {
return response(503, {
code: 503,
message: 'QL3 面板启动入口暂不可用',
});
}
return response(200, {
code: 200,
data: Object.freeze({
info: Object.freeze({
panelTitle: 'QingLong 3.0',
lang: 'zh-cn',
}),
ql3: panelCapabilities(profile),
}),
});
},
});
}
@@ -49,7 +49,7 @@ function validRequest(request: Readonly<PanelCronListRequest>): boolean {
Number.isSafeInteger(request.maximumRows) && Number.isSafeInteger(request.maximumRows) &&
request.maximumRows >= 1 && request.maximumRows >= 1 &&
request.maximumRows <= 256 && request.maximumRows <= 256 &&
request.page * request.size <= request.maximumRows (request.page - 1) * request.size < request.maximumRows
); );
} }
@@ -72,7 +72,10 @@ export function createPanelCronListRoute(
return response(400, { code: 400, message: '参数错误' }); return response(400, { code: 400, message: '参数错误' });
} }
try { try {
const scanLimit = request.page * request.size; const scanLimit = Math.min(
request.page * request.size,
request.maximumRows,
);
const page = await sources.triggers.listTriggers({ const page = await sources.triggers.listTriggers({
projectId: request.projectId, projectId: request.projectId,
limit: scanLimit, limit: scanLimit,
@@ -149,7 +152,10 @@ export function createPanelCronListRoute(
code: 200, code: 200,
data: Object.freeze({ data: Object.freeze({
data: Object.freeze(data), data: Object.freeze(data),
total: page.triggers.length + (page.truncated ? 1 : 0), total: Math.min(
request.maximumRows,
page.triggers.length + (page.truncated ? 1 : 0),
),
}), }),
}); });
} catch { } catch {
@@ -18,6 +18,7 @@ import {
loadLocalConsoleAssets, loadLocalConsoleAssets,
type LocalConsoleAsset, type LocalConsoleAsset,
} from '../console/localConsoleAssets'; } from '../console/localConsoleAssets';
import { panelPublicResponse } from '../panel-compatibility/panelBootstrapRoute';
import type { LocalApiResponse } from './contract'; import type { LocalApiResponse } from './contract';
const MAX_HEADER_BYTES = 8 * 1_024; const MAX_HEADER_BYTES = 8 * 1_024;
@@ -65,9 +66,12 @@ type LocalApiRouteResolution =
| 'invalid_task_list_query' | 'invalid_task_list_query'
| 'invalid_trigger_list_query' | 'invalid_trigger_list_query'
| 'invalid_secret_list_query' | 'invalid_secret_list_query'
| 'invalid_panel_cron_list_query'; | 'invalid_panel_cron_list_query'
| 'invalid_panel_bootstrap_query';
}>; }>;
type PanelPublicOperation = 'capabilities' | 'health' | 'system';
export interface LocalApiHttpSurfaceOptions { export interface LocalApiHttpSurfaceOptions {
readonly profile: LocalApplicationProfile; readonly profile: LocalApplicationProfile;
readonly host: '127.0.0.1' | '::1'; readonly host: '127.0.0.1' | '::1';
@@ -589,7 +593,7 @@ function parsePanelCronListRoute(
size < 1 || size < 1 ||
size > 64 || size > 64 ||
String(size) !== rawSize || String(size) !== rawSize ||
page * size > maximumRows || (page - 1) * size >= maximumRows ||
(timestamp !== null && !/^\d{1,20}$/u.test(timestamp)) (timestamp !== null && !/^\d{1,20}$/u.test(timestamp))
) { ) {
throw new TypeError(); throw new TypeError();
@@ -606,6 +610,53 @@ function parsePanelCronListRoute(
} }
} }
function panelExactGetPath(
rawUrl: string,
expectedPath: string,
): 'invalid' | 'match' | 'unrelated' {
const separator = rawUrl.indexOf('?');
const path = separator < 0 ? rawUrl : rawUrl.slice(0, separator);
if (path !== expectedPath) return 'unrelated';
if (separator !== rawUrl.lastIndexOf('?')) return 'invalid';
if (separator < 0) return 'match';
try {
const query = new URLSearchParams(rawUrl.slice(separator + 1));
if (
[...query.keys()].some(
(key) => key !== 't' || query.getAll(key).length !== 1,
)
) {
return 'invalid';
}
const timestamp = query.get('t');
return timestamp !== null && /^\d{1,20}$/u.test(timestamp)
? 'match'
: 'invalid';
} catch {
return 'invalid';
}
}
function panelPublicOperation(
request: IncomingMessage,
): PanelPublicOperation | 'invalid' | null {
if (request.method !== 'GET' || typeof request.url !== 'string') return null;
const paths = Object.freeze([
Object.freeze({ path: '/api/health', operation: 'health' as const }),
Object.freeze({ path: '/api/system', operation: 'system' as const }),
Object.freeze({
path: '/api/v3/capabilities',
operation: 'capabilities' as const,
}),
]);
for (const candidate of paths) {
const result = panelExactGetPath(request.url, candidate.path);
if (result === 'invalid') return 'invalid';
if (result === 'match') return candidate.operation;
}
return null;
}
function route( function route(
request: IncomingMessage, request: IncomingMessage,
profile: LocalApplicationProfile, profile: LocalApplicationProfile,
@@ -622,6 +673,24 @@ function route(
if (request.method === 'GET') { if (request.method === 'GET') {
const panelCronList = parsePanelCronListRoute(rawUrl, profile); const panelCronList = parsePanelCronListRoute(rawUrl, profile);
if (panelCronList) return panelCronList; if (panelCronList) return panelCronList;
const panelUser = panelExactGetPath(rawUrl, '/api/user');
if (panelUser !== 'unrelated') {
return panelUser === 'match'
? Object.freeze({
operationId: 'panel.user.get',
projectId: 'default',
})
: Object.freeze({ errorCode: 'invalid_panel_bootstrap_query' });
}
const panelSystemConfig = panelExactGetPath(rawUrl, '/api/system/config');
if (panelSystemConfig !== 'unrelated') {
return panelSystemConfig === 'match'
? Object.freeze({
operationId: 'panel.system.config.get',
projectId: 'default',
})
: Object.freeze({ errorCode: 'invalid_panel_bootstrap_query' });
}
} }
if (rawUrl.includes('%')) return null; if (rawUrl.includes('%')) return null;
const separator = rawUrl.indexOf('?'); const separator = rawUrl.indexOf('?');
@@ -940,6 +1009,22 @@ export async function startLocalApiHttpSurface(
sendConsoleFavicon(response, requestId); sendConsoleFavicon(response, requestId);
return; return;
} }
const publicOperation = panelPublicOperation(request);
if (publicOperation) {
if (hasRequestBody(request)) {
send(response, requestId, errorResponse(400, 'invalid_request_body'));
request.resume();
return;
}
send(
response,
requestId,
publicOperation === 'invalid'
? errorResponse(400, 'invalid_panel_bootstrap_query')
: panelPublicResponse(publicOperation, options.profile),
);
return;
}
const resolvedRoute = route(request, options.profile); const resolvedRoute = route(request, options.profile);
if (!resolvedRoute) { if (!resolvedRoute) {
send(response, requestId, errorResponse(404, 'route_not_found')); send(response, requestId, errorResponse(404, 'route_not_found'));
@@ -314,6 +314,17 @@ function fixture(overrides = {}) {
}; };
}, },
}, },
panelBootstrapRoute: {
async handle(value) {
events.push(
`panel-bootstrap:${value.operationId}:${value.principal.subject.id}`,
);
return {
statusCode: 200,
body: { code: 200, data: { username: value.principal.subject.id } },
};
},
},
now: () => 10_000, now: () => 10_000,
randomUuid: () => '019f70c0-0000-4000-8000-000000000002', randomUuid: () => '019f70c0-0000-4000-8000-000000000002',
...overrides, ...overrides,
@@ -548,6 +559,27 @@ test('uses task.read and the exact panel Cron audit identity for the compatibili
]); ]);
}); });
test('uses the same authenticated task.read chain for the panel bootstrap identity', async () => {
for (const operationId of ['panel.user.get', 'panel.system.config.get']) {
const { admission, events } = fixture();
const result = await execute(
admission,
request({
operation: Object.freeze({ operationId, projectId: 'default' }),
}),
);
assert.equal(result.statusCode, 200);
assert.equal(result.body.data.username, 'usr_local');
assert.deepEqual(events, [
'authenticate',
'authorize:task.read:default',
`audit:allowed:${operationId}`,
'confirm',
`panel-bootstrap:${operationId}:usr_local`,
]);
}
});
test('authorizes and audits run.stop before exposing the cancellation body handler', async () => { test('authorizes and audits run.stop before exposing the cancellation body handler', async () => {
const { admission, events } = fixture(); const { admission, events } = fixture();
const prepared = await admission.prepare( const prepared = await admission.prepare(
@@ -277,6 +277,14 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
}); });
assert.deepEqual(observed[6].operation, panelCrons.body.input); assert.deepEqual(observed[6].operation, panelCrons.body.input);
const finalEdgePage = await request(
port,
'/api/crons?searchValue=&page=4&size=20&filters=%7B%7D',
);
assert.equal(finalEdgePage.statusCode, 200);
assert.equal(finalEdgePage.body.input.maximumRows, 64);
assert.equal(finalEdgePage.body.input.page, 4);
const unsupportedPanelQuery = await request( const unsupportedPanelQuery = await request(
port, port,
'/api/crons?searchValue=private&page=1&size=20&filters=%7B%7D', '/api/crons?searchValue=private&page=1&size=20&filters=%7B%7D',
@@ -285,7 +293,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
code: 'invalid_panel_cron_list_query', code: 'invalid_panel_cron_list_query',
}); });
assert.equal(unsupportedPanelQuery.statusCode, 400); assert.equal(unsupportedPanelQuery.statusCode, 400);
assert.equal(observed.length, 7); assert.equal(observed.length, 8);
const cancellationBody = JSON.stringify({ const cancellationBody = JSON.stringify({
schema: 'qinglong/run-cancellation@v1', schema: 'qinglong/run-cancellation@v1',
@@ -306,7 +314,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
); );
assert.equal(cancellation.statusCode, 202); assert.equal(cancellation.statusCode, 202);
assert.deepEqual(cancellation.body.accepted, JSON.parse(cancellationBody)); assert.deepEqual(cancellation.body.accepted, JSON.parse(cancellationBody));
assert.deepEqual(observed[7].operation, { assert.deepEqual(observed[8].operation, {
operationId: 'run.cancel', operationId: 'run.cancel',
projectId: 'prj_default', projectId: 'prj_default',
runId: 'run_123', runId: 'run_123',
@@ -333,7 +341,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
); );
assert.equal(taskStart.statusCode, 202); assert.equal(taskStart.statusCode, 202);
assert.deepEqual(taskStart.body.accepted, JSON.parse(taskStartBody)); assert.deepEqual(taskStart.body.accepted, JSON.parse(taskStartBody));
assert.deepEqual(observed[8].operation, { assert.deepEqual(observed[9].operation, {
operationId: 'task.start', operationId: 'task.start',
projectId: 'prj_default', projectId: 'prj_default',
taskId: 'task_1', taskId: 'task_1',
@@ -357,20 +365,20 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
); );
assert.equal(taskPut.statusCode, 202); assert.equal(taskPut.statusCode, 202);
assert.deepEqual(taskPut.body.accepted, JSON.parse(taskPutBody)); assert.deepEqual(taskPut.body.accepted, JSON.parse(taskPutBody));
assert.deepEqual(observed[9].operation, { assert.deepEqual(observed[10].operation, {
operationId: 'task.put', operationId: 'task.put',
projectId: 'prj_default', projectId: 'prj_default',
taskId: 'task_1', taskId: 'task_1',
}); });
assert.equal(observed[9].localPresence, 'ql3p_request_bound_proof'); assert.equal(observed[10].localPresence, 'ql3p_request_bound_proof');
assert.equal(observed[9].taskAuthoringLease, 'ql3a_exact_snapshot_lease'); assert.equal(observed[10].taskAuthoringLease, 'ql3a_exact_snapshot_lease');
const log = await request( const log = await request(
port, port,
'/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?offset=4&length=32', '/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?offset=4&length=32',
); );
assert.deepEqual(log.body, { range: { offset: 4, length: 32 } }); assert.deepEqual(log.body, { range: { offset: 4, length: 32 } });
assert.deepEqual(observed[10].operation, { assert.deepEqual(observed[11].operation, {
operationId: 'run.log.read', operationId: 'run.log.read',
projectId: 'prj_default', projectId: 'prj_default',
runId: 'run_123', runId: 'run_123',
@@ -399,19 +407,19 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
}, },
); );
assert.equal(authoring.statusCode, 200); assert.equal(authoring.statusCode, 200);
assert.deepEqual(observed[12].operation, { assert.deepEqual(observed[13].operation, {
operationId: 'task.authoring', operationId: 'task.authoring',
projectId: 'prj_default', projectId: 'prj_default',
taskId: 'task_1', taskId: 'task_1',
}); });
assert.equal(observed[12].localPresence, 'ql3p_authoring_read_proof'); assert.equal(observed[13].localPresence, 'ql3p_authoring_read_proof');
const secrets = await request( const secrets = await request(
port, port,
'/api/v3/projects/prj_default/secrets?limit=8&after=YWxwaGE', '/api/v3/projects/prj_default/secrets?limit=8&after=YWxwaGE',
); );
assert.deepEqual(secrets.body, { secrets: [], truncated: false }); assert.deepEqual(secrets.body, { secrets: [], truncated: false });
assert.deepEqual(observed[13].operation, { assert.deepEqual(observed[14].operation, {
operationId: 'secret.list', operationId: 'secret.list',
projectId: 'prj_default', projectId: 'prj_default',
limit: 8, limit: 8,
@@ -435,11 +443,11 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
); );
assert.equal(secretPut.statusCode, 202); assert.equal(secretPut.statusCode, 202);
assert.deepEqual(secretPut.body.accepted, JSON.parse(secretPutBody)); assert.deepEqual(secretPut.body.accepted, JSON.parse(secretPutBody));
assert.deepEqual(observed[14].operation, { assert.deepEqual(observed[15].operation, {
operationId: 'secret.put', operationId: 'secret.put',
projectId: 'prj_default', projectId: 'prj_default',
}); });
assert.equal(observed[14].localPresence, 'ql3p_secret_bound_proof'); assert.equal(observed[15].localPresence, 'ql3p_secret_bound_proof');
for (const invalidPath of [ for (const invalidPath of [
'/api/v3/projects/prj_default/runs/run_123?expanded=true', '/api/v3/projects/prj_default/runs/run_123?expanded=true',
@@ -521,13 +529,86 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
assert.equal(invalid.statusCode, 400); assert.equal(invalid.statusCode, 400);
assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' }); assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' });
} }
assert.equal(observed.length, 15); assert.equal(observed.length, 16);
assert.deepEqual( assert.deepEqual(
await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]), await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]),
['stopped', 'stopped'], ['stopped', 'stopped'],
); );
}); });
test('serves the public capability shell and authenticates private panel bootstrap reads', async (t) => {
const port = await reservePort();
const observed = [];
const surface = await startLocalApiHttpSurface({
profile: 'edge',
host: '127.0.0.1',
port,
admission: preparedAdmission(async (value) => {
observed.push(value);
return {
statusCode: 200,
body: {
code: 200,
data: { operationId: value.operation.operationId },
},
};
}),
randomUuid: () => '019f70c0-0000-4000-8000-000000000013',
});
t.after(() => surface.stopAndDrain());
const health = await request(port, '/api/health?t=100', { headers: {} });
assert.equal(health.statusCode, 200);
assert.equal(health.body.data.status, 'ok');
assert.equal(health.body.data.ql3.capabilitiesPath, '/api/v3/capabilities');
const system = await request(port, '/api/system', { headers: {} });
assert.equal(system.statusCode, 200);
assert.equal(system.body.data.isInitialized, true);
assert.equal(system.body.data.ql3.profile, 'edge');
const capabilities = await request(port, '/api/v3/capabilities?t=101', {
headers: {},
});
assert.equal(capabilities.statusCode, 200);
assert.equal(
capabilities.body.capabilities.authentication.loginEndpoint,
null,
);
assert.equal(capabilities.body.capabilities.panel.legacyLogin, false);
assert.equal(capabilities.body.capabilities.limits.cronRows, 64);
assert.equal(observed.length, 0);
const user = await request(port, '/api/user?t=102');
assert.deepEqual(user.body, {
code: 200,
data: { operationId: 'panel.user.get' },
});
assert.deepEqual(observed[0].operation, {
operationId: 'panel.user.get',
projectId: 'default',
});
const config = await request(port, '/api/system/config?t=103');
assert.deepEqual(config.body, {
code: 200,
data: { operationId: 'panel.system.config.get' },
});
assert.deepEqual(observed[1].operation, {
operationId: 'panel.system.config.get',
projectId: 'default',
});
const invalid = await request(port, '/api/system?search=wide', {
headers: {},
});
assert.equal(invalid.statusCode, 400);
assert.deepEqual(invalid.body, { code: 'invalid_panel_bootstrap_query' });
const encoded = await request(port, '/api/%73ystem', { headers: {} });
assert.equal(encoded.statusCode, 404);
assert.equal(observed.length, 2);
});
test('rejects GET bodies without invoking the prepared route handler', async (t) => { test('rejects GET bodies without invoking the prepared route handler', async (t) => {
const port = await reservePort(); const port = await reservePort();
let handlers = 0; let handlers = 0;
@@ -0,0 +1,150 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const {
createPanelBootstrapRoute,
panelCapabilities,
panelPublicResponse,
} = require('../dist/panel-compatibility/panelBootstrapRoute.js');
const PRINCIPAL = Object.freeze({
subject: Object.freeze({ type: 'user', id: 'owner' }),
authenticationId: 'auth:owner',
authenticatedAtMs: 1_787_200_000_000,
expiresAtMs: 1_787_200_060_000,
assurance: 'local_console',
});
test('publishes an exact profile-aware capability contract', () => {
const edge = panelCapabilities('edge');
const standalone = panelCapabilities('standalone');
assert.deepEqual(edge, {
schemaVersion: 1,
product: 'qinglong3',
version: '3.0.0-alpha.2',
deployment: { mode: 'local', profile: 'edge' },
authentication: {
kind: 'api_credential',
transport: 'bearer',
persistence: 'memory_only',
loginEndpoint: null,
},
project: { selection: 'explicit', defaultId: 'default' },
panel: {
bootstrap: true,
cronList: 'bounded_read_only',
taskRead: true,
triggerRead: true,
runRead: true,
runLogRead: true,
legacyMutations: false,
legacyLogin: false,
subscriptions: false,
scripts: false,
environmentVariables: false,
webSocket: false,
},
limits: {
cronRows: 64,
cronPageSize: 64,
logChunkBytes: 16 * 1_024,
},
});
assert.deepEqual(standalone.limits, {
cronRows: 256,
cronPageSize: 64,
logChunkBytes: 32 * 1_024,
});
assert.throws(() => panelCapabilities('cluster'));
});
test('serves public health, system and native capability envelopes', () => {
assert.deepEqual(panelPublicResponse('health', 'edge'), {
statusCode: 200,
body: {
code: 200,
data: {
status: 'ok',
ql3: {
schemaVersion: 1,
apiVersion: 'v3',
capabilitiesPath: '/api/v3/capabilities',
},
},
},
});
const system = panelPublicResponse('system', 'standalone');
assert.equal(system.body.code, 200);
assert.equal(system.body.data.isInitialized, true);
assert.equal(system.body.data.version, '3.0.0-alpha.2');
assert.equal(system.body.data.ql3.profile, 'standalone');
const capabilities = panelPublicResponse('capabilities', 'edge');
assert.equal(capabilities.body.capabilities.limits.cronRows, 64);
});
test('projects the authenticated principal and bounded panel configuration', async () => {
const route = createPanelBootstrapRoute('edge');
const user = await route.handle({
operationId: 'panel.user.get',
principal: PRINCIPAL,
});
assert.deepEqual(user, {
statusCode: 200,
body: {
code: 200,
data: {
username: 'owner',
ql3: {
schemaVersion: 1,
subjectType: 'user',
assurance: 'local_console',
expiresAtMs: 1_787_200_060_000,
credentialPersistence: 'memory_only',
panelHome: '/crontab',
},
},
},
});
const config = await route.handle({
operationId: 'panel.system.config.get',
principal: PRINCIPAL,
});
assert.equal(config.statusCode, 200);
assert.deepEqual(config.body.data.info, {
panelTitle: 'QingLong 3.0',
lang: 'zh-cn',
});
assert.equal(config.body.data.ql3.limits.cronRows, 64);
});
test('fails closed for invalid profile, principal and operation', async () => {
assert.throws(() => createPanelBootstrapRoute('cluster'));
const route = createPanelBootstrapRoute('standalone');
assert.equal(
(await route.handle({ operationId: 'panel.user.get', principal: null }))
.statusCode,
503,
);
assert.equal(
(
await route.handle({
operationId: 'panel.user.get',
principal: {
...PRINCIPAL,
subject: { type: 'api_app', id: 'service' },
assurance: 'service',
},
})
).statusCode,
503,
);
assert.equal(
(
await route.handle({
operationId: 'panel.unknown',
principal: PRINCIPAL,
})
).statusCode,
503,
);
});
@@ -230,6 +230,18 @@ test('fails closed for detached pins, unsupported triggers, invalid budgets and
}, },
}, },
}); });
assert.deepEqual(
await route.handle({
projectId: 'default',
page: 4,
size: 20,
maximumRows: 64,
}),
{
statusCode: 200,
body: { code: 200, data: { data: [], total: 0 } },
},
);
assert.equal( assert.equal(
( (
await route.handle({ await route.handle({
@@ -462,6 +462,34 @@ test('serves an authenticated Run through one real SQLite authority and durable
}); });
t.after(() => active.stopAndDrain()); t.after(() => active.stopAndDrain());
const health = await request(
port,
'Bearer ignored-public-credential',
'/api/health?t=100',
);
assert.equal(health.statusCode, 200);
assert.equal(health.body.data.status, 'ok');
const capabilities = await request(
port,
'Bearer ignored-public-credential',
'/api/v3/capabilities',
);
assert.equal(capabilities.statusCode, 200);
assert.equal(capabilities.body.capabilities.deployment.profile, 'edge');
assert.equal(capabilities.body.capabilities.panel.legacyMutations, false);
const panelUser = await request(port, `Bearer ${TOKEN}`, '/api/user?t=101');
assert.equal(panelUser.statusCode, 200);
assert.equal(panelUser.body.data.username, 'local-api-user');
assert.equal(panelUser.body.data.ql3.credentialPersistence, 'memory_only');
const panelConfig = await request(
port,
`Bearer ${TOKEN}`,
'/api/system/config?t=102',
);
assert.equal(panelConfig.statusCode, 200);
assert.equal(panelConfig.body.data.info.panelTitle, 'QingLong 3.0');
assert.equal(panelConfig.body.data.ql3.limits.cronRows, 64);
const accepted = await request(port, `Bearer ${TOKEN}`); const accepted = await request(port, `Bearer ${TOKEN}`);
assert.equal(accepted.statusCode, 200); assert.equal(accepted.statusCode, 200);
assert.equal(accepted.body.run.id, RUN_ID); assert.equal(accepted.body.run.id, RUN_ID);
@@ -1132,6 +1160,7 @@ test('serves an authenticated Run through one real SQLite authority and durable
'run.get', 'run.list', 'run.events.list', 'run.steps.list', 'run.get', 'run.list', 'run.events.list', 'run.steps.list',
'run.cancel', 'task.authoring.read', 'task.create', 'task.get', 'run.cancel', 'task.authoring.read', 'task.create', 'task.get',
'task.list', 'task.start', 'task.update', 'run.log.read', 'task.list', 'task.start', 'task.update', 'run.log.read',
'panel.system.config.get', 'panel.user.get',
'trigger.create', 'trigger.get', 'trigger.list', 'trigger.update', 'trigger.create', 'trigger.get', 'trigger.list', 'trigger.update',
'secret.create', 'secret.list' 'secret.create', 'secret.list'
) )
@@ -1140,6 +1169,8 @@ test('serves an authenticated Run through one real SQLite authority and durable
.all() .all()
.map(({ operation_id, outcome }) => `${operation_id}:${outcome}`), .map(({ operation_id, outcome }) => `${operation_id}:${outcome}`),
[ [
'panel.system.config.get:allowed',
'panel.user.get:allowed',
'run.cancel:allowed', 'run.cancel:allowed',
'run.cancel:allowed', 'run.cancel:allowed',
'run.events.list:allowed', 'run.events.list:allowed',
+64 -8
View File
@@ -18,6 +18,13 @@ import defaultProps from './defaultProps';
import './index.less'; import './index.less';
import { init } from '../utils/init'; import { init } from '../utils/init';
import WebSocketManager from '../utils/websocket'; import WebSocketManager from '../utils/websocket';
import {
clearQingLong3Credential,
discoverQingLong3,
isQingLong3PanelSession,
qingLong3Capabilities,
qingLong3Credential,
} from '../utils/qinglong3';
export interface SharedContext { export interface SharedContext {
headerStyle: React.CSSProperties; headerStyle: React.CSSProperties;
@@ -38,6 +45,12 @@ interface TSystemInfo {
version: string; version: string;
changeLog: string; changeLog: string;
changeLogLink: string; changeLogLink: string;
ql3?: {
schemaVersion: 1;
mode: 'local';
profile: 'edge' | 'standalone';
capabilitiesPath: string;
};
} }
export default function () { export default function () {
@@ -48,7 +61,8 @@ export default function () {
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [systemInfo, setSystemInfo] = useState<TSystemInfo>(); const [systemInfo, setSystemInfo] = useState<TSystemInfo>();
const [siteTitle, setSiteTitle] = useState( const [siteTitle, setSiteTitle] = useState(
() => localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'), () =>
localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'),
); );
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [initLoading, setInitLoading] = useState<boolean>(true); const [initLoading, setInitLoading] = useState<boolean>(true);
@@ -61,6 +75,12 @@ export default function () {
} = DarkReader || {}; } = DarkReader || {};
const logout = () => { const logout = () => {
if (isQingLong3PanelSession()) {
clearQingLong3Credential();
setUser({});
history.push('/login');
return;
}
request.post(`${config.apiPrefix}user/logout`).then(() => { request.post(`${config.apiPrefix}user/logout`).then(() => {
localStorage.removeItem(config.authKey); localStorage.removeItem(config.authKey);
history.push('/login'); history.push('/login');
@@ -70,14 +90,27 @@ export default function () {
const getSystemInfo = () => { const getSystemInfo = () => {
request request
.get(`${config.apiPrefix}system`) .get(`${config.apiPrefix}system`)
.then(({ code, data }) => { .then(async ({ code, data }) => {
if (code === 200) { if (code === 200) {
let qingLong3 = null;
if (data?.ql3?.capabilitiesPath === '/api/v3/capabilities') {
qingLong3 = await discoverQingLong3(
`${config.apiPrefix}v3/capabilities`,
);
}
setSystemInfo(data); setSystemInfo(data);
if (!data.isInitialized) { if (!data.isInitialized) {
history.push('/initialization'); history.push('/initialization');
} else { } else {
init(data.version); init(data.version);
getUser(); if (!qingLong3 || qingLong3Credential()) {
getUser();
} else {
setLoading(false);
if (!['/login', '/error'].includes(location.pathname)) {
history.replace('/login');
}
}
} }
} }
}) })
@@ -94,7 +127,7 @@ export default function () {
if (code === 200 && data.username) { if (code === 200 && data.username) {
setUser(data); setUser(data);
if (location.pathname === '/') { if (location.pathname === '/') {
history.push('/dashboard'); history.push(data?.ql3?.panelHome || '/dashboard');
} }
} }
needLoading && setLoading(false); needLoading && setLoading(false);
@@ -165,8 +198,10 @@ export default function () {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!systemInfo) return;
if (systemInfo.ql3 && !isQingLong3PanelSession()) return;
reloadSystemConfig(); reloadSystemConfig();
}, []); }, [systemInfo]);
useEffect(() => { useEffect(() => {
if (!['/login', '/initialization', '/error'].includes(location.pathname)) { if (!['/login', '/initialization', '/error'].includes(location.pathname)) {
@@ -210,7 +245,7 @@ export default function () {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!user || !user.username) return; if (!user || !user.username || isQingLong3PanelSession()) return;
const ws = WebSocketManager.getInstance( const ws = WebSocketManager.getInstance(
`${window.location.origin}${ `${window.location.origin}${
config.apiPrefix config.apiPrefix
@@ -222,6 +257,16 @@ export default function () {
}; };
}, [user]); }, [user]);
useEffect(() => {
if (
isQingLong3PanelSession() &&
user?.username &&
!['/login', '/error', '/crontab'].includes(location.pathname)
) {
history.replace('/crontab');
}
}, [location.pathname, user]);
useEffect(() => { useEffect(() => {
window.onload = () => { window.onload = () => {
const timing = performance.timing; const timing = performance.timing;
@@ -244,7 +289,7 @@ export default function () {
if (['/login', '/initialization', '/error'].includes(location.pathname)) { if (['/login', '/initialization', '/error'].includes(location.pathname)) {
if (systemInfo?.isInitialized && location.pathname === '/initialization') { if (systemInfo?.isInitialized && location.pathname === '/initialization') {
history.push('/dashboard'); history.push(qingLong3Capabilities() ? '/crontab' : '/dashboard');
} }
if (systemInfo || location.pathname === '/error') { if (systemInfo || location.pathname === '/error') {
@@ -282,6 +327,17 @@ export default function () {
}, },
], ],
}; };
const layoutProps = isQingLong3PanelSession()
? {
...defaultProps,
route: {
...defaultProps.route,
routes: defaultProps.route?.routes?.filter((item) =>
['/login', '/error', '/crontab'].includes(item.path || ''),
),
},
}
: defaultProps;
return loading ? ( return loading ? (
<PageLoading /> <PageLoading />
) : ( ) : (
@@ -393,7 +449,7 @@ export default function () {
</span> </span>
</span> </span>
)} )}
{...defaultProps} {...layoutProps}
> >
<Outlet <Outlet
context={{ context={{
+146 -81
View File
@@ -4,6 +4,10 @@ import { getCommandScript, getCrontabsNextDate } from '@/utils';
import config from '@/utils/config'; import config from '@/utils/config';
import { diffTime } from '@/utils/date'; import { diffTime } from '@/utils/date';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
import {
isQingLong3PanelSession,
qingLong3Capabilities,
} from '@/utils/qinglong3';
import { import {
CheckCircleOutlined, CheckCircleOutlined,
CheckOutlined, CheckOutlined,
@@ -66,6 +70,9 @@ const SHOW_TAB_COUNT = 10;
const Crontab = () => { const Crontab = () => {
const { headerStyle, isPhone, theme } = useOutletContext<SharedContext>(); const { headerStyle, isPhone, theme } = useOutletContext<SharedContext>();
const qingLong3 = qingLong3Capabilities();
const qingLong3ReadOnly = qingLong3 !== null;
const qingLong3Authenticated = isQingLong3PanelSession();
const [allSubscriptions, setAllSubscriptions] = useState<any[]>([]); const [allSubscriptions, setAllSubscriptions] = useState<any[]>([]);
const columns: ColumnProps<ICrontab>[] = [ const columns: ColumnProps<ICrontab>[] = [
{ {
@@ -79,16 +86,21 @@ const Crontab = () => {
style={{ style={{
wordBreak: 'break-all', wordBreak: 'break-all',
marginBottom: 0, marginBottom: 0,
color: '#1890ff', color: qingLong3ReadOnly ? undefined : '#1890ff',
cursor: 'pointer', cursor: qingLong3ReadOnly ? 'default' : 'pointer',
}} }}
ellipsis={{ tooltip: text, rows: 2 }} ellipsis={{ tooltip: text, rows: 2 }}
onClick={() => { onClick={() => {
if (qingLong3ReadOnly) return;
setDetailCron(record); setDetailCron(record);
setIsDetailModalVisible(true); setIsDetailModalVisible(true);
}} }}
> >
<Link>{record.name || '-'}</Link> {qingLong3ReadOnly ? (
record.name || '-'
) : (
<Link>{record.name || '-'}</Link>
)}
</Paragraph> </Paragraph>
), ),
sorter: { sorter: {
@@ -109,13 +121,17 @@ const Crontab = () => {
}} }}
ellipsis={{ tooltip: text, rows: 2 }} ellipsis={{ tooltip: text, rows: 2 }}
> >
<a {qingLong3ReadOnly ? (
onClick={() => { text
goToScriptManager(record); ) : (
}} <a
> onClick={() => {
{text} goToScriptManager(record);
</a> }}
>
{text}
</a>
)}
</Paragraph> </Paragraph>
); );
}, },
@@ -248,8 +264,8 @@ const Crontab = () => {
> >
{record.last_execution_time {record.last_execution_time
? dayjs(record.last_execution_time * 1000).format( ? dayjs(record.last_execution_time * 1000).format(
'YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm:ss',
) )
: '-'} : '-'}
</span> </span>
); );
@@ -354,6 +370,19 @@ const Crontab = () => {
const tableRef = useRef<HTMLDivElement>(null); const tableRef = useRef<HTMLDivElement>(null);
const tableScrollHeight = useTableScrollHeight(tableRef); const tableScrollHeight = useTableScrollHeight(tableRef);
const [activeKey, setActiveKey] = useState(''); const [activeKey, setActiveKey] = useState('');
const visibleColumns = qingLong3ReadOnly
? columns
.filter((column) =>
['name', 'command', 'status', 'schedule'].includes(
String(column.key || ''),
),
)
.map((column) => ({
...column,
sorter: undefined,
filters: undefined,
}))
: columns;
const goToScriptManager = (record: any) => { const goToScriptManager = (record: any) => {
const result = getCommandScript(record.command); const result = getCommandScript(record.command);
@@ -366,12 +395,20 @@ const Crontab = () => {
}; };
const getCrons = async (silent?: boolean) => { const getCrons = async (silent?: boolean) => {
if (qingLong3ReadOnly && !qingLong3Authenticated) {
if (!silent) setLoading(false);
return;
}
if (!silent) setLoading(true); if (!silent) setLoading(true);
const { page = 1, size = 20, sorter, filters = {} } = pageConf; const { page = 1, size = 20, sorter, filters = {} } = pageConf;
let url = `${config.apiPrefix const effectiveSize = qingLong3ReadOnly
}crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify( ? Math.min(size, qingLong3?.limits.cronPageSize || 20)
filters, : size;
)}`; const effectiveSearchText = qingLong3ReadOnly ? '' : searchText;
const serializedFilters = qingLong3ReadOnly
? encodeURIComponent('{}')
: JSON.stringify(filters);
let url = `${config.apiPrefix}crons?searchValue=${effectiveSearchText}&page=${page}&size=${effectiveSize}&filters=${serializedFilters}`;
if (sorter && sorter.column && sorter.order) { if (sorter && sorter.column && sorter.order) {
url += `&sorter=${JSON.stringify({ url += `&sorter=${JSON.stringify({
field: sorter.column.key, field: sorter.column.key,
@@ -390,6 +427,11 @@ const Crontab = () => {
.then(async ({ code, data: _data }) => { .then(async ({ code, data: _data }) => {
if (code === 200) { if (code === 200) {
const { data, total } = _data; const { data, total } = _data;
if (qingLong3ReadOnly) {
setValue(data);
setTotal(total);
return;
}
const subscriptions = await request.get( const subscriptions = await request.get(
`${config.apiPrefix}subscriptions?ids=${JSON.stringify([ `${config.apiPrefix}subscriptions?ids=${JSON.stringify([
...new Set(data.map((x) => x.sub_id).filter(Boolean)), ...new Set(data.map((x) => x.sub_id).filter(Boolean)),
@@ -544,8 +586,9 @@ const Crontab = () => {
const enabledOrDisabledCron = (record: any, index: number) => { const enabledOrDisabledCron = (record: any, index: number) => {
Modal.confirm({ Modal.confirm({
title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') title: `确认${
}`, record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
content: ( content: (
<> <>
{intl.get('确认')} {intl.get('确认')}
@@ -560,7 +603,8 @@ const Crontab = () => {
onOk() { onOk() {
request request
.put( .put(
`${config.apiPrefix}crons/${record.isDisabled === 1 ? 'enable' : 'disable' `${config.apiPrefix}crons/${
record.isDisabled === 1 ? 'enable' : 'disable'
}`, }`,
[record.id], [record.id],
) )
@@ -584,8 +628,9 @@ const Crontab = () => {
const pinOrUnPinCron = (record: any, index: number) => { const pinOrUnPinCron = (record: any, index: number) => {
Modal.confirm({ Modal.confirm({
title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') title: `确认${
}`, record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
content: ( content: (
<> <>
{intl.get('确认')} {intl.get('确认')}
@@ -600,7 +645,8 @@ const Crontab = () => {
onOk() { onOk() {
request request
.put( .put(
`${config.apiPrefix}crons/${record.isPinned === 1 ? 'unpin' : 'pin' `${config.apiPrefix}crons/${
record.isPinned === 1 ? 'unpin' : 'pin'
}`, }`,
[record.id], [record.id],
) )
@@ -777,8 +823,8 @@ const Crontab = () => {
setPageConf({ setPageConf({
page: current as number, page: current as number,
size: pageSize as number, size: pageSize as number,
sorter, sorter: qingLong3ReadOnly ? {} : sorter,
filters, filters: qingLong3ReadOnly ? {} : filters,
}); });
localStorage.setItem('pageSize', String(pageSize)); localStorage.setItem('pageSize', String(pageSize));
}; };
@@ -824,10 +870,20 @@ const Crontab = () => {
setAllSubscriptions(data || []); setAllSubscriptions(data || []);
} }
}) })
.catch(() => { }); .catch(() => {});
}; };
useEffect(() => { useEffect(() => {
if (qingLong3ReadOnly) {
setPageConf({
page: 1,
size: Math.min(20, qingLong3?.limits.cronPageSize || 20),
sorter: {},
filters: {},
});
setActiveKey('all');
return;
}
getCronViews(); getCronViews();
getAllSubscriptions(); getAllSubscriptions();
}, []); }, []);
@@ -931,56 +987,63 @@ const Crontab = () => {
<PageContainer <PageContainer
className="ql-container-wrapper crontab-wrapper ql-container-wrapper-has-tab" className="ql-container-wrapper crontab-wrapper ql-container-wrapper-has-tab"
title={intl.get('定时任务')} title={intl.get('定时任务')}
extra={[ extra={
<Search qingLong3ReadOnly
placeholder={intl.get('请输入名称或者关键词')} ? [<Tag key="ql3-read-only">QingLong 3.0 · </Tag>]
style={{ width: 'auto' }} : [
enterButton <Search
allowClear key="search"
loading={loading} placeholder={intl.get('请输入名称或者关键词')}
value={searchValue} style={{ width: 'auto' }}
onChange={(e) => setSearchValue(e.target.value)} enterButton
onSearch={onSearch} allowClear
/>, loading={loading}
<Button key="2" type="primary" onClick={() => addCron()}> value={searchValue}
{intl.get('创建任务')} onChange={(e) => setSearchValue(e.target.value)}
</Button>, onSearch={onSearch}
]} />,
<Button key="create" type="primary" onClick={() => addCron()}>
{intl.get('创建任务')}
</Button>,
]
}
header={{ header={{
style: headerStyle, style: headerStyle,
}} }}
> >
<Tabs {!qingLong3ReadOnly && (
defaultActiveKey="all" <Tabs
size="small" defaultActiveKey="all"
activeKey={activeKey} size="small"
tabPosition="top" activeKey={activeKey}
className={`crontab-view ${moreMenuActive ? 'more-active' : ''}`} tabPosition="top"
tabBarExtraContent={ className={`crontab-view ${moreMenuActive ? 'more-active' : ''}`}
<Dropdown tabBarExtraContent={
menu={menu} <Dropdown
trigger={['click']} menu={menu}
overlayStyle={{ minWidth: 200 }} trigger={['click']}
> overlayStyle={{ minWidth: 200 }}
<div className={`view-more ${moreMenuActive ? 'active' : ''}`}> >
<Space> <div className={`view-more ${moreMenuActive ? 'active' : ''}`}>
{intl.get('更多')} <Space>
<DownOutlined /> {intl.get('更多')}
</Space> <DownOutlined />
<div className="ant-tabs-ink-bar ant-tabs-ink-bar-animated"></div> </Space>
</div> <div className="ant-tabs-ink-bar ant-tabs-ink-bar-animated"></div>
</Dropdown> </div>
} </Dropdown>
onTabClick={tabClick} }
items={[ onTabClick={tabClick}
...[...enabledCronViews].slice(0, SHOW_TAB_COUNT).map((x) => ({ items={[
key: x.id, ...[...enabledCronViews].slice(0, SHOW_TAB_COUNT).map((x) => ({
label: x.name, key: x.id,
})), label: x.name,
]} })),
/> ]}
/>
)}
<div ref={tableRef}> <div ref={tableRef}>
{selectedRowIds.length > 0 && ( {!qingLong3ReadOnly && selectedRowIds.length > 0 && (
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Button <Button
type="primary" type="primary"
@@ -1042,7 +1105,7 @@ const Crontab = () => {
</div> </div>
)} )}
<Table <Table
columns={columns} columns={visibleColumns}
sortDirections={['descend', 'ascend']} sortDirections={['descend', 'ascend']}
pagination={{ pagination={{
current: pageConf.page, current: pageConf.page,
@@ -1052,22 +1115,24 @@ const Crontab = () => {
total, total,
showTotal: (total: number, range: number[]) => showTotal: (total: number, range: number[]) =>
`${range[0]}-${range[1]} 条/总共 ${total}`, `${range[0]}-${range[1]} 条/总共 ${total}`,
pageSizeOptions: [10, 20, 50, 100, 200, 500, total || 10000].sort( pageSizeOptions: qingLong3ReadOnly
(a, b) => a - b, ? [10, 20, 50, qingLong3?.limits.cronPageSize || 64]
), : [10, 20, 50, 100, 200, 500, total || 10000].sort(
(a, b) => a - b,
),
}} }}
dataSource={value} dataSource={value}
rowKey="id" rowKey="id"
size="middle" size="middle"
scroll={{ x: 1200, y: tableScrollHeight }} scroll={{ x: 1200, y: tableScrollHeight }}
loading={loading} loading={loading}
rowSelection={rowSelection} rowSelection={qingLong3ReadOnly ? undefined : rowSelection}
rowClassName={getRowClassName} rowClassName={getRowClassName}
onChange={onPageChange} onChange={onPageChange}
components={isPhone || pageConf.size < 50 ? undefined : vt} components={isPhone || pageConf.size < 50 ? undefined : vt}
/> />
</div> </div>
{isLogModalVisible && ( {!qingLong3ReadOnly && isLogModalVisible && (
<CronLogModal <CronLogModal
handleCancel={() => { handleCancel={() => {
getCronDetail(logCron); getCronDetail(logCron);
@@ -1076,10 +1141,10 @@ const Crontab = () => {
cron={logCron} cron={logCron}
/> />
)} )}
{isModalVisible && ( {!qingLong3ReadOnly && isModalVisible && (
<CronModal handleCancel={handleCancel} cron={editedCron} /> <CronModal handleCancel={handleCancel} cron={editedCron} />
)} )}
{isLabelModalVisible && ( {!qingLong3ReadOnly && isLabelModalVisible && (
<CronLabelModal <CronLabelModal
handleCancel={(needUpdate?: boolean) => { handleCancel={(needUpdate?: boolean) => {
setIsLabelModalVisible(false); setIsLabelModalVisible(false);
@@ -1090,7 +1155,7 @@ const Crontab = () => {
ids={selectedRowIds} ids={selectedRowIds}
/> />
)} )}
{isDetailModalVisible && ( {!qingLong3ReadOnly && isDetailModalVisible && (
<CronDetailModal <CronDetailModal
handleCancel={(needUpdate?: boolean) => { handleCancel={(needUpdate?: boolean) => {
setIsDetailModalVisible(false); setIsDetailModalVisible(false);
@@ -1103,7 +1168,7 @@ const Crontab = () => {
isPhone={isPhone} isPhone={isPhone}
/> />
)} )}
{isCreateViewModalVisible && ( {!qingLong3ReadOnly && isCreateViewModalVisible && (
<ViewCreateModal <ViewCreateModal
handleCancel={(data) => { handleCancel={(data) => {
setIsCreateViewModalVisible(false); setIsCreateViewModalVisible(false);
@@ -1111,7 +1176,7 @@ const Crontab = () => {
}} }}
/> />
)} )}
{isViewManageModalVisible && ( {!qingLong3ReadOnly && isViewManageModalVisible && (
<ViewManageModal <ViewManageModal
cronViews={cronViews} cronViews={cronViews}
handleCancel={() => { handleCancel={() => {
+84 -19
View File
@@ -17,24 +17,59 @@ import { useTheme } from '@/utils/hooks';
import { MobileOutlined } from '@ant-design/icons'; import { MobileOutlined } from '@ant-design/icons';
import { SharedContext } from '@/layouts'; import { SharedContext } from '@/layouts';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import {
clearQingLong3Credential,
qingLong3Capabilities,
qingLong3Credential,
setQingLong3Credential,
} from '@/utils/qinglong3';
const FormItem = Form.Item; const FormItem = Form.Item;
const { Countdown } = Statistic; const { Countdown } = Statistic;
const isDemoEnv = window.__ENV__DeployEnv === 'demo'; const isDemoEnv = window.__ENV__DeployEnv === 'demo';
const Login = () => { const Login = () => {
const { reloadUser } = useOutletContext<SharedContext>(); const { reloadSystemConfig, reloadUser } = useOutletContext<SharedContext>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [waitTime, setWaitTime] = useState<any>(); const [waitTime, setWaitTime] = useState<any>();
const { theme } = useTheme(); const { theme } = useTheme();
const [twoFactor, setTwoFactor] = useState(false); const [twoFactor, setTwoFactor] = useState(false);
const [verifying, setVerifying] = useState(false); const [verifying, setVerifying] = useState(false);
const [loginInfo, setLoginInfo] = useState<any>(); const [loginInfo, setLoginInfo] = useState<any>();
const qingLong3 = qingLong3Capabilities();
const handleOk = (values: any) => { const handleOk = (values: any) => {
setLoading(true); setLoading(true);
setTwoFactor(false); setTwoFactor(false);
setWaitTime(null); setWaitTime(null);
if (qingLong3) {
const token = String(values.credential || '').trim();
if (!setQingLong3Credential(token)) {
message.error('API Credential 格式无效');
setLoading(false);
return;
}
request
.get(`${config.apiPrefix}user`)
.then(({ code, data }: any) => {
if (code !== 200 || !data?.username) {
throw new TypeError('QL3 identity is unavailable');
}
notification.success({
message: 'QingLong 3.0 已连接',
description: `${data.username} · ${qingLong3.deployment.profile}`,
});
reloadSystemConfig();
reloadUser(true);
history.push('/crontab');
})
.catch(() => {
clearQingLong3Credential();
message.error('API Credential 验证失败');
})
.finally(() => setLoading(false));
return;
}
request request
.post(`${config.apiPrefix}user/login`, { .post(`${config.apiPrefix}user/login`, {
username: values.username, username: values.username,
@@ -134,11 +169,13 @@ const Login = () => {
}; };
useEffect(() => { useEffect(() => {
const isAuth = localStorage.getItem(config.authKey); const isAuth = qingLong3
? qingLong3Credential()
: localStorage.getItem(config.authKey);
if (isAuth) { if (isAuth) {
history.push('/dashboard'); history.push(qingLong3 ? '/crontab' : '/dashboard');
} }
}, []); }, [qingLong3]);
return ( return (
<div className={styles.container}> <div className={styles.container}>
@@ -150,7 +187,11 @@ const Login = () => {
src="https://qn.whyour.cn/logo.png" src="https://qn.whyour.cn/logo.png"
/> />
<span className={styles.title}> <span className={styles.title}>
{twoFactor ? intl.get('两步验证') : config.siteName} {twoFactor
? intl.get('两步验证')
: qingLong3
? 'QingLong 3.0'
: config.siteName}
</span> </span>
</div> </div>
</div> </div>
@@ -186,20 +227,44 @@ const Login = () => {
</Form> </Form>
) : ( ) : (
<Form layout="vertical" onFinish={handleOk}> <Form layout="vertical" onFinish={handleOk}>
<FormItem name="username" label={intl.get('用户名')} hasFeedback> {qingLong3 ? (
<Input <FormItem
placeholder={`${intl.get('用户名')}${ name="credential"
isDemoEnv ? ': admin' : '' label="API Credential"
}`} hasFeedback
autoFocus rules={[{ required: true, message: '请输入 API Credential' }]}
/> extra="凭据只保存在当前页面内存;刷新或关闭页面后需要重新输入。"
</FormItem> >
<FormItem name="password" label={intl.get('密码')} hasFeedback> <Input.Password
<Input placeholder="ql3c_…"
type="password" autoComplete="off"
placeholder={`${intl.get('密码')}${isDemoEnv ? ': 123' : ''}`} autoFocus
/> />
</FormItem> </FormItem>
) : (
<>
<FormItem
name="username"
label={intl.get('用户名')}
hasFeedback
>
<Input
placeholder={`${intl.get('用户名')}${
isDemoEnv ? ': admin' : ''
}`}
autoFocus
/>
</FormItem>
<FormItem name="password" label={intl.get('密码')} hasFeedback>
<Input
type="password"
placeholder={`${intl.get('密码')}${
isDemoEnv ? ': 123' : ''
}`}
/>
</FormItem>
</>
)}
<Row> <Row>
{waitTime ? ( {waitTime ? (
<Button type="primary" style={{ width: '100%' }} disabled> <Button type="primary" style={{ width: '100%' }} disabled>
+4 -1
View File
@@ -9,6 +9,7 @@ import axios, {
AxiosResponse, AxiosResponse,
InternalAxiosRequestConfig, InternalAxiosRequestConfig,
} from 'axios'; } from 'axios';
import { clearQingLong3Credential, qingLong3Credential } from './qinglong3';
export interface IResponseData { export interface IResponseData {
code?: number; code?: number;
@@ -49,6 +50,7 @@ const errorHandler = function (
} else if (responseStatus === 401) { } else if (responseStatus === 401) {
if (history.location.pathname !== '/login') { if (history.location.pathname !== '/login') {
message.error(intl.get('登录已过期,请重新登录')); message.error(intl.get('登录已过期,请重新登录'));
clearQingLong3Credential();
localStorage.removeItem(config.authKey); localStorage.removeItem(config.authKey);
history.push('/login'); history.push('/login');
} }
@@ -93,7 +95,7 @@ const apiWhiteList = [
]; ];
_request.interceptors.request.use((_config) => { _request.interceptors.request.use((_config) => {
const token = localStorage.getItem(config.authKey); const token = qingLong3Credential() || localStorage.getItem(config.authKey);
if (token && !apiWhiteList.includes(_config.url!)) { if (token && !apiWhiteList.includes(_config.url!)) {
_config.headers.Authorization = `Bearer ${token}`; _config.headers.Authorization = `Bearer ${token}`;
return _config; return _config;
@@ -107,6 +109,7 @@ _request.interceptors.response.use(async (response) => {
history.push('/error'); history.push('/error');
} else if (responseStatus === 401) { } else if (responseStatus === 401) {
if (history.location.pathname !== '/login') { if (history.location.pathname !== '/login') {
clearQingLong3Credential();
localStorage.removeItem(config.authKey); localStorage.removeItem(config.authKey);
history.push('/login'); history.push('/login');
} }
+113
View File
@@ -0,0 +1,113 @@
export interface QingLong3Capabilities {
readonly schemaVersion: 1;
readonly product: 'qinglong3';
readonly version: string;
readonly deployment: Readonly<{
mode: 'local';
profile: 'edge' | 'standalone';
}>;
readonly authentication: Readonly<{
kind: 'api_credential';
transport: 'bearer';
persistence: 'memory_only';
loginEndpoint: null;
}>;
readonly panel: Readonly<{
bootstrap: true;
cronList: 'bounded_read_only';
legacyMutations: false;
legacyLogin: false;
subscriptions: false;
scripts: false;
environmentVariables: false;
webSocket: false;
}>;
readonly limits: Readonly<{
cronRows: number;
cronPageSize: number;
logChunkBytes: number;
}>;
}
const CREDENTIAL_PATTERN =
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
let capabilities: Readonly<QingLong3Capabilities> | null = null;
let credential: string | null = null;
function validCapabilities(value: any): value is QingLong3Capabilities {
const profile = value?.deployment?.profile;
return Boolean(
value?.schemaVersion === 1 &&
value?.product === 'qinglong3' &&
typeof value?.version === 'string' &&
value.version.startsWith('3.') &&
value?.deployment?.mode === 'local' &&
(profile === 'edge' || profile === 'standalone') &&
value?.authentication?.kind === 'api_credential' &&
value?.authentication?.transport === 'bearer' &&
value?.authentication?.persistence === 'memory_only' &&
value?.authentication?.loginEndpoint === null &&
value?.panel?.bootstrap === true &&
value?.panel?.cronList === 'bounded_read_only' &&
value?.panel?.legacyMutations === false &&
value?.panel?.legacyLogin === false &&
value?.panel?.subscriptions === false &&
value?.panel?.scripts === false &&
value?.panel?.environmentVariables === false &&
value?.panel?.webSocket === false &&
Number.isSafeInteger(value?.limits?.cronRows) &&
value.limits.cronRows >= 1 &&
value.limits.cronRows <= 256 &&
Number.isSafeInteger(value?.limits?.cronPageSize) &&
value.limits.cronPageSize >= 1 &&
value.limits.cronPageSize <= 64 &&
Number.isSafeInteger(value?.limits?.logChunkBytes) &&
value.limits.logChunkBytes >= 1 &&
value.limits.logChunkBytes <= 32 * 1024,
);
}
export async function discoverQingLong3(
endpoint: string,
): Promise<Readonly<QingLong3Capabilities> | null> {
try {
const response = await fetch(endpoint, {
method: 'GET',
cache: 'no-store',
credentials: 'omit',
redirect: 'error',
referrerPolicy: 'no-referrer',
headers: { accept: 'application/json' },
});
if (!response.ok) return null;
const value = await response.json();
if (!validCapabilities(value?.capabilities)) return null;
capabilities = Object.freeze(value.capabilities);
return capabilities;
} catch {
return null;
}
}
export function qingLong3Capabilities(): Readonly<QingLong3Capabilities> | null {
return capabilities;
}
export function setQingLong3Credential(value: string): boolean {
if (!CREDENTIAL_PATTERN.test(value)) return false;
credential = value;
return true;
}
export function qingLong3Credential(): string | null {
return credential;
}
export function clearQingLong3Credential(): void {
credential = null;
}
export function isQingLong3PanelSession(): boolean {
return capabilities !== null && credential !== null;
}