mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-06 07:10:41 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41910b5236 | ||
|
|
442ab2c616 |
+68
-6
@@ -11,12 +11,11 @@ export default (app: Router) => {
|
|||||||
route.get(
|
route.get(
|
||||||
'/',
|
'/',
|
||||||
celebrate({
|
celebrate({
|
||||||
query:
|
query: Joi.object({
|
||||||
Joi.object({
|
searchValue: Joi.string().optional().allow(''),
|
||||||
searchValue: Joi.string().optional().allow(''),
|
type: Joi.string().optional().allow(''),
|
||||||
type: Joi.string().optional().allow(''),
|
status: Joi.string().optional().allow(''),
|
||||||
status: Joi.string().optional().allow(''),
|
}).unknown(true),
|
||||||
}).unknown(true),
|
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const logger: Logger = Container.get('logger');
|
const logger: Logger = Container.get('logger');
|
||||||
@@ -31,6 +30,69 @@ export default (app: Router) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
route.post(
|
||||||
|
'/refresh',
|
||||||
|
celebrate({
|
||||||
|
body: Joi.object({
|
||||||
|
type: Joi.string().valid('nodejs', 'python3').required(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const dependenceService = Container.get(DependenceService);
|
||||||
|
const { job, accepted } = dependenceService.startRefresh(req.body.type);
|
||||||
|
if (!accepted) {
|
||||||
|
return res.status(409).send({
|
||||||
|
code: 409,
|
||||||
|
message: 'Another dependency refresh is already running',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.send({ code: 200, data: job });
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.get(
|
||||||
|
'/refresh/active',
|
||||||
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const dependenceService = Container.get(DependenceService);
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
return res.send({
|
||||||
|
code: 200,
|
||||||
|
data: dependenceService.getActiveRefreshJob() || null,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
route.get(
|
||||||
|
'/refresh/:id',
|
||||||
|
celebrate({
|
||||||
|
params: Joi.object({ id: Joi.string().uuid().required() }),
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const dependenceService = Container.get(DependenceService);
|
||||||
|
const job = dependenceService.getRefreshJob(req.params.id);
|
||||||
|
if (!job) {
|
||||||
|
return res.status(404).send({
|
||||||
|
code: 404,
|
||||||
|
message: 'Dependency refresh job not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
return res.send({ code: 200, data: job });
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
route.post(
|
route.post(
|
||||||
'/',
|
'/',
|
||||||
celebrate({
|
celebrate({
|
||||||
|
|||||||
+76
-19
@@ -27,9 +27,20 @@ import taskLimit from '../shared/pLimit';
|
|||||||
import { detectOS } from '../config/util';
|
import { detectOS } from '../config/util';
|
||||||
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
|
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
|
||||||
import { t, tf } from '../shared/i18n';
|
import { t, tf } from '../shared/i18n';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
type RefreshType = 'nodejs' | 'python3';
|
||||||
|
type RefreshJob = {
|
||||||
|
id: string;
|
||||||
|
type: RefreshType;
|
||||||
|
state: 'running' | 'completed' | 'failed';
|
||||||
|
};
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class DependenceService {
|
export default class DependenceService {
|
||||||
|
private activeRefreshJob?: RefreshJob;
|
||||||
|
private refreshJobs = new Map<string, RefreshJob>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject('logger') private logger: winston.Logger,
|
@Inject('logger') private logger: winston.Logger,
|
||||||
private sockService: SockService,
|
private sockService: SockService,
|
||||||
@@ -118,11 +129,6 @@ export default class DependenceService {
|
|||||||
query: any = {},
|
query: any = {},
|
||||||
): Promise<Dependence[]> {
|
): Promise<Dependence[]> {
|
||||||
let condition = query;
|
let condition = query;
|
||||||
const dependenceType =
|
|
||||||
type && DependenceTypes[type] !== undefined
|
|
||||||
? DependenceTypes[type]
|
|
||||||
: undefined;
|
|
||||||
await this.refreshInstalledStatuses(dependenceType);
|
|
||||||
if (type && DependenceTypes[type] !== undefined) {
|
if (type && DependenceTypes[type] !== undefined) {
|
||||||
condition.type = DependenceTypes[type];
|
condition.type = DependenceTypes[type];
|
||||||
}
|
}
|
||||||
@@ -145,6 +151,52 @@ export default class DependenceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public startRefresh(type: RefreshType): {
|
||||||
|
job: RefreshJob;
|
||||||
|
accepted: boolean;
|
||||||
|
} {
|
||||||
|
if (this.activeRefreshJob) {
|
||||||
|
return {
|
||||||
|
job: this.activeRefreshJob,
|
||||||
|
accepted: this.activeRefreshJob.type === type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const job: RefreshJob = { id: randomUUID(), type, state: 'running' };
|
||||||
|
this.activeRefreshJob = job;
|
||||||
|
this.refreshJobs.set(job.id, job);
|
||||||
|
while (this.refreshJobs.size > 20) {
|
||||||
|
const oldestId = this.refreshJobs.keys().next().value;
|
||||||
|
this.refreshJobs.delete(oldestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.refreshInstalledStatuses(DependenceTypes[type])
|
||||||
|
.then(() => {
|
||||||
|
job.state = 'completed';
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.logger.error('Dependency refresh failed: %o', error);
|
||||||
|
job.state = 'failed';
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (this.activeRefreshJob === job) {
|
||||||
|
this.activeRefreshJob = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { job, accepted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRefreshJob(id: string): RefreshJob | undefined {
|
||||||
|
return this.refreshJobs.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getActiveRefreshJob(): RefreshJob | undefined {
|
||||||
|
return this.activeRefreshJob?.state === 'running'
|
||||||
|
? this.activeRefreshJob
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
private async refreshInstalledStatuses(type?: DependenceTypes) {
|
private async refreshInstalledStatuses(type?: DependenceTypes) {
|
||||||
const cacheDependenceTypes = [
|
const cacheDependenceTypes = [
|
||||||
DependenceTypes.nodejs,
|
DependenceTypes.nodejs,
|
||||||
@@ -160,26 +212,31 @@ export default class DependenceService {
|
|||||||
type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type,
|
type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const checkableDocs = docs.filter(
|
||||||
|
(doc) =>
|
||||||
|
doc.type !== DependenceTypes.python3 ||
|
||||||
|
!isPythonSourceDependency(doc.name),
|
||||||
|
);
|
||||||
const checks = await concurrentRun(
|
const checks = await concurrentRun(
|
||||||
docs
|
checkableDocs.map((doc) => async () => this.isDependenceInstalled(doc)),
|
||||||
.filter(
|
|
||||||
(doc) =>
|
|
||||||
doc.type !== DependenceTypes.python3 ||
|
|
||||||
!isPythonSourceDependency(doc.name),
|
|
||||||
)
|
|
||||||
.map((doc) => async () => {
|
|
||||||
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
|
|
||||||
}),
|
|
||||||
5,
|
5,
|
||||||
);
|
);
|
||||||
const missingIds = (checks || []).filter(
|
|
||||||
(id): id is number => id !== undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (missingIds.length) {
|
for (const [index, installed] of (checks || []).entries()) {
|
||||||
|
if (installed) continue;
|
||||||
|
const doc = checkableDocs[index];
|
||||||
await DependenceModel.update(
|
await DependenceModel.update(
|
||||||
{ status: DependenceStatus.installFailed },
|
{ status: DependenceStatus.installFailed },
|
||||||
{ where: { id: missingIds } },
|
{
|
||||||
|
where: {
|
||||||
|
id: doc.id,
|
||||||
|
status: DependenceStatus.installed,
|
||||||
|
name: doc.name,
|
||||||
|
type: doc.type,
|
||||||
|
updatedAt: (doc as typeof doc & { updatedAt: Date }).updatedAt,
|
||||||
|
// Sequelize's automatic timestamp is absent from Dependence's attribute type.
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# OpenAPI 全量参考 / Complete OpenAPI reference
|
# 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
|
## 认证与权限 / Authentication and permissions
|
||||||
|
|
||||||
@@ -56,12 +56,19 @@ ql config save --data '{"name":"example.sh","content":"# example"}' --json
|
|||||||
ql config get --query '{"path":"example.sh"}' --json
|
ql config get --query '{"path":"example.sh"}' --json
|
||||||
ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --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 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-export --data '{}' --output ./panel.tgz --json
|
||||||
ql system data-import --file ./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 PUT /open/crons/run --data '[12,13]' --json
|
||||||
ql api request GET /open/crons --query '{"page":1,"size":100,"searchValue":"demo"}' --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)。
|
任务 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.
|
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.
|
||||||
@@ -154,6 +161,9 @@ System/user operations below execute remotely through the panel API. They do not
|
|||||||
| `ql log delete ` | DELETE | `logs` | --data, --query |
|
| `ql log delete ` | DELETE | `logs` | --data, --query |
|
||||||
| `ql log download ` | POST | `logs/download` | --data, --output, --query |
|
| `ql log download ` | POST | `logs/download` | --data, --output, --query |
|
||||||
| `ql dependency list ` | GET | `dependencies` | --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 create ` | POST | `dependencies` | --data, --query |
|
||||||
| `ql dependency update <id>` | PUT | `dependencies` | --data, --query |
|
| `ql dependency update <id>` | PUT | `dependencies` | --data, --query |
|
||||||
| `ql dependency delete <id...>` | DELETE | `dependencies` | --query |
|
| `ql dependency delete <id...>` | DELETE | `dependencies` | --query |
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface OpenOperation {
|
|||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||||
path: string;
|
path: string;
|
||||||
params?: string[];
|
params?: string[];
|
||||||
|
uuidParams?: string[];
|
||||||
body?: 'ids' | 'json' | 'object-id';
|
body?: 'ids' | 'json' | 'object-id';
|
||||||
upload?: string;
|
upload?: string;
|
||||||
download?: boolean;
|
download?: boolean;
|
||||||
@@ -334,6 +335,24 @@ export const openOperations: readonly OpenOperation[] = [
|
|||||||
download: true,
|
download: true,
|
||||||
},
|
},
|
||||||
{ name: 'dependency list', method: 'GET', path: 'dependencies' },
|
{ 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',
|
name: 'dependency create',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -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 {
|
function route(method: string, path: string): OpenOperation {
|
||||||
const match = openOperations.find(
|
const match = openOperations.find(
|
||||||
(op) =>
|
(op) =>
|
||||||
op.method === method &&
|
op.method === method &&
|
||||||
new RegExp(
|
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),
|
).test(path),
|
||||||
);
|
);
|
||||||
if (!match)
|
if (!match)
|
||||||
@@ -63,7 +70,8 @@ function route(method: string, path: string): OpenOperation {
|
|||||||
'Method/path is not a supported OpenAPI route.',
|
'Method/path is not a supported OpenAPI route.',
|
||||||
);
|
);
|
||||||
for (const [index, part] of match.path.split('/').entries())
|
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;
|
return match;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +115,11 @@ export async function openCommand(
|
|||||||
if (!generic) {
|
if (!generic) {
|
||||||
endpoint = op.path;
|
endpoint = op.path;
|
||||||
for (const [index, name] of (op.params ?? []).entries()) {
|
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));
|
endpoint = endpoint.replace(`:${name}`, String(value));
|
||||||
if (op.body === 'object-id') {
|
if (op.body === 'object-id') {
|
||||||
if (body !== undefined && !isRecord(body))
|
if (body !== undefined && !isRecord(body))
|
||||||
|
|||||||
@@ -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');
|
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 () => {
|
test('OpenAPI catalogue covers every active registered backend route; retired 410 routes are explicit', async () => {
|
||||||
const root = path.resolve(__dirname, '../../../back/api');
|
const root = path.resolve(__dirname, '../../../back/api');
|
||||||
const expected = [];
|
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, '{}');
|
const file = path.join(f.root, 'fixture.json'); await fs.writeFile(file, '{}');
|
||||||
for (const op of openOperations.filter(op => !op.existing)) {
|
for (const op of openOperations.filter(op => !op.existing)) {
|
||||||
const args = op.name.split(' ');
|
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');
|
if (op.body === 'ids') args.push('7', '8');
|
||||||
else if (op.body) {
|
else if (op.body) {
|
||||||
const data = op.name.startsWith('task ') ? { command: 'echo fixture', schedule: '0 0 * * *' }
|
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));
|
success(await f.run(args));
|
||||||
const last = f.requests.at(-1);
|
const last = f.requests.at(-1);
|
||||||
assert.equal(last.method, op.method, op.name);
|
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);
|
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);
|
if (op.upload) assert.ok(last.body.includes(`name="${op.upload}"; filename="fixture.json"`), op.name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"选中条数": "Selected {count} items",
|
"选中条数": "Selected {count} items",
|
||||||
|
"重新检查依赖": "Recheck dependencies",
|
||||||
|
"依赖检查失败": "Dependency check failed",
|
||||||
|
"正在检查依赖": "Checking {type} dependencies",
|
||||||
|
"依赖检查状态已中断": "Dependency check status was lost. Please retry.",
|
||||||
"登录失败次数": "Failed {count} time(s)",
|
"登录失败次数": "Failed {count} time(s)",
|
||||||
"间隔定时规则": "Every {value} {unit}",
|
"间隔定时规则": "Every {value} {unit}",
|
||||||
"SOCK5代理,例如 IP:PORT": "SOCKS5 proxy, e.g. IP:PORT",
|
"SOCK5代理,例如 IP:PORT": "SOCKS5 proxy, e.g. IP:PORT",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"选中条数": "已选择 {count} 项",
|
"选中条数": "已选择 {count} 项",
|
||||||
|
"重新检查依赖": "重新检查依赖",
|
||||||
|
"依赖检查失败": "依赖检查失败",
|
||||||
|
"正在检查依赖": "正在检查 {type} 依赖",
|
||||||
|
"依赖检查状态已中断": "依赖检查状态已中断,请重新检查",
|
||||||
"登录失败次数": "失败{count}次",
|
"登录失败次数": "失败{count}次",
|
||||||
"间隔定时规则": "每{value}{unit}",
|
"间隔定时规则": "每{value}{unit}",
|
||||||
"SOCK5代理,例如 IP:PORT": "SOCK5代理,例如 IP:PORT",
|
"SOCK5代理,例如 IP:PORT": "SOCK5代理,例如 IP:PORT",
|
||||||
|
|||||||
+175
-10
@@ -87,6 +87,7 @@ const StatusMap: Record<number, { icon: React.ReactNode; color: string }> = {
|
|||||||
|
|
||||||
const Dependence = () => {
|
const Dependence = () => {
|
||||||
const { headerStyle, isPhone } = useOutletContext<SharedContext>();
|
const { headerStyle, isPhone } = useOutletContext<SharedContext>();
|
||||||
|
const [statusFilter, setStatusFilter] = useState<number[]>([]);
|
||||||
const columns: any = [
|
const columns: any = [
|
||||||
{
|
{
|
||||||
title: intl.get('序号'),
|
title: intl.get('序号'),
|
||||||
@@ -106,6 +107,7 @@ const Dependence = () => {
|
|||||||
key: 'status',
|
key: 'status',
|
||||||
width: 120,
|
width: 120,
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
|
filteredValue: statusFilter.length ? statusFilter : null,
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
text: intl.get('队列中'),
|
text: intl.get('队列中'),
|
||||||
@@ -233,6 +235,9 @@ const Dependence = () => {
|
|||||||
];
|
];
|
||||||
const [value, setValue] = useState<any[]>([]);
|
const [value, setValue] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [refreshingType, setRefreshingType] = useState<string | null>(null);
|
||||||
|
const [refreshGeneration, setRefreshGeneration] = useState(0);
|
||||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
const [editedDependence, setEditedDependence] = useState();
|
const [editedDependence, setEditedDependence] = useState();
|
||||||
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
|
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
|
||||||
@@ -240,9 +245,26 @@ const Dependence = () => {
|
|||||||
const [logDependence, setLogDependence] = useState<any>();
|
const [logDependence, setLogDependence] = useState<any>();
|
||||||
const [isLogModalVisible, setIsLogModalVisible] = useState(false);
|
const [isLogModalVisible, setIsLogModalVisible] = useState(false);
|
||||||
const [type, setType] = useState('nodejs');
|
const [type, setType] = useState('nodejs');
|
||||||
|
const listQuery = useRef({ type, searchText, statusFilter });
|
||||||
|
listQuery.current = { type, searchText, statusFilter };
|
||||||
const tableRef = useRef<HTMLDivElement>(null);
|
const tableRef = useRef<HTMLDivElement>(null);
|
||||||
|
const latestListRequest = useRef(0);
|
||||||
|
const refreshTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const activeRefreshId = useRef<string | null>(null);
|
||||||
|
const refreshRequestVersion = useRef(0);
|
||||||
|
const alive = useRef(true);
|
||||||
const [height, setHeight] = useState<number>(0);
|
const [height, setHeight] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
alive.current = true;
|
||||||
|
return () => {
|
||||||
|
alive.current = false;
|
||||||
|
latestListRequest.current += 1;
|
||||||
|
refreshRequestVersion.current += 1;
|
||||||
|
if (refreshTimer.current) clearTimeout(refreshTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useResizeObserver(tableRef, (entry) => {
|
useResizeObserver(tableRef, (entry) => {
|
||||||
const _height =
|
const _height =
|
||||||
entry.target?.parentElement?.parentElement?.parentElement?.offsetHeight;
|
entry.target?.parentElement?.parentElement?.parentElement?.offsetHeight;
|
||||||
@@ -256,23 +278,146 @@ const Dependence = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getDependencies = (status?: number[]) => {
|
const getDependencies = (status?: number[]) => {
|
||||||
|
const query = listQuery.current;
|
||||||
|
const requestId = ++latestListRequest.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
request
|
request
|
||||||
.get(
|
.get(
|
||||||
`${
|
`${config.apiPrefix}dependencies?searchValue=${query.searchText}&type=${
|
||||||
config.apiPrefix
|
query.type
|
||||||
}dependencies?searchValue=${searchText}&type=${type}&status=${
|
}&status=${status ?? query.statusFilter}`,
|
||||||
status || ''
|
|
||||||
}`,
|
|
||||||
)
|
)
|
||||||
.then(({ code, data }) => {
|
.then(({ code, data }) => {
|
||||||
if (code === 200) {
|
if (
|
||||||
|
alive.current &&
|
||||||
|
requestId === latestListRequest.current &&
|
||||||
|
code === 200
|
||||||
|
) {
|
||||||
setValue(data);
|
setValue(data);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => {
|
||||||
|
if (alive.current && requestId === latestListRequest.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scheduleRefresh = (callback: () => void, delay: number) => {
|
||||||
|
if (refreshTimer.current) clearTimeout(refreshTimer.current);
|
||||||
|
refreshTimer.current = setTimeout(callback, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishRefresh = (id: string, completed: boolean) => {
|
||||||
|
if (activeRefreshId.current !== id) return;
|
||||||
|
refreshRequestVersion.current += 1;
|
||||||
|
activeRefreshId.current = null;
|
||||||
|
if (refreshTimer.current) clearTimeout(refreshTimer.current);
|
||||||
|
setRefreshing(false);
|
||||||
|
setRefreshingType(null);
|
||||||
|
setRefreshGeneration((generation) => generation + 1);
|
||||||
|
if (!completed) {
|
||||||
|
message.error(intl.get('依赖检查失败'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachRefresh = (job: { id: string; type: string }) => {
|
||||||
|
if (!alive.current) return;
|
||||||
|
refreshRequestVersion.current += 1;
|
||||||
|
if (refreshTimer.current) clearTimeout(refreshTimer.current);
|
||||||
|
activeRefreshId.current = job.id;
|
||||||
|
setRefreshing(true);
|
||||||
|
setRefreshingType(job.type);
|
||||||
|
scheduleRefresh(() => pollRefresh(job.id), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const recoverRefresh = (
|
||||||
|
reloadWhenIdle = false,
|
||||||
|
retryOnError = false,
|
||||||
|
notifyWhenIdle = false,
|
||||||
|
) => {
|
||||||
|
const version = ++refreshRequestVersion.current;
|
||||||
|
request
|
||||||
|
.get(`${config.apiPrefix}dependencies/refresh/active`)
|
||||||
|
.then(({ code, data }) => {
|
||||||
|
if (!alive.current || version !== refreshRequestVersion.current) return;
|
||||||
|
if (code === 200 && data) {
|
||||||
|
attachRefresh(data);
|
||||||
|
} else if (reloadWhenIdle) {
|
||||||
|
activeRefreshId.current = null;
|
||||||
|
setRefreshing(false);
|
||||||
|
setRefreshingType(null);
|
||||||
|
setRefreshGeneration((generation) => generation + 1);
|
||||||
|
if (notifyWhenIdle) message.warning(intl.get('依赖检查状态已中断'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (
|
||||||
|
alive.current &&
|
||||||
|
version === refreshRequestVersion.current &&
|
||||||
|
retryOnError
|
||||||
|
) {
|
||||||
|
scheduleRefresh(() => {
|
||||||
|
if (alive.current && version === refreshRequestVersion.current) {
|
||||||
|
recoverRefresh(reloadWhenIdle, true, notifyWhenIdle);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollRefresh = (id: string) => {
|
||||||
|
request
|
||||||
|
.get(`${config.apiPrefix}dependencies/refresh/${id}`)
|
||||||
|
.then(({ code, data }) => {
|
||||||
|
if (!alive.current || activeRefreshId.current !== id) return;
|
||||||
|
if (code !== 200) {
|
||||||
|
scheduleRefresh(() => pollRefresh(id), 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.state === 'running') {
|
||||||
|
scheduleRefresh(() => pollRefresh(id), 2000);
|
||||||
|
} else {
|
||||||
|
finishRefresh(id, data.state === 'completed');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!alive.current || activeRefreshId.current !== id) return;
|
||||||
|
if (error?.response?.status === 404) {
|
||||||
|
activeRefreshId.current = null;
|
||||||
|
recoverRefresh(true, true, true);
|
||||||
|
} else {
|
||||||
|
scheduleRefresh(() => pollRefresh(id), 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRefresh = () => {
|
||||||
|
if (activeRefreshId.current) return;
|
||||||
|
const version = ++refreshRequestVersion.current;
|
||||||
|
setRefreshing(true);
|
||||||
|
setRefreshingType(type);
|
||||||
|
request
|
||||||
|
.post(`${config.apiPrefix}dependencies/refresh`, { type })
|
||||||
|
.then(({ code, data }) => {
|
||||||
|
if (!alive.current || version !== refreshRequestVersion.current) return;
|
||||||
|
if (code === 200) {
|
||||||
|
attachRefresh(data);
|
||||||
|
} else {
|
||||||
|
recoverRefresh(true, true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive.current && version === refreshRequestVersion.current) {
|
||||||
|
recoverRefresh(true, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
recoverRefresh(false, true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const addDependence = () => {
|
const addDependence = () => {
|
||||||
setEditedDependence(null as any);
|
setEditedDependence(null as any);
|
||||||
setIsModalVisible(true);
|
setIsModalVisible(true);
|
||||||
@@ -450,12 +595,16 @@ const Dependence = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSearch = (value: string) => {
|
const onSearch = (value: string) => {
|
||||||
setSearchText(value.trim());
|
const trimmed = value.trim();
|
||||||
|
if (trimmed !== searchText) {
|
||||||
|
latestListRequest.current += 1;
|
||||||
|
setSearchText(trimmed);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getDependencies();
|
getDependencies();
|
||||||
}, [searchText, type]);
|
}, [searchText, type, refreshGeneration]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (logDependence) {
|
if (logDependence) {
|
||||||
@@ -506,6 +655,7 @@ const Dependence = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onTabChange = (activeKey: string) => {
|
const onTabChange = (activeKey: string) => {
|
||||||
|
latestListRequest.current += 1;
|
||||||
setSelectedRowIds([]);
|
setSelectedRowIds([]);
|
||||||
setType(activeKey);
|
setType(activeKey);
|
||||||
};
|
};
|
||||||
@@ -551,7 +701,9 @@ const Dependence = () => {
|
|||||||
scroll={{ x: 768, y: height }}
|
scroll={{ x: 768, y: height }}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onChange={(pagination, filters) => {
|
onChange={(pagination, filters) => {
|
||||||
getDependencies(filters?.status as number[]);
|
const status = (filters?.status as number[]) || [];
|
||||||
|
setStatusFilter(status);
|
||||||
|
getDependencies(status);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</DndProvider>
|
</DndProvider>
|
||||||
@@ -563,6 +715,19 @@ const Dependence = () => {
|
|||||||
className="ql-container-wrapper dependence-wrapper ql-container-wrapper-has-tab"
|
className="ql-container-wrapper dependence-wrapper ql-container-wrapper-has-tab"
|
||||||
title={intl.get('依赖管理')}
|
title={intl.get('依赖管理')}
|
||||||
extra={[
|
extra={[
|
||||||
|
<Button
|
||||||
|
key="refresh"
|
||||||
|
icon={<SyncOutlined />}
|
||||||
|
loading={refreshing}
|
||||||
|
disabled={type === 'linux' || loading}
|
||||||
|
onClick={startRefresh}
|
||||||
|
>
|
||||||
|
{refreshingType
|
||||||
|
? intl.get('正在检查依赖', {
|
||||||
|
type: refreshingType === 'nodejs' ? 'Node' : 'Python',
|
||||||
|
})
|
||||||
|
: intl.get('重新检查依赖')}
|
||||||
|
</Button>,
|
||||||
<Search
|
<Search
|
||||||
placeholder={intl.get('请输入名称')}
|
placeholder={intl.get('请输入名称')}
|
||||||
style={{ width: 'auto' }}
|
style={{ width: 'auto' }}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
|
|
||||||
test('dependency listing marks cache entries missing from disk for reinstall', async (t) => {
|
test('dependency listing uses stored status until explicitly refreshed', async (t) => {
|
||||||
const moduleStubs = new Map();
|
const moduleStubs = new Map();
|
||||||
const stubModule = (modulePath, exports) => {
|
const stubModule = (modulePath, exports) => {
|
||||||
const resolved = require.resolve(modulePath);
|
const resolved = require.resolve(modulePath);
|
||||||
@@ -23,24 +23,47 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
|
|||||||
};
|
};
|
||||||
const DependenceTypes = { nodejs: 0, python3: 1, linux: 2 };
|
const DependenceTypes = { nodejs: 0, python3: 1, linux: 2 };
|
||||||
const docs = [
|
const docs = [
|
||||||
{ id: 1, name: 'missing-package', type: 0, status: 1 },
|
{
|
||||||
{ id: 2, name: 'present-package', type: 0, status: 1 },
|
id: 1,
|
||||||
|
name: 'missing-package',
|
||||||
|
type: 0,
|
||||||
|
status: 1,
|
||||||
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'present-package',
|
||||||
|
type: 0,
|
||||||
|
status: 1,
|
||||||
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const updates = [];
|
const updates = [];
|
||||||
|
const probes = [];
|
||||||
const DependenceModel = {
|
const DependenceModel = {
|
||||||
findAll: async ({ where }) => {
|
findAll: async ({ where }) => {
|
||||||
if (where.status === DependenceStatus.installed) {
|
if (where.status === DependenceStatus.installed) {
|
||||||
return docs;
|
return docs
|
||||||
|
.filter((doc) => doc.status === DependenceStatus.installed)
|
||||||
|
.map((doc) => ({ ...doc }));
|
||||||
}
|
}
|
||||||
return docs.map((doc) => ({
|
return docs.map((doc) => ({ ...doc }));
|
||||||
...doc,
|
|
||||||
status: updates.some((update) => update.ids.includes(doc.id))
|
|
||||||
? DependenceStatus.installFailed
|
|
||||||
: doc.status,
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
update: async ({ status }, { where }) => {
|
update: async ({ status }, { where }) => {
|
||||||
updates.push({ status, ids: where.id });
|
const changed = docs.filter(
|
||||||
|
(doc) =>
|
||||||
|
doc.id === where.id &&
|
||||||
|
doc.status === where.status &&
|
||||||
|
doc.name === where.name &&
|
||||||
|
doc.type === where.type &&
|
||||||
|
doc.updatedAt.getTime() === where.updatedAt.getTime(),
|
||||||
|
);
|
||||||
|
for (const doc of changed) {
|
||||||
|
doc.status = status;
|
||||||
|
doc.updatedAt = new Date(doc.updatedAt.getTime() + 1000);
|
||||||
|
}
|
||||||
|
if (changed.length)
|
||||||
|
updates.push({ status, ids: changed.map((d) => d.id) });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,8 +88,12 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
|
|||||||
getPid: async () => 0,
|
getPid: async () => 0,
|
||||||
getUninstallCommand: () => '',
|
getUninstallCommand: () => '',
|
||||||
killTask: async () => {},
|
killTask: async () => {},
|
||||||
promiseExecSuccess: async (command) =>
|
promiseExecSuccess: async (command) => {
|
||||||
command === 'check:present-package' ? 'present-package 1.0.0\n' : '',
|
probes.push(command);
|
||||||
|
return command === 'check:present-package'
|
||||||
|
? 'present-package 1.0.0\n'
|
||||||
|
: '';
|
||||||
|
},
|
||||||
});
|
});
|
||||||
stubModule('../../back/config/const', {
|
stubModule('../../back/config/const', {
|
||||||
LINUX_DEPENDENCE_COMMAND: { Alpine: {} },
|
LINUX_DEPENDENCE_COMMAND: { Alpine: {} },
|
||||||
@@ -104,15 +131,29 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
|
|||||||
|
|
||||||
const DependenceService = require('../../back/services/dependence').default;
|
const DependenceService = require('../../back/services/dependence').default;
|
||||||
const service = new DependenceService({}, {});
|
const service = new DependenceService({}, {});
|
||||||
const result = await service.dependencies({
|
const query = {
|
||||||
searchValue: '',
|
searchValue: '',
|
||||||
type: 'nodejs',
|
type: 'nodejs',
|
||||||
status: '',
|
status: '',
|
||||||
});
|
};
|
||||||
|
const cached = await service.dependencies(query);
|
||||||
|
assert.deepEqual(updates, []);
|
||||||
|
assert.deepEqual(probes, []);
|
||||||
|
assert.deepEqual(
|
||||||
|
cached.map(({ status }) => status),
|
||||||
|
[1, 1],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { job } = service.startRefresh('nodejs');
|
||||||
|
assert.equal(job.state, 'running');
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
assert.equal(service.getRefreshJob(job.id).state, 'completed');
|
||||||
|
const result = await service.dependencies(query);
|
||||||
|
|
||||||
assert.deepEqual(updates, [
|
assert.deepEqual(updates, [
|
||||||
{ status: DependenceStatus.installFailed, ids: [1] },
|
{ status: DependenceStatus.installFailed, ids: [1] },
|
||||||
]);
|
]);
|
||||||
|
assert.deepEqual(probes, ['check:missing-package', 'check:present-package']);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
result.map(({ id, status }) => ({ id, status })),
|
result.map(({ id, status }) => ({ id, status })),
|
||||||
[
|
[
|
||||||
@@ -123,4 +164,41 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
|
|||||||
|
|
||||||
await service.dependencies({ searchValue: '', type: 'linux', status: '' });
|
await service.dependencies({ searchValue: '', type: 'linux', status: '' });
|
||||||
assert.equal(updates.length, 1);
|
assert.equal(updates.length, 1);
|
||||||
|
assert.equal(probes.length, 2);
|
||||||
|
|
||||||
|
let finishProbe;
|
||||||
|
service.isDependenceInstalled = async () => {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
finishProbe = resolve;
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const racing = service.startRefresh('nodejs');
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
docs[1].updatedAt = new Date(docs[1].updatedAt.getTime() + 1000);
|
||||||
|
finishProbe();
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
assert.equal(racing.job.state, 'completed');
|
||||||
|
assert.equal(docs[1].status, DependenceStatus.installed);
|
||||||
|
assert.equal(updates.length, 1);
|
||||||
|
|
||||||
|
let finishRefresh;
|
||||||
|
service.refreshInstalledStatuses = () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
finishRefresh = resolve;
|
||||||
|
});
|
||||||
|
const first = service.startRefresh('nodejs');
|
||||||
|
assert.equal(first.accepted, true);
|
||||||
|
assert.equal(service.getActiveRefreshJob().id, first.job.id);
|
||||||
|
assert.equal(service.startRefresh('nodejs').job.id, first.job.id);
|
||||||
|
assert.equal(service.startRefresh('python3').accepted, false);
|
||||||
|
finishRefresh();
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
assert.equal(first.job.state, 'completed');
|
||||||
|
assert.equal(service.getActiveRefreshJob(), undefined);
|
||||||
|
const second = service.startRefresh('python3');
|
||||||
|
assert.equal(second.accepted, true);
|
||||||
|
finishRefresh();
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
assert.equal(second.job.state, 'completed');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
require('reflect-metadata');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
const { randomUUID } = require('node:crypto');
|
||||||
|
const express = require('express');
|
||||||
|
const { errors } = require('celebrate');
|
||||||
|
const { Container } = require('typedi');
|
||||||
|
const load = require('../helpers/load-security-module.cjs');
|
||||||
|
|
||||||
|
test('dependency refresh API starts one job and exposes its status', async (t) => {
|
||||||
|
const job = { id: randomUUID(), type: 'nodejs', state: 'running' };
|
||||||
|
class DependenceService {
|
||||||
|
startRefresh(type) {
|
||||||
|
return { job, accepted: type === job.type };
|
||||||
|
}
|
||||||
|
getRefreshJob(id) {
|
||||||
|
return id === job.id ? job : undefined;
|
||||||
|
}
|
||||||
|
getActiveRefreshJob() {
|
||||||
|
return job.state === 'running' ? job : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Container.set(DependenceService, new DependenceService());
|
||||||
|
t.after(() => Container.remove(DependenceService));
|
||||||
|
|
||||||
|
const api = load('back/api/dependence.ts', {
|
||||||
|
'../services/dependence': { __esModule: true, default: DependenceService },
|
||||||
|
});
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
api.default(app);
|
||||||
|
app.use(errors());
|
||||||
|
const server = app.listen(0, '127.0.0.1');
|
||||||
|
await new Promise((resolve) => server.once('listening', resolve));
|
||||||
|
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||||
|
const base = `http://127.0.0.1:${server.address().port}/dependencies/refresh`;
|
||||||
|
const start = (type) =>
|
||||||
|
fetch(base, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const started = await start('nodejs');
|
||||||
|
assert.equal(started.status, 200);
|
||||||
|
assert.deepEqual((await started.json()).data, job);
|
||||||
|
assert.equal((await start('python3')).status, 409);
|
||||||
|
assert.equal((await start('linux')).status, 400);
|
||||||
|
const active = await fetch(`${base}/active`);
|
||||||
|
assert.equal(active.headers.get('cache-control'), 'no-store');
|
||||||
|
assert.deepEqual((await active.json()).data, job);
|
||||||
|
const running = await fetch(`${base}/${job.id}`);
|
||||||
|
assert.equal(running.headers.get('cache-control'), 'no-store');
|
||||||
|
assert.equal((await running.json()).data.state, 'running');
|
||||||
|
job.state = 'completed';
|
||||||
|
assert.equal((await (await fetch(`${base}/active`)).json()).data, null);
|
||||||
|
assert.equal(
|
||||||
|
(await (await fetch(`${base}/${job.id}`)).json()).data.state,
|
||||||
|
'completed',
|
||||||
|
);
|
||||||
|
assert.equal((await fetch(`${base}/${randomUUID()}`)).status, 404);
|
||||||
|
});
|
||||||
@@ -35,8 +35,15 @@ function serviceFor(docs) {
|
|||||||
(!where.id || where.id.includes(d.id)),
|
(!where.id || where.id.includes(d.id)),
|
||||||
),
|
),
|
||||||
update: async (values, { where }) => {
|
update: async (values, { where }) => {
|
||||||
|
const ids = Array.isArray(where.id) ? where.id : [where.id];
|
||||||
docs
|
docs
|
||||||
.filter((d) => where.id.includes(d.id))
|
.filter(
|
||||||
|
(d) =>
|
||||||
|
ids.includes(d.id) &&
|
||||||
|
(where.status === undefined || d.status === where.status) &&
|
||||||
|
(where.updatedAt === undefined ||
|
||||||
|
d.updatedAt === where.updatedAt),
|
||||||
|
)
|
||||||
.forEach((d) => Object.assign(d, values));
|
.forEach((d) => Object.assign(d, values));
|
||||||
},
|
},
|
||||||
destroy: async ({ where }) => removed.push(...where.id),
|
destroy: async ({ where }) => removed.push(...where.id),
|
||||||
@@ -63,7 +70,7 @@ function serviceFor(docs) {
|
|||||||
return { service, probes, removed, uninstalls };
|
return { service, probes, removed, uninstalls };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('listing preserves Python source results and still detects missing named packages', async () => {
|
test('explicit refresh preserves Python sources and detects missing named packages', async () => {
|
||||||
const docs = [
|
const docs = [
|
||||||
' requirements with spaces/requirements.txt ',
|
' requirements with spaces/requirements.txt ',
|
||||||
'requirements-dev.in',
|
'requirements-dev.in',
|
||||||
@@ -71,7 +78,13 @@ test('listing preserves Python source results and still detects missing named pa
|
|||||||
'git+https://example.invalid/repo.git',
|
'git+https://example.invalid/repo.git',
|
||||||
'https://example.invalid/pkg.whl',
|
'https://example.invalid/pkg.whl',
|
||||||
'missing-package',
|
'missing-package',
|
||||||
].map((name, i) => ({ id: i + 1, name, type: 1, status: 1 }));
|
].map((name, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name,
|
||||||
|
type: 1,
|
||||||
|
status: 1,
|
||||||
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
}));
|
||||||
const { service, probes } = serviceFor(docs);
|
const { service, probes } = serviceFor(docs);
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
await service.dependencies({
|
await service.dependencies({
|
||||||
@@ -80,6 +93,10 @@ test('listing preserves Python source results and still detects missing named pa
|
|||||||
status: '',
|
status: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
assert.deepEqual(probes, []);
|
||||||
|
const { job } = service.startRefresh('python3');
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
assert.equal(job.state, 'completed');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
docs.map((d) => d.status),
|
docs.map((d) => d.status),
|
||||||
[1, 1, 1, 1, 1, 2],
|
[1, 1, 1, 1, 1, 2],
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const ts = require('typescript');
|
||||||
|
|
||||||
|
const source = ts.createSourceFile(
|
||||||
|
'index.tsx',
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../src/pages/dependence/index.tsx'),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
ts.ScriptTarget.Latest,
|
||||||
|
true,
|
||||||
|
ts.ScriptKind.TSX,
|
||||||
|
);
|
||||||
|
const names = new Set([
|
||||||
|
'scheduleRefresh',
|
||||||
|
'finishRefresh',
|
||||||
|
'attachRefresh',
|
||||||
|
'recoverRefresh',
|
||||||
|
'pollRefresh',
|
||||||
|
'startRefresh',
|
||||||
|
]);
|
||||||
|
const declarations = [];
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isVariableDeclaration(node) && names.has(node.name.getText(source))) {
|
||||||
|
declarations.push(`var ${node.getText(source)};`);
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
}
|
||||||
|
visit(source);
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const requests = [];
|
||||||
|
const timers = new Map();
|
||||||
|
let timerId = 0;
|
||||||
|
const state = { refreshing: false, type: null, reloads: 0 };
|
||||||
|
const request = (url) => {
|
||||||
|
let resolve, reject;
|
||||||
|
const promise = new Promise((yes, no) => {
|
||||||
|
resolve = yes;
|
||||||
|
reject = no;
|
||||||
|
});
|
||||||
|
requests.push({ url, resolve, reject });
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
config: { apiPrefix: '/api/' },
|
||||||
|
type: 'python3',
|
||||||
|
alive: { current: true },
|
||||||
|
activeRefreshId: { current: null },
|
||||||
|
refreshRequestVersion: { current: 0 },
|
||||||
|
refreshTimer: {},
|
||||||
|
request: { get: request, post: request },
|
||||||
|
setRefreshing: (value) => {
|
||||||
|
state.refreshing = value;
|
||||||
|
},
|
||||||
|
setRefreshingType: (value) => {
|
||||||
|
state.type = value;
|
||||||
|
},
|
||||||
|
setRefreshGeneration: () => {
|
||||||
|
state.reloads += 1;
|
||||||
|
},
|
||||||
|
message: { error() {}, warning() {} },
|
||||||
|
intl: { get: (value) => value },
|
||||||
|
setTimeout: (fn, delay) => {
|
||||||
|
timers.set(++timerId, { fn, delay });
|
||||||
|
return timerId;
|
||||||
|
},
|
||||||
|
clearTimeout: (id) => timers.delete(id),
|
||||||
|
};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(
|
||||||
|
ts.transpile(declarations.join('\n'), { target: ts.ScriptTarget.ES2020 }),
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
return { context, requests, timers, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
const settle = () => new Promise(setImmediate);
|
||||||
|
const newJob = { id: 'new-job', type: 'python3' };
|
||||||
|
|
||||||
|
test('late recovery cannot replace a newly attached job or clear it as idle', async () => {
|
||||||
|
for (const data of [{ id: 'old-job', type: 'nodejs' }, null]) {
|
||||||
|
const f = fixture();
|
||||||
|
f.context.recoverRefresh(true, true);
|
||||||
|
f.context.attachRefresh(newJob);
|
||||||
|
f.requests[0].resolve({ code: 200, data });
|
||||||
|
await settle();
|
||||||
|
assert.equal(f.context.activeRefreshId.current, newJob.id);
|
||||||
|
assert.equal(f.state.refreshing, true);
|
||||||
|
assert.equal(f.state.reloads, 0);
|
||||||
|
assert.equal([...f.timers.values()][0].delay, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('late recovery failure cannot replace the new job poll timer', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.context.recoverRefresh(false, true);
|
||||||
|
f.context.attachRefresh(newJob);
|
||||||
|
f.requests[0].reject(new Error('offline'));
|
||||||
|
await settle();
|
||||||
|
assert.equal(f.timers.size, 1);
|
||||||
|
assert.equal([...f.timers.values()][0].delay, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a queued recovery retry is invalidated when a new job starts', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.context.recoverRefresh(false, true);
|
||||||
|
f.requests[0].reject(new Error('offline'));
|
||||||
|
await settle();
|
||||||
|
const retry = [...f.timers.values()][0].fn;
|
||||||
|
f.context.startRefresh();
|
||||||
|
retry();
|
||||||
|
assert.equal(f.requests.length, 2);
|
||||||
|
f.requests[1].resolve({ code: 200, data: newJob });
|
||||||
|
await settle();
|
||||||
|
assert.equal(f.context.activeRefreshId.current, newJob.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starting a job invalidates recovery before the start response arrives', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.context.recoverRefresh(false, true);
|
||||||
|
f.context.startRefresh();
|
||||||
|
f.requests[0].resolve({ code: 200, data: { id: 'old-job', type: 'nodejs' } });
|
||||||
|
await settle();
|
||||||
|
assert.equal(f.context.activeRefreshId.current, null);
|
||||||
|
f.requests[1].resolve({ code: 200, data: newJob });
|
||||||
|
await settle();
|
||||||
|
assert.equal(f.context.activeRefreshId.current, newJob.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('current recovery still attaches and polls the running job', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.context.recoverRefresh();
|
||||||
|
f.requests[0].resolve({ code: 200, data: newJob });
|
||||||
|
await settle();
|
||||||
|
[...f.timers.values()][0].fn();
|
||||||
|
assert.equal(f.requests[1].url, '/api/dependencies/refresh/new-job');
|
||||||
|
f.requests[1].resolve({ code: 200, data: { ...newJob, state: 'completed' } });
|
||||||
|
await settle();
|
||||||
|
assert.equal(f.state.refreshing, false);
|
||||||
|
assert.equal(f.state.reloads, 1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user