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 --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:
|
||||
name: systemd/OpenRC dual-authority bridge
|
||||
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')
|
||||
needs:
|
||||
- backend
|
||||
- legacy-panel-compatibility
|
||||
- service-manager-bridge
|
||||
- linux-resource-envelopes
|
||||
- linux-resource-release-evidence
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# 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 边界不变。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-0529:有界只读 Local 旧面板 Cron Adapter
|
||||
|
||||
- 状态:Proposed(源码候选,尚未进入双架构阶段实物)
|
||||
- 状态:Accepted(D-427 双架构阶段实物已交付)
|
||||
- 日期:2026-09-02
|
||||
- 关联 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`。
|
||||
- 非空搜索、排序、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 或全表扫描。
|
||||
|
||||
### 领域映射
|
||||
@@ -48,7 +48,7 @@ QingLong 2.x 面板以 `/api/crons`、数值型 Cron 行和 `{code,data}` 包络
|
||||
|
||||
## 验证与后续门禁
|
||||
|
||||
源码候选必须通过:
|
||||
本切片通过:
|
||||
|
||||
1. Adapter 单测:分页、禁用合成、pinned identity、未知 schema、预算和 storage failure;
|
||||
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;
|
||||
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-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-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,
|
||||
} from '../secret/secretRoutes';
|
||||
import type { PanelCronListRoute } from '../panel-compatibility/panelCronListRoute';
|
||||
import type { PanelBootstrapRoute } from '../panel-compatibility/panelBootstrapRoute';
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
export type LocalApiAdmissionOperation =
|
||||
@@ -133,6 +134,10 @@ export type LocalApiAdmissionOperation =
|
||||
page: number;
|
||||
size: number;
|
||||
maximumRows: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
operationId: 'panel.user.get' | 'panel.system.config.get';
|
||||
projectId: 'default';
|
||||
}>;
|
||||
|
||||
export interface LocalApiAdmissionRequest {
|
||||
@@ -177,6 +182,7 @@ export interface LocalApiAdmissionOptions {
|
||||
readonly secretListRoute: LocalApiSecretListRoute;
|
||||
readonly secretPutRoute: LocalApiSecretPutRoute;
|
||||
readonly panelCronListRoute: PanelCronListRoute;
|
||||
readonly panelBootstrapRoute: PanelBootstrapRoute;
|
||||
readonly now?: () => number;
|
||||
readonly randomUuid?: () => string;
|
||||
}
|
||||
@@ -263,6 +269,7 @@ export function createLocalApiAdmission(
|
||||
typeof options.secretListRoute?.handle !== 'function' ||
|
||||
typeof options.secretPutRoute?.handle !== 'function' ||
|
||||
typeof options.panelCronListRoute?.handle !== 'function' ||
|
||||
typeof options.panelBootstrapRoute?.handle !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.randomUuid !== undefined &&
|
||||
typeof options.randomUuid !== 'function')
|
||||
@@ -404,7 +411,9 @@ export function createLocalApiAdmission(
|
||||
request.operation.operationId === 'task.get' ||
|
||||
request.operation.operationId === 'trigger.list' ||
|
||||
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'
|
||||
: request.operation.operationId === 'secret.list'
|
||||
? 'secret.manage'
|
||||
@@ -572,6 +581,13 @@ export function createLocalApiAdmission(
|
||||
size: request.operation.size,
|
||||
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.authoring':
|
||||
case 'trigger.put':
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
createLocalApiSecretPutRoute,
|
||||
} from '../secret/secretRoutes';
|
||||
import { createPanelCronListRoute } from '../panel-compatibility/panelCronListRoute';
|
||||
import { createPanelBootstrapRoute } from '../panel-compatibility/panelBootstrapRoute';
|
||||
import { startLocalApiHttpSurface } from '../transport/httpSurface';
|
||||
|
||||
export interface LocalApiProductSurfaceEvent {
|
||||
@@ -226,6 +227,7 @@ export function createLocalApiProductSurface(
|
||||
tasks: authority.taskDefinitions,
|
||||
triggers: authority.triggers,
|
||||
});
|
||||
const panelBootstrapRoute = createPanelBootstrapRoute(authority.profile);
|
||||
const admission = createLocalApiAdmission({
|
||||
authenticator,
|
||||
policy,
|
||||
@@ -247,6 +249,7 @@ export function createLocalApiProductSurface(
|
||||
secretListRoute,
|
||||
secretPutRoute,
|
||||
panelCronListRoute,
|
||||
panelBootstrapRoute,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
...(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) &&
|
||||
request.maximumRows >= 1 &&
|
||||
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: '参数错误' });
|
||||
}
|
||||
try {
|
||||
const scanLimit = request.page * request.size;
|
||||
const scanLimit = Math.min(
|
||||
request.page * request.size,
|
||||
request.maximumRows,
|
||||
);
|
||||
const page = await sources.triggers.listTriggers({
|
||||
projectId: request.projectId,
|
||||
limit: scanLimit,
|
||||
@@ -149,7 +152,10 @@ export function createPanelCronListRoute(
|
||||
code: 200,
|
||||
data: Object.freeze({
|
||||
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 {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
loadLocalConsoleAssets,
|
||||
type LocalConsoleAsset,
|
||||
} from '../console/localConsoleAssets';
|
||||
import { panelPublicResponse } from '../panel-compatibility/panelBootstrapRoute';
|
||||
import type { LocalApiResponse } from './contract';
|
||||
|
||||
const MAX_HEADER_BYTES = 8 * 1_024;
|
||||
@@ -65,9 +66,12 @@ type LocalApiRouteResolution =
|
||||
| 'invalid_task_list_query'
|
||||
| 'invalid_trigger_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 {
|
||||
readonly profile: LocalApplicationProfile;
|
||||
readonly host: '127.0.0.1' | '::1';
|
||||
@@ -589,7 +593,7 @@ function parsePanelCronListRoute(
|
||||
size < 1 ||
|
||||
size > 64 ||
|
||||
String(size) !== rawSize ||
|
||||
page * size > maximumRows ||
|
||||
(page - 1) * size >= maximumRows ||
|
||||
(timestamp !== null && !/^\d{1,20}$/u.test(timestamp))
|
||||
) {
|
||||
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(
|
||||
request: IncomingMessage,
|
||||
profile: LocalApplicationProfile,
|
||||
@@ -622,6 +673,24 @@ function route(
|
||||
if (request.method === 'GET') {
|
||||
const panelCronList = parsePanelCronListRoute(rawUrl, profile);
|
||||
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;
|
||||
const separator = rawUrl.indexOf('?');
|
||||
@@ -940,6 +1009,22 @@ export async function startLocalApiHttpSurface(
|
||||
sendConsoleFavicon(response, requestId);
|
||||
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);
|
||||
if (!resolvedRoute) {
|
||||
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,
|
||||
randomUuid: () => '019f70c0-0000-4000-8000-000000000002',
|
||||
...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 () => {
|
||||
const { admission, events } = fixture();
|
||||
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);
|
||||
|
||||
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(
|
||||
port,
|
||||
'/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',
|
||||
});
|
||||
assert.equal(unsupportedPanelQuery.statusCode, 400);
|
||||
assert.equal(observed.length, 7);
|
||||
assert.equal(observed.length, 8);
|
||||
|
||||
const cancellationBody = JSON.stringify({
|
||||
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.deepEqual(cancellation.body.accepted, JSON.parse(cancellationBody));
|
||||
assert.deepEqual(observed[7].operation, {
|
||||
assert.deepEqual(observed[8].operation, {
|
||||
operationId: 'run.cancel',
|
||||
projectId: 'prj_default',
|
||||
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.deepEqual(taskStart.body.accepted, JSON.parse(taskStartBody));
|
||||
assert.deepEqual(observed[8].operation, {
|
||||
assert.deepEqual(observed[9].operation, {
|
||||
operationId: 'task.start',
|
||||
projectId: 'prj_default',
|
||||
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.deepEqual(taskPut.body.accepted, JSON.parse(taskPutBody));
|
||||
assert.deepEqual(observed[9].operation, {
|
||||
assert.deepEqual(observed[10].operation, {
|
||||
operationId: 'task.put',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task_1',
|
||||
});
|
||||
assert.equal(observed[9].localPresence, 'ql3p_request_bound_proof');
|
||||
assert.equal(observed[9].taskAuthoringLease, 'ql3a_exact_snapshot_lease');
|
||||
assert.equal(observed[10].localPresence, 'ql3p_request_bound_proof');
|
||||
assert.equal(observed[10].taskAuthoringLease, 'ql3a_exact_snapshot_lease');
|
||||
|
||||
const log = await request(
|
||||
port,
|
||||
'/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(observed[10].operation, {
|
||||
assert.deepEqual(observed[11].operation, {
|
||||
operationId: 'run.log.read',
|
||||
projectId: 'prj_default',
|
||||
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.deepEqual(observed[12].operation, {
|
||||
assert.deepEqual(observed[13].operation, {
|
||||
operationId: 'task.authoring',
|
||||
projectId: 'prj_default',
|
||||
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(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/secrets?limit=8&after=YWxwaGE',
|
||||
);
|
||||
assert.deepEqual(secrets.body, { secrets: [], truncated: false });
|
||||
assert.deepEqual(observed[13].operation, {
|
||||
assert.deepEqual(observed[14].operation, {
|
||||
operationId: 'secret.list',
|
||||
projectId: 'prj_default',
|
||||
limit: 8,
|
||||
@@ -435,11 +443,11 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
);
|
||||
assert.equal(secretPut.statusCode, 202);
|
||||
assert.deepEqual(secretPut.body.accepted, JSON.parse(secretPutBody));
|
||||
assert.deepEqual(observed[14].operation, {
|
||||
assert.deepEqual(observed[15].operation, {
|
||||
operationId: 'secret.put',
|
||||
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 [
|
||||
'/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.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' });
|
||||
}
|
||||
assert.equal(observed.length, 15);
|
||||
assert.equal(observed.length, 16);
|
||||
assert.deepEqual(
|
||||
await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]),
|
||||
['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) => {
|
||||
const port = await reservePort();
|
||||
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(
|
||||
(
|
||||
await route.handle({
|
||||
|
||||
@@ -462,6 +462,34 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
});
|
||||
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}`);
|
||||
assert.equal(accepted.statusCode, 200);
|
||||
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.cancel', 'task.authoring.read', 'task.create', 'task.get',
|
||||
'task.list', 'task.start', 'task.update', 'run.log.read',
|
||||
'panel.system.config.get', 'panel.user.get',
|
||||
'trigger.create', 'trigger.get', 'trigger.list', 'trigger.update',
|
||||
'secret.create', 'secret.list'
|
||||
)
|
||||
@@ -1140,6 +1169,8 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
.all()
|
||||
.map(({ operation_id, outcome }) => `${operation_id}:${outcome}`),
|
||||
[
|
||||
'panel.system.config.get:allowed',
|
||||
'panel.user.get:allowed',
|
||||
'run.cancel:allowed',
|
||||
'run.cancel:allowed',
|
||||
'run.events.list:allowed',
|
||||
|
||||
+64
-8
@@ -18,6 +18,13 @@ import defaultProps from './defaultProps';
|
||||
import './index.less';
|
||||
import { init } from '../utils/init';
|
||||
import WebSocketManager from '../utils/websocket';
|
||||
import {
|
||||
clearQingLong3Credential,
|
||||
discoverQingLong3,
|
||||
isQingLong3PanelSession,
|
||||
qingLong3Capabilities,
|
||||
qingLong3Credential,
|
||||
} from '../utils/qinglong3';
|
||||
|
||||
export interface SharedContext {
|
||||
headerStyle: React.CSSProperties;
|
||||
@@ -38,6 +45,12 @@ interface TSystemInfo {
|
||||
version: string;
|
||||
changeLog: string;
|
||||
changeLogLink: string;
|
||||
ql3?: {
|
||||
schemaVersion: 1;
|
||||
mode: 'local';
|
||||
profile: 'edge' | 'standalone';
|
||||
capabilitiesPath: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function () {
|
||||
@@ -48,7 +61,8 @@ export default function () {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [systemInfo, setSystemInfo] = useState<TSystemInfo>();
|
||||
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 [initLoading, setInitLoading] = useState<boolean>(true);
|
||||
@@ -61,6 +75,12 @@ export default function () {
|
||||
} = DarkReader || {};
|
||||
|
||||
const logout = () => {
|
||||
if (isQingLong3PanelSession()) {
|
||||
clearQingLong3Credential();
|
||||
setUser({});
|
||||
history.push('/login');
|
||||
return;
|
||||
}
|
||||
request.post(`${config.apiPrefix}user/logout`).then(() => {
|
||||
localStorage.removeItem(config.authKey);
|
||||
history.push('/login');
|
||||
@@ -70,14 +90,27 @@ export default function () {
|
||||
const getSystemInfo = () => {
|
||||
request
|
||||
.get(`${config.apiPrefix}system`)
|
||||
.then(({ code, data }) => {
|
||||
.then(async ({ code, data }) => {
|
||||
if (code === 200) {
|
||||
let qingLong3 = null;
|
||||
if (data?.ql3?.capabilitiesPath === '/api/v3/capabilities') {
|
||||
qingLong3 = await discoverQingLong3(
|
||||
`${config.apiPrefix}v3/capabilities`,
|
||||
);
|
||||
}
|
||||
setSystemInfo(data);
|
||||
if (!data.isInitialized) {
|
||||
history.push('/initialization');
|
||||
} else {
|
||||
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) {
|
||||
setUser(data);
|
||||
if (location.pathname === '/') {
|
||||
history.push('/dashboard');
|
||||
history.push(data?.ql3?.panelHome || '/dashboard');
|
||||
}
|
||||
}
|
||||
needLoading && setLoading(false);
|
||||
@@ -165,8 +198,10 @@ export default function () {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!systemInfo) return;
|
||||
if (systemInfo.ql3 && !isQingLong3PanelSession()) return;
|
||||
reloadSystemConfig();
|
||||
}, []);
|
||||
}, [systemInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!['/login', '/initialization', '/error'].includes(location.pathname)) {
|
||||
@@ -210,7 +245,7 @@ export default function () {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !user.username) return;
|
||||
if (!user || !user.username || isQingLong3PanelSession()) return;
|
||||
const ws = WebSocketManager.getInstance(
|
||||
`${window.location.origin}${
|
||||
config.apiPrefix
|
||||
@@ -222,6 +257,16 @@ export default function () {
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isQingLong3PanelSession() &&
|
||||
user?.username &&
|
||||
!['/login', '/error', '/crontab'].includes(location.pathname)
|
||||
) {
|
||||
history.replace('/crontab');
|
||||
}
|
||||
}, [location.pathname, user]);
|
||||
|
||||
useEffect(() => {
|
||||
window.onload = () => {
|
||||
const timing = performance.timing;
|
||||
@@ -244,7 +289,7 @@ export default function () {
|
||||
|
||||
if (['/login', '/initialization', '/error'].includes(location.pathname)) {
|
||||
if (systemInfo?.isInitialized && location.pathname === '/initialization') {
|
||||
history.push('/dashboard');
|
||||
history.push(qingLong3Capabilities() ? '/crontab' : '/dashboard');
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<PageLoading />
|
||||
) : (
|
||||
@@ -393,7 +449,7 @@ export default function () {
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{...defaultProps}
|
||||
{...layoutProps}
|
||||
>
|
||||
<Outlet
|
||||
context={{
|
||||
|
||||
+146
-81
@@ -4,6 +4,10 @@ import { getCommandScript, getCrontabsNextDate } from '@/utils';
|
||||
import config from '@/utils/config';
|
||||
import { diffTime } from '@/utils/date';
|
||||
import { request } from '@/utils/http';
|
||||
import {
|
||||
isQingLong3PanelSession,
|
||||
qingLong3Capabilities,
|
||||
} from '@/utils/qinglong3';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CheckOutlined,
|
||||
@@ -66,6 +70,9 @@ const SHOW_TAB_COUNT = 10;
|
||||
|
||||
const Crontab = () => {
|
||||
const { headerStyle, isPhone, theme } = useOutletContext<SharedContext>();
|
||||
const qingLong3 = qingLong3Capabilities();
|
||||
const qingLong3ReadOnly = qingLong3 !== null;
|
||||
const qingLong3Authenticated = isQingLong3PanelSession();
|
||||
const [allSubscriptions, setAllSubscriptions] = useState<any[]>([]);
|
||||
const columns: ColumnProps<ICrontab>[] = [
|
||||
{
|
||||
@@ -79,16 +86,21 @@ const Crontab = () => {
|
||||
style={{
|
||||
wordBreak: 'break-all',
|
||||
marginBottom: 0,
|
||||
color: '#1890ff',
|
||||
cursor: 'pointer',
|
||||
color: qingLong3ReadOnly ? undefined : '#1890ff',
|
||||
cursor: qingLong3ReadOnly ? 'default' : 'pointer',
|
||||
}}
|
||||
ellipsis={{ tooltip: text, rows: 2 }}
|
||||
onClick={() => {
|
||||
if (qingLong3ReadOnly) return;
|
||||
setDetailCron(record);
|
||||
setIsDetailModalVisible(true);
|
||||
}}
|
||||
>
|
||||
<Link>{record.name || '-'}</Link>
|
||||
{qingLong3ReadOnly ? (
|
||||
record.name || '-'
|
||||
) : (
|
||||
<Link>{record.name || '-'}</Link>
|
||||
)}
|
||||
</Paragraph>
|
||||
),
|
||||
sorter: {
|
||||
@@ -109,13 +121,17 @@ const Crontab = () => {
|
||||
}}
|
||||
ellipsis={{ tooltip: text, rows: 2 }}
|
||||
>
|
||||
<a
|
||||
onClick={() => {
|
||||
goToScriptManager(record);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
{qingLong3ReadOnly ? (
|
||||
text
|
||||
) : (
|
||||
<a
|
||||
onClick={() => {
|
||||
goToScriptManager(record);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
)}
|
||||
</Paragraph>
|
||||
);
|
||||
},
|
||||
@@ -248,8 +264,8 @@ const Crontab = () => {
|
||||
>
|
||||
{record.last_execution_time
|
||||
? dayjs(record.last_execution_time * 1000).format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)
|
||||
: '-'}
|
||||
</span>
|
||||
);
|
||||
@@ -354,6 +370,19 @@ const Crontab = () => {
|
||||
const tableRef = useRef<HTMLDivElement>(null);
|
||||
const tableScrollHeight = useTableScrollHeight(tableRef);
|
||||
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 result = getCommandScript(record.command);
|
||||
@@ -366,12 +395,20 @@ const Crontab = () => {
|
||||
};
|
||||
|
||||
const getCrons = async (silent?: boolean) => {
|
||||
if (qingLong3ReadOnly && !qingLong3Authenticated) {
|
||||
if (!silent) setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!silent) setLoading(true);
|
||||
const { page = 1, size = 20, sorter, filters = {} } = pageConf;
|
||||
let url = `${config.apiPrefix
|
||||
}crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify(
|
||||
filters,
|
||||
)}`;
|
||||
const effectiveSize = qingLong3ReadOnly
|
||||
? Math.min(size, qingLong3?.limits.cronPageSize || 20)
|
||||
: 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) {
|
||||
url += `&sorter=${JSON.stringify({
|
||||
field: sorter.column.key,
|
||||
@@ -390,6 +427,11 @@ const Crontab = () => {
|
||||
.then(async ({ code, data: _data }) => {
|
||||
if (code === 200) {
|
||||
const { data, total } = _data;
|
||||
if (qingLong3ReadOnly) {
|
||||
setValue(data);
|
||||
setTotal(total);
|
||||
return;
|
||||
}
|
||||
const subscriptions = await request.get(
|
||||
`${config.apiPrefix}subscriptions?ids=${JSON.stringify([
|
||||
...new Set(data.map((x) => x.sub_id).filter(Boolean)),
|
||||
@@ -544,8 +586,9 @@ const Crontab = () => {
|
||||
|
||||
const enabledOrDisabledCron = (record: any, index: number) => {
|
||||
Modal.confirm({
|
||||
title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
|
||||
}`,
|
||||
title: `确认${
|
||||
record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
|
||||
}`,
|
||||
content: (
|
||||
<>
|
||||
{intl.get('确认')}
|
||||
@@ -560,7 +603,8 @@ const Crontab = () => {
|
||||
onOk() {
|
||||
request
|
||||
.put(
|
||||
`${config.apiPrefix}crons/${record.isDisabled === 1 ? 'enable' : 'disable'
|
||||
`${config.apiPrefix}crons/${
|
||||
record.isDisabled === 1 ? 'enable' : 'disable'
|
||||
}`,
|
||||
[record.id],
|
||||
)
|
||||
@@ -584,8 +628,9 @@ const Crontab = () => {
|
||||
|
||||
const pinOrUnPinCron = (record: any, index: number) => {
|
||||
Modal.confirm({
|
||||
title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
|
||||
}`,
|
||||
title: `确认${
|
||||
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
|
||||
}`,
|
||||
content: (
|
||||
<>
|
||||
{intl.get('确认')}
|
||||
@@ -600,7 +645,8 @@ const Crontab = () => {
|
||||
onOk() {
|
||||
request
|
||||
.put(
|
||||
`${config.apiPrefix}crons/${record.isPinned === 1 ? 'unpin' : 'pin'
|
||||
`${config.apiPrefix}crons/${
|
||||
record.isPinned === 1 ? 'unpin' : 'pin'
|
||||
}`,
|
||||
[record.id],
|
||||
)
|
||||
@@ -777,8 +823,8 @@ const Crontab = () => {
|
||||
setPageConf({
|
||||
page: current as number,
|
||||
size: pageSize as number,
|
||||
sorter,
|
||||
filters,
|
||||
sorter: qingLong3ReadOnly ? {} : sorter,
|
||||
filters: qingLong3ReadOnly ? {} : filters,
|
||||
});
|
||||
localStorage.setItem('pageSize', String(pageSize));
|
||||
};
|
||||
@@ -824,10 +870,20 @@ const Crontab = () => {
|
||||
setAllSubscriptions(data || []);
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (qingLong3ReadOnly) {
|
||||
setPageConf({
|
||||
page: 1,
|
||||
size: Math.min(20, qingLong3?.limits.cronPageSize || 20),
|
||||
sorter: {},
|
||||
filters: {},
|
||||
});
|
||||
setActiveKey('all');
|
||||
return;
|
||||
}
|
||||
getCronViews();
|
||||
getAllSubscriptions();
|
||||
}, []);
|
||||
@@ -931,56 +987,63 @@ const Crontab = () => {
|
||||
<PageContainer
|
||||
className="ql-container-wrapper crontab-wrapper ql-container-wrapper-has-tab"
|
||||
title={intl.get('定时任务')}
|
||||
extra={[
|
||||
<Search
|
||||
placeholder={intl.get('请输入名称或者关键词')}
|
||||
style={{ width: 'auto' }}
|
||||
enterButton
|
||||
allowClear
|
||||
loading={loading}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onSearch={onSearch}
|
||||
/>,
|
||||
<Button key="2" type="primary" onClick={() => addCron()}>
|
||||
{intl.get('创建任务')}
|
||||
</Button>,
|
||||
]}
|
||||
extra={
|
||||
qingLong3ReadOnly
|
||||
? [<Tag key="ql3-read-only">QingLong 3.0 · 有界只读</Tag>]
|
||||
: [
|
||||
<Search
|
||||
key="search"
|
||||
placeholder={intl.get('请输入名称或者关键词')}
|
||||
style={{ width: 'auto' }}
|
||||
enterButton
|
||||
allowClear
|
||||
loading={loading}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onSearch={onSearch}
|
||||
/>,
|
||||
<Button key="create" type="primary" onClick={() => addCron()}>
|
||||
{intl.get('创建任务')}
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
header={{
|
||||
style: headerStyle,
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
defaultActiveKey="all"
|
||||
size="small"
|
||||
activeKey={activeKey}
|
||||
tabPosition="top"
|
||||
className={`crontab-view ${moreMenuActive ? 'more-active' : ''}`}
|
||||
tabBarExtraContent={
|
||||
<Dropdown
|
||||
menu={menu}
|
||||
trigger={['click']}
|
||||
overlayStyle={{ minWidth: 200 }}
|
||||
>
|
||||
<div className={`view-more ${moreMenuActive ? 'active' : ''}`}>
|
||||
<Space>
|
||||
{intl.get('更多')}
|
||||
<DownOutlined />
|
||||
</Space>
|
||||
<div className="ant-tabs-ink-bar ant-tabs-ink-bar-animated"></div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
}
|
||||
onTabClick={tabClick}
|
||||
items={[
|
||||
...[...enabledCronViews].slice(0, SHOW_TAB_COUNT).map((x) => ({
|
||||
key: x.id,
|
||||
label: x.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
{!qingLong3ReadOnly && (
|
||||
<Tabs
|
||||
defaultActiveKey="all"
|
||||
size="small"
|
||||
activeKey={activeKey}
|
||||
tabPosition="top"
|
||||
className={`crontab-view ${moreMenuActive ? 'more-active' : ''}`}
|
||||
tabBarExtraContent={
|
||||
<Dropdown
|
||||
menu={menu}
|
||||
trigger={['click']}
|
||||
overlayStyle={{ minWidth: 200 }}
|
||||
>
|
||||
<div className={`view-more ${moreMenuActive ? 'active' : ''}`}>
|
||||
<Space>
|
||||
{intl.get('更多')}
|
||||
<DownOutlined />
|
||||
</Space>
|
||||
<div className="ant-tabs-ink-bar ant-tabs-ink-bar-animated"></div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
}
|
||||
onTabClick={tabClick}
|
||||
items={[
|
||||
...[...enabledCronViews].slice(0, SHOW_TAB_COUNT).map((x) => ({
|
||||
key: x.id,
|
||||
label: x.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<div ref={tableRef}>
|
||||
{selectedRowIds.length > 0 && (
|
||||
{!qingLong3ReadOnly && selectedRowIds.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -1042,7 +1105,7 @@ const Crontab = () => {
|
||||
</div>
|
||||
)}
|
||||
<Table
|
||||
columns={columns}
|
||||
columns={visibleColumns}
|
||||
sortDirections={['descend', 'ascend']}
|
||||
pagination={{
|
||||
current: pageConf.page,
|
||||
@@ -1052,22 +1115,24 @@ const Crontab = () => {
|
||||
total,
|
||||
showTotal: (total: number, range: number[]) =>
|
||||
`第 ${range[0]}-${range[1]} 条/总共 ${total} 条`,
|
||||
pageSizeOptions: [10, 20, 50, 100, 200, 500, total || 10000].sort(
|
||||
(a, b) => a - b,
|
||||
),
|
||||
pageSizeOptions: qingLong3ReadOnly
|
||||
? [10, 20, 50, qingLong3?.limits.cronPageSize || 64]
|
||||
: [10, 20, 50, 100, 200, 500, total || 10000].sort(
|
||||
(a, b) => a - b,
|
||||
),
|
||||
}}
|
||||
dataSource={value}
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
scroll={{ x: 1200, y: tableScrollHeight }}
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
rowSelection={qingLong3ReadOnly ? undefined : rowSelection}
|
||||
rowClassName={getRowClassName}
|
||||
onChange={onPageChange}
|
||||
components={isPhone || pageConf.size < 50 ? undefined : vt}
|
||||
/>
|
||||
</div>
|
||||
{isLogModalVisible && (
|
||||
{!qingLong3ReadOnly && isLogModalVisible && (
|
||||
<CronLogModal
|
||||
handleCancel={() => {
|
||||
getCronDetail(logCron);
|
||||
@@ -1076,10 +1141,10 @@ const Crontab = () => {
|
||||
cron={logCron}
|
||||
/>
|
||||
)}
|
||||
{isModalVisible && (
|
||||
{!qingLong3ReadOnly && isModalVisible && (
|
||||
<CronModal handleCancel={handleCancel} cron={editedCron} />
|
||||
)}
|
||||
{isLabelModalVisible && (
|
||||
{!qingLong3ReadOnly && isLabelModalVisible && (
|
||||
<CronLabelModal
|
||||
handleCancel={(needUpdate?: boolean) => {
|
||||
setIsLabelModalVisible(false);
|
||||
@@ -1090,7 +1155,7 @@ const Crontab = () => {
|
||||
ids={selectedRowIds}
|
||||
/>
|
||||
)}
|
||||
{isDetailModalVisible && (
|
||||
{!qingLong3ReadOnly && isDetailModalVisible && (
|
||||
<CronDetailModal
|
||||
handleCancel={(needUpdate?: boolean) => {
|
||||
setIsDetailModalVisible(false);
|
||||
@@ -1103,7 +1168,7 @@ const Crontab = () => {
|
||||
isPhone={isPhone}
|
||||
/>
|
||||
)}
|
||||
{isCreateViewModalVisible && (
|
||||
{!qingLong3ReadOnly && isCreateViewModalVisible && (
|
||||
<ViewCreateModal
|
||||
handleCancel={(data) => {
|
||||
setIsCreateViewModalVisible(false);
|
||||
@@ -1111,7 +1176,7 @@ const Crontab = () => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isViewManageModalVisible && (
|
||||
{!qingLong3ReadOnly && isViewManageModalVisible && (
|
||||
<ViewManageModal
|
||||
cronViews={cronViews}
|
||||
handleCancel={() => {
|
||||
|
||||
+84
-19
@@ -17,24 +17,59 @@ import { useTheme } from '@/utils/hooks';
|
||||
import { MobileOutlined } from '@ant-design/icons';
|
||||
import { SharedContext } from '@/layouts';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
clearQingLong3Credential,
|
||||
qingLong3Capabilities,
|
||||
qingLong3Credential,
|
||||
setQingLong3Credential,
|
||||
} from '@/utils/qinglong3';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
const { Countdown } = Statistic;
|
||||
const isDemoEnv = window.__ENV__DeployEnv === 'demo';
|
||||
|
||||
const Login = () => {
|
||||
const { reloadUser } = useOutletContext<SharedContext>();
|
||||
const { reloadSystemConfig, reloadUser } = useOutletContext<SharedContext>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [waitTime, setWaitTime] = useState<any>();
|
||||
const { theme } = useTheme();
|
||||
const [twoFactor, setTwoFactor] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [loginInfo, setLoginInfo] = useState<any>();
|
||||
const qingLong3 = qingLong3Capabilities();
|
||||
|
||||
const handleOk = (values: any) => {
|
||||
setLoading(true);
|
||||
setTwoFactor(false);
|
||||
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
|
||||
.post(`${config.apiPrefix}user/login`, {
|
||||
username: values.username,
|
||||
@@ -134,11 +169,13 @@ const Login = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const isAuth = localStorage.getItem(config.authKey);
|
||||
const isAuth = qingLong3
|
||||
? qingLong3Credential()
|
||||
: localStorage.getItem(config.authKey);
|
||||
if (isAuth) {
|
||||
history.push('/dashboard');
|
||||
history.push(qingLong3 ? '/crontab' : '/dashboard');
|
||||
}
|
||||
}, []);
|
||||
}, [qingLong3]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@@ -150,7 +187,11 @@ const Login = () => {
|
||||
src="https://qn.whyour.cn/logo.png"
|
||||
/>
|
||||
<span className={styles.title}>
|
||||
{twoFactor ? intl.get('两步验证') : config.siteName}
|
||||
{twoFactor
|
||||
? intl.get('两步验证')
|
||||
: qingLong3
|
||||
? 'QingLong 3.0'
|
||||
: config.siteName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,20 +227,44 @@ const Login = () => {
|
||||
</Form>
|
||||
) : (
|
||||
<Form layout="vertical" onFinish={handleOk}>
|
||||
<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>
|
||||
{qingLong3 ? (
|
||||
<FormItem
|
||||
name="credential"
|
||||
label="API Credential"
|
||||
hasFeedback
|
||||
rules={[{ required: true, message: '请输入 API Credential' }]}
|
||||
extra="凭据只保存在当前页面内存;刷新或关闭页面后需要重新输入。"
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="ql3c_…"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
</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>
|
||||
{waitTime ? (
|
||||
<Button type="primary" style={{ width: '100%' }} disabled>
|
||||
|
||||
+4
-1
@@ -9,6 +9,7 @@ import axios, {
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
import { clearQingLong3Credential, qingLong3Credential } from './qinglong3';
|
||||
|
||||
export interface IResponseData {
|
||||
code?: number;
|
||||
@@ -49,6 +50,7 @@ const errorHandler = function (
|
||||
} else if (responseStatus === 401) {
|
||||
if (history.location.pathname !== '/login') {
|
||||
message.error(intl.get('登录已过期,请重新登录'));
|
||||
clearQingLong3Credential();
|
||||
localStorage.removeItem(config.authKey);
|
||||
history.push('/login');
|
||||
}
|
||||
@@ -93,7 +95,7 @@ const apiWhiteList = [
|
||||
];
|
||||
|
||||
_request.interceptors.request.use((_config) => {
|
||||
const token = localStorage.getItem(config.authKey);
|
||||
const token = qingLong3Credential() || localStorage.getItem(config.authKey);
|
||||
if (token && !apiWhiteList.includes(_config.url!)) {
|
||||
_config.headers.Authorization = `Bearer ${token}`;
|
||||
return _config;
|
||||
@@ -107,6 +109,7 @@ _request.interceptors.response.use(async (response) => {
|
||||
history.push('/error');
|
||||
} else if (responseStatus === 401) {
|
||||
if (history.location.pathname !== '/login') {
|
||||
clearQingLong3Credential();
|
||||
localStorage.removeItem(config.authKey);
|
||||
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