fix(cli): expose asynchronous dependency refresh operations

This commit is contained in:
whyour
2026-11-03 00:10:13 +08:00
parent a6c4acbfd2
commit 6d69c3340c
4 changed files with 70 additions and 7 deletions
+12 -2
View File
@@ -1,8 +1,8 @@
# OpenAPI 全量参考 / Complete OpenAPI reference
CLI 对应本版本后端的 146 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
CLI 对应本版本后端的 151 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
The catalogue covers 146 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
The catalogue covers 151 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
## 认证与权限 / Authentication and permissions
@@ -53,12 +53,19 @@ ql config save --data '{"name":"example.sh","content":"# example"}' --json
ql config get --query '{"path":"example.sh"}' --json
ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json
ql dependency create --data '[{"name":"example-package","type":0}]' --json
ql dependency refresh --data '{"type":"python3"}' --json
ql dependency refresh-active --json
ql dependency refresh-status <JOB_UUID> --json
ql system data-export --data '{}' --output ./panel.tgz --json
ql system data-import --file ./panel.tgz --json
ql api request PUT /open/crons/run --data '[12,13]' --json
ql api request GET /open/crons --query '{"page":1,"size":100,"searchValue":"demo"}' --json
```
依赖 refresh 的 type 为 nodejs 或 python3,立即返回后台任务 id 与 state;用 refresh-status 查询该 UUID,直到 completed 或 failed。refresh-active 返回运行中的任务或 null,普通 list 不触发检查。
Dependency refresh accepts type nodejs or python3 and immediately returns a background job id and state. Poll refresh-status with that UUID until completed or failed. refresh-active returns the running job or null; listing dependencies does not trigger checks.
任务 create/update 需要 command/schedule;name、labels、sub_id、extra_schedules、task_before/after、log_name、allow_multiple_instances、work_dir 等字段可通过 --data 提交。订阅 create 需要 type/url/alias/schedule_type,update 需要 id/type/url/alias;interval_schedule、pull_option、dependences、extensions、sub_before/after、proxy、autoAddCron/autoDelCron 等用 --data。private-repo 凭据仅通过受保护文件或 stdin 提供。环境变量与依赖 create 接受对象数组,应用 scopes 为字符串数组。其余准确字段/枚举由当前后端 Joi schema 校验:见 [back/api](https://github.com/whyour/qinglong/tree/develop/back/api) 和 [定时规则 schema](https://github.com/whyour/qinglong/blob/develop/back/validation/schedule.ts)。
Task creation/update needs command/schedule; additional fields use --data. Subscription creation needs type/url/alias/schedule_type; updates need id/type/url/alias. Interval schedules, private repository credentials, filters, hooks and booleans use --data. Env/dependency creation accepts arrays; application scopes is a string array. The server validates field types/enums; the linked backend schemas are authoritative.
@@ -151,6 +158,9 @@ System/user operations below execute remotely through the panel API. They do not
| `ql log delete ` | DELETE | `logs` | --data, --query |
| `ql log download ` | POST | `logs/download` | --data, --output, --query |
| `ql dependency list ` | GET | `dependencies` | --query |
| `ql dependency refresh ` | POST | `dependencies/refresh` | --data, --query |
| `ql dependency refresh-active ` | GET | `dependencies/refresh/active` | --query |
| `ql dependency refresh-status <id>` | GET | `dependencies/refresh/:id` | --query |
| `ql dependency create ` | POST | `dependencies` | --data, --query |
| `ql dependency update <id>` | PUT | `dependencies` | --data, --query |
| `ql dependency delete <id...>` | DELETE | `dependencies` | --query |
+19
View File
@@ -5,6 +5,7 @@ export interface OpenOperation {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
params?: string[];
uuidParams?: string[];
body?: 'ids' | 'json' | 'object-id';
upload?: string;
download?: boolean;
@@ -334,6 +335,24 @@ export const openOperations: readonly OpenOperation[] = [
download: true,
},
{ name: 'dependency list', method: 'GET', path: 'dependencies' },
{
name: 'dependency refresh',
method: 'POST',
path: 'dependencies/refresh',
body: 'json',
},
{
name: 'dependency refresh-active',
method: 'GET',
path: 'dependencies/refresh/active',
},
{
name: 'dependency refresh-status',
method: 'GET',
path: 'dependencies/refresh/:id',
params: ['id'],
uuidParams: ['id'],
},
{
name: 'dependency create',
method: 'POST',
+15 -3
View File
@@ -49,12 +49,19 @@ async function input(value: string | boolean | undefined): Promise<unknown> {
}
}
const UUID_PATTERN =
'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
function route(method: string, path: string): OpenOperation {
const match = openOperations.find(
(op) =>
op.method === method &&
new RegExp(
'^' + op.path.replace(/:[A-Za-z]+/g, '[1-9][0-9]*') + '$',
'^' +
op.path.replace(/:([A-Za-z]+)/g, (_match, name) =>
op.uuidParams?.includes(name) ? UUID_PATTERN : '[1-9][0-9]*',
) +
'$',
).test(path),
);
if (!match)
@@ -63,7 +70,8 @@ function route(method: string, path: string): OpenOperation {
'Method/path is not a supported OpenAPI route.',
);
for (const [index, part] of match.path.split('/').entries())
if (part.startsWith(':')) integer(path.split('/')[index]!);
if (part.startsWith(':') && !match.uuidParams?.includes(part.slice(1)))
integer(path.split('/')[index]!);
return match;
}
@@ -107,7 +115,11 @@ export async function openCommand(
if (!generic) {
endpoint = op.path;
for (const [index, name] of (op.params ?? []).entries()) {
const value = integer(command.positionals[index]!);
const positional = command.positionals[index]!;
const isUuid = op.uuidParams?.includes(name);
if (isUuid && !new RegExp(`^${UUID_PATTERN}$`).test(positional))
usage('任务 ID 必须为 UUID。', 'Job ID must be a UUID.');
const value = isUuid ? positional : integer(positional);
endpoint = endpoint.replace(`:${name}`, String(value));
if (op.body === 'object-id') {
if (body !== undefined && !isRecord(body))
+24 -2
View File
@@ -83,6 +83,27 @@ test('script history commands send exact query parameters and restore payloads',
assert.equal(f.requests.length, count, 'missing restore payload must not issue a request');
});
test('dependency refresh commands send the dependency type and address background jobs', async t => {
const f = await fixture(t);
success(await f.run(['dependency', 'refresh', '--data', '{"type":"python3"}']));
assert.equal(f.requests[0].method, 'POST');
assert.equal(f.requests[0].url, '/panel/open/dependencies/refresh');
assert.deepEqual(JSON.parse(f.requests[0].body), { type: 'python3' });
const id = '32ec8b22-2c48-4bcf-85d4-b394198417ba';
success(await f.run(['dependency', 'refresh-status', id]));
assert.equal(f.requests[1].method, 'GET');
assert.equal(f.requests[1].url, `/panel/open/dependencies/refresh/${id}`);
success(await f.run(['dependency', 'refresh-active']));
assert.equal(f.requests[2].method, 'GET');
assert.equal(f.requests[2].url, '/panel/open/dependencies/refresh/active');
success(await f.run(['api', 'request', 'GET', `/open/dependencies/refresh/${id}`]));
assert.equal(f.requests[3].url, `/panel/open/dependencies/refresh/${id}`);
assert.equal((await f.run(['dependency', 'refresh-status', '7'])).code, 2);
assert.equal((await f.run(['api', 'request', 'GET', '/open/dependencies/refresh/7'])).code, 2);
assert.equal((await f.run(['dependency', 'refresh'])).code, 2);
assert.equal(f.requests.length, 4, 'invalid arguments must not issue a request');
});
test('OpenAPI catalogue covers every active registered backend route; retired 410 routes are explicit', async () => {
const root = path.resolve(__dirname, '../../../back/api');
const expected = [];
@@ -132,7 +153,8 @@ test('all added named operations reach their registered method/path, including u
const file = path.join(f.root, 'fixture.json'); await fs.writeFile(file, '{}');
for (const op of openOperations.filter(op => !op.existing)) {
const args = op.name.split(' ');
for (const _ of op.params ?? []) args.push('7');
const parameter = name => op.uuidParams?.includes(name) ? '32ec8b22-2c48-4bcf-85d4-b394198417ba' : '7';
for (const name of op.params ?? []) args.push(parameter(name));
if (op.body === 'ids') args.push('7', '8');
else if (op.body) {
const data = op.name.startsWith('task ') ? { command: 'echo fixture', schedule: '0 0 * * *' }
@@ -144,7 +166,7 @@ test('all added named operations reach their registered method/path, including u
success(await f.run(args));
const last = f.requests.at(-1);
assert.equal(last.method, op.method, op.name);
assert.equal(last.url, '/panel/open/' + op.path.replace(/:[A-Za-z]+/g, '7'), op.name);
assert.equal(last.url, '/panel/open/' + op.path.replace(/:([A-Za-z]+)/g, (_match, name) => parameter(name)), op.name);
assert.equal(last.headers.authorization, op.anonymous ? undefined : 'Bearer fixture-token', op.name);
if (op.upload) assert.ok(last.body.includes(`name="${op.upload}"; filename="fixture.json"`), op.name);
}