mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-03 16:19:11 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d69c3340c | ||
|
|
a6c4acbfd2 |
+68
-6
@@ -11,12 +11,11 @@ export default (app: Router) => {
|
||||
route.get(
|
||||
'/',
|
||||
celebrate({
|
||||
query:
|
||||
Joi.object({
|
||||
searchValue: Joi.string().optional().allow(''),
|
||||
type: Joi.string().optional().allow(''),
|
||||
status: Joi.string().optional().allow(''),
|
||||
}).unknown(true),
|
||||
query: Joi.object({
|
||||
searchValue: Joi.string().optional().allow(''),
|
||||
type: Joi.string().optional().allow(''),
|
||||
status: Joi.string().optional().allow(''),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
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(
|
||||
'/',
|
||||
celebrate({
|
||||
|
||||
+76
-19
@@ -27,9 +27,20 @@ import taskLimit from '../shared/pLimit';
|
||||
import { detectOS } from '../config/util';
|
||||
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
|
||||
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()
|
||||
export default class DependenceService {
|
||||
private activeRefreshJob?: RefreshJob;
|
||||
private refreshJobs = new Map<string, RefreshJob>();
|
||||
|
||||
constructor(
|
||||
@Inject('logger') private logger: winston.Logger,
|
||||
private sockService: SockService,
|
||||
@@ -118,11 +129,6 @@ export default class DependenceService {
|
||||
query: any = {},
|
||||
): Promise<Dependence[]> {
|
||||
let condition = query;
|
||||
const dependenceType =
|
||||
type && DependenceTypes[type] !== undefined
|
||||
? DependenceTypes[type]
|
||||
: undefined;
|
||||
await this.refreshInstalledStatuses(dependenceType);
|
||||
if (type && DependenceTypes[type] !== undefined) {
|
||||
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) {
|
||||
const cacheDependenceTypes = [
|
||||
DependenceTypes.nodejs,
|
||||
@@ -160,26 +212,31 @@ export default class DependenceService {
|
||||
type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type,
|
||||
},
|
||||
});
|
||||
const checkableDocs = docs.filter(
|
||||
(doc) =>
|
||||
doc.type !== DependenceTypes.python3 ||
|
||||
!isPythonSourceDependency(doc.name),
|
||||
);
|
||||
const checks = await concurrentRun(
|
||||
docs
|
||||
.filter(
|
||||
(doc) =>
|
||||
doc.type !== DependenceTypes.python3 ||
|
||||
!isPythonSourceDependency(doc.name),
|
||||
)
|
||||
.map((doc) => async () => {
|
||||
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
|
||||
}),
|
||||
checkableDocs.map((doc) => async () => this.isDependenceInstalled(doc)),
|
||||
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(
|
||||
{ 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
|
||||
|
||||
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 |
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"选中条数": "Selected {count} items",
|
||||
"重新检查依赖": "Recheck dependencies",
|
||||
"依赖检查失败": "Dependency check failed",
|
||||
"正在检查依赖": "Checking {type} dependencies",
|
||||
"依赖检查状态已中断": "Dependency check status was lost. Please retry.",
|
||||
"登录失败次数": "Failed {count} time(s)",
|
||||
"间隔定时规则": "Every {value} {unit}",
|
||||
"SOCK5代理,例如 IP:PORT": "SOCKS5 proxy, e.g. IP:PORT",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"选中条数": "已选择 {count} 项",
|
||||
"重新检查依赖": "重新检查依赖",
|
||||
"依赖检查失败": "依赖检查失败",
|
||||
"正在检查依赖": "正在检查 {type} 依赖",
|
||||
"依赖检查状态已中断": "依赖检查状态已中断,请重新检查",
|
||||
"登录失败次数": "失败{count}次",
|
||||
"间隔定时规则": "每{value}{unit}",
|
||||
"SOCK5代理,例如 IP:PORT": "SOCK5代理,例如 IP:PORT",
|
||||
|
||||
+175
-10
@@ -87,6 +87,7 @@ const StatusMap: Record<number, { icon: React.ReactNode; color: string }> = {
|
||||
|
||||
const Dependence = () => {
|
||||
const { headerStyle, isPhone } = useOutletContext<SharedContext>();
|
||||
const [statusFilter, setStatusFilter] = useState<number[]>([]);
|
||||
const columns: any = [
|
||||
{
|
||||
title: intl.get('序号'),
|
||||
@@ -106,6 +107,7 @@ const Dependence = () => {
|
||||
key: 'status',
|
||||
width: 120,
|
||||
dataIndex: 'status',
|
||||
filteredValue: statusFilter.length ? statusFilter : null,
|
||||
filters: [
|
||||
{
|
||||
text: intl.get('队列中'),
|
||||
@@ -233,6 +235,9 @@ const Dependence = () => {
|
||||
];
|
||||
const [value, setValue] = useState<any[]>([]);
|
||||
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 [editedDependence, setEditedDependence] = useState();
|
||||
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
|
||||
@@ -240,9 +245,26 @@ const Dependence = () => {
|
||||
const [logDependence, setLogDependence] = useState<any>();
|
||||
const [isLogModalVisible, setIsLogModalVisible] = useState(false);
|
||||
const [type, setType] = useState('nodejs');
|
||||
const listQuery = useRef({ type, searchText, statusFilter });
|
||||
listQuery.current = { type, searchText, statusFilter };
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
return () => {
|
||||
alive.current = false;
|
||||
latestListRequest.current += 1;
|
||||
refreshRequestVersion.current += 1;
|
||||
if (refreshTimer.current) clearTimeout(refreshTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useResizeObserver(tableRef, (entry) => {
|
||||
const _height =
|
||||
entry.target?.parentElement?.parentElement?.parentElement?.offsetHeight;
|
||||
@@ -256,23 +278,146 @@ const Dependence = () => {
|
||||
});
|
||||
|
||||
const getDependencies = (status?: number[]) => {
|
||||
const query = listQuery.current;
|
||||
const requestId = ++latestListRequest.current;
|
||||
setLoading(true);
|
||||
request
|
||||
.get(
|
||||
`${
|
||||
config.apiPrefix
|
||||
}dependencies?searchValue=${searchText}&type=${type}&status=${
|
||||
status || ''
|
||||
}`,
|
||||
`${config.apiPrefix}dependencies?searchValue=${query.searchText}&type=${
|
||||
query.type
|
||||
}&status=${status ?? query.statusFilter}`,
|
||||
)
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
if (
|
||||
alive.current &&
|
||||
requestId === latestListRequest.current &&
|
||||
code === 200
|
||||
) {
|
||||
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 = () => {
|
||||
setEditedDependence(null as any);
|
||||
setIsModalVisible(true);
|
||||
@@ -450,12 +595,16 @@ const Dependence = () => {
|
||||
};
|
||||
|
||||
const onSearch = (value: string) => {
|
||||
setSearchText(value.trim());
|
||||
const trimmed = value.trim();
|
||||
if (trimmed !== searchText) {
|
||||
latestListRequest.current += 1;
|
||||
setSearchText(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getDependencies();
|
||||
}, [searchText, type]);
|
||||
}, [searchText, type, refreshGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (logDependence) {
|
||||
@@ -506,6 +655,7 @@ const Dependence = () => {
|
||||
}, []);
|
||||
|
||||
const onTabChange = (activeKey: string) => {
|
||||
latestListRequest.current += 1;
|
||||
setSelectedRowIds([]);
|
||||
setType(activeKey);
|
||||
};
|
||||
@@ -551,7 +701,9 @@ const Dependence = () => {
|
||||
scroll={{ x: 768, y: height }}
|
||||
loading={loading}
|
||||
onChange={(pagination, filters) => {
|
||||
getDependencies(filters?.status as number[]);
|
||||
const status = (filters?.status as number[]) || [];
|
||||
setStatusFilter(status);
|
||||
getDependencies(status);
|
||||
}}
|
||||
/>
|
||||
</DndProvider>
|
||||
@@ -563,6 +715,19 @@ const Dependence = () => {
|
||||
className="ql-container-wrapper dependence-wrapper ql-container-wrapper-has-tab"
|
||||
title={intl.get('依赖管理')}
|
||||
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
|
||||
placeholder={intl.get('请输入名称')}
|
||||
style={{ width: 'auto' }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const assert = require('node:assert/strict');
|
||||
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 stubModule = (modulePath, exports) => {
|
||||
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 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 probes = [];
|
||||
const DependenceModel = {
|
||||
findAll: async ({ where }) => {
|
||||
if (where.status === DependenceStatus.installed) {
|
||||
return docs;
|
||||
return docs
|
||||
.filter((doc) => doc.status === DependenceStatus.installed)
|
||||
.map((doc) => ({ ...doc }));
|
||||
}
|
||||
return docs.map((doc) => ({
|
||||
...doc,
|
||||
status: updates.some((update) => update.ids.includes(doc.id))
|
||||
? DependenceStatus.installFailed
|
||||
: doc.status,
|
||||
}));
|
||||
return docs.map((doc) => ({ ...doc }));
|
||||
},
|
||||
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,
|
||||
getUninstallCommand: () => '',
|
||||
killTask: async () => {},
|
||||
promiseExecSuccess: async (command) =>
|
||||
command === 'check:present-package' ? 'present-package 1.0.0\n' : '',
|
||||
promiseExecSuccess: async (command) => {
|
||||
probes.push(command);
|
||||
return command === 'check:present-package'
|
||||
? 'present-package 1.0.0\n'
|
||||
: '';
|
||||
},
|
||||
});
|
||||
stubModule('../../back/config/const', {
|
||||
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 service = new DependenceService({}, {});
|
||||
const result = await service.dependencies({
|
||||
const query = {
|
||||
searchValue: '',
|
||||
type: 'nodejs',
|
||||
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, [
|
||||
{ status: DependenceStatus.installFailed, ids: [1] },
|
||||
]);
|
||||
assert.deepEqual(probes, ['check:missing-package', 'check:present-package']);
|
||||
assert.deepEqual(
|
||||
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: '' });
|
||||
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)),
|
||||
),
|
||||
update: async (values, { where }) => {
|
||||
const ids = Array.isArray(where.id) ? where.id : [where.id];
|
||||
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));
|
||||
},
|
||||
destroy: async ({ where }) => removed.push(...where.id),
|
||||
@@ -63,7 +70,7 @@ function serviceFor(docs) {
|
||||
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 = [
|
||||
' requirements with spaces/requirements.txt ',
|
||||
'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',
|
||||
'https://example.invalid/pkg.whl',
|
||||
'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);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await service.dependencies({
|
||||
@@ -80,6 +93,10 @@ test('listing preserves Python source results and still detects missing named pa
|
||||
status: '',
|
||||
});
|
||||
}
|
||||
assert.deepEqual(probes, []);
|
||||
const { job } = service.startRefresh('python3');
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(job.state, 'completed');
|
||||
assert.deepEqual(
|
||||
docs.map((d) => d.status),
|
||||
[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