mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add bounded panel bootstrap
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 audit、credential reconfirm 后,按 Edge 64/Standalone 256 行上限读取 `qinglong/cron@v1` Trigger 前缀,并以 Trigger 固定的 `taskId/revision/contentDigest` 读取 pinned Task revision。每个 Trigger 投影成一条旧 Cron 包络,字符串 `triggerId` 保持稳定身份,schedule/timezone/misfire 来自规范化 Trigger;command 只返回 `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 或 watcher;headless 与 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 资源门已通过;仍须完成双架构 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 audit 与 credential 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、timer、watcher 或后台进程;默认 headless、Cluster 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 来自规范化 Trigger;command 只返回 `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 或 watcher;headless 与 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 12,Local 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 12,Local 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(源码候选,尚未进入双架构阶段实物)
|
- 状态:Accepted(D-427 双架构阶段实物已交付)
|
||||||
- 日期:2026-09-02
|
- 日期:2026-09-02
|
||||||
- 关联 RFC:QL-RFC-0001 D-427、D-423、D-424、D-426c3
|
- 关联 RFC:QL-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
|
||||||
|
- 关联 RFC:QL-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 reconfirm;operation 固定为 `panel.user.get` 与 `panel.system.config.get`,且面板身份只接受 User principal,service 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
@@ -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 应用与显式回滚 | Accepted(exact headless 双架构阶段实物已交付) |
|
| [ADR-0527](./ADR-0527-reviewed-automation-reconciliation-application-and-rollback.md) | 受审核 Automation Reconciliation 应用与显式回滚 | Accepted(exact headless 双架构阶段实物已交付) |
|
||||||
| [ADR-0528](./ADR-0528-cross-domain-reconciliation-completion-rehearsal.md) | 跨域 Reconciliation Completion 演练 | Accepted(D-426c3 源码候选;双架构阶段实物待 gate) |
|
| [ADR-0528](./ADR-0528-cross-domain-reconciliation-completion-rehearsal.md) | 跨域 Reconciliation Completion 演练 | Accepted(D-426c3 源码候选;双架构阶段实物待 gate) |
|
||||||
| [ADR-0529](./ADR-0529-bounded-read-only-local-panel-cron-adapter.md) | 有界只读 Local 旧面板 Cron Adapter | Proposed(D-427 源码候选;双架构阶段实物待 gate) |
|
| [ADR-0529](./ADR-0529-bounded-read-only-local-panel-cron-adapter.md) | 有界只读 Local 旧面板 Cron Adapter | Accepted(D-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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user