Compare commits

..
13 changed files with 60 additions and 718 deletions
+6 -68
View File
@@ -11,11 +11,12 @@ 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');
@@ -30,69 +31,6 @@ 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({
+19 -76
View File
@@ -27,20 +27,9 @@ 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,
@@ -129,6 +118,11 @@ 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];
}
@@ -151,52 +145,6 @@ 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,
@@ -212,31 +160,26 @@ 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(
checkableDocs.map((doc) => async () => this.isDependenceInstalled(doc)),
docs
.filter(
(doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
)
.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
5,
);
const missingIds = (checks || []).filter(
(id): id is number => id !== undefined,
);
for (const [index, installed] of (checks || []).entries()) {
if (installed) continue;
const doc = checkableDocs[index];
if (missingIds.length) {
await DependenceModel.update(
{ status: DependenceStatus.installFailed },
{
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,
},
{ where: { id: missingIds } },
);
}
}
+2 -12
View File
@@ -1,8 +1,8 @@
# OpenAPI 全量参考 / Complete OpenAPI reference
CLI 对应本版本后端的 151 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
CLI 对应本版本后端的 146 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
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.
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.
## 认证与权限 / Authentication and permissions
@@ -56,19 +56,12 @@ 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.
@@ -161,9 +154,6 @@ System/user operations below execute remotely through the panel API. They do not
| `ql log delete ` | DELETE | `logs` | --data, --query |
| `ql log download ` | POST | `logs/download` | --data, --output, --query |
| `ql dependency list ` | GET | `dependencies` | --query |
| `ql dependency refresh ` | POST | `dependencies/refresh` | --data, --query |
| `ql dependency refresh-active ` | GET | `dependencies/refresh/active` | --query |
| `ql dependency refresh-status <id>` | GET | `dependencies/refresh/:id` | --query |
| `ql dependency create ` | POST | `dependencies` | --data, --query |
| `ql dependency update <id>` | PUT | `dependencies` | --data, --query |
| `ql dependency delete <id...>` | DELETE | `dependencies` | --query |
-19
View File
@@ -5,7 +5,6 @@ export interface OpenOperation {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
params?: string[];
uuidParams?: string[];
body?: 'ids' | 'json' | 'object-id';
upload?: string;
download?: boolean;
@@ -335,24 +334,6 @@ 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',
+3 -15
View File
@@ -49,19 +49,12 @@ 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, (_match, name) =>
op.uuidParams?.includes(name) ? UUID_PATTERN : '[1-9][0-9]*',
) +
'$',
'^' + op.path.replace(/:[A-Za-z]+/g, '[1-9][0-9]*') + '$',
).test(path),
);
if (!match)
@@ -70,8 +63,7 @@ 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(':') && !match.uuidParams?.includes(part.slice(1)))
integer(path.split('/')[index]!);
if (part.startsWith(':')) integer(path.split('/')[index]!);
return match;
}
@@ -115,11 +107,7 @@ export async function openCommand(
if (!generic) {
endpoint = op.path;
for (const [index, name] of (op.params ?? []).entries()) {
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);
const value = integer(command.positionals[index]!);
endpoint = endpoint.replace(`:${name}`, String(value));
if (op.body === 'object-id') {
if (body !== undefined && !isRecord(body))
+2 -24
View File
@@ -83,27 +83,6 @@ 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 = [];
@@ -153,8 +132,7 @@ 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(' ');
const parameter = name => op.uuidParams?.includes(name) ? '32ec8b22-2c48-4bcf-85d4-b394198417ba' : '7';
for (const name of op.params ?? []) args.push(parameter(name));
for (const _ of op.params ?? []) args.push('7');
if (op.body === 'ids') args.push('7', '8');
else if (op.body) {
const data = op.name.startsWith('task ') ? { command: 'echo fixture', schedule: '0 0 * * *' }
@@ -166,7 +144,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, (_match, name) => parameter(name)), op.name);
assert.equal(last.url, '/panel/open/' + op.path.replace(/:[A-Za-z]+/g, '7'), 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);
}
-4
View File
@@ -1,9 +1,5 @@
{
"选中条数": "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",
-4
View File
@@ -1,9 +1,5 @@
{
"选中条数": "已选择 {count} 项",
"重新检查依赖": "重新检查依赖",
"依赖检查失败": "依赖检查失败",
"正在检查依赖": "正在检查 {type} 依赖",
"依赖检查状态已中断": "依赖检查状态已中断,请重新检查",
"登录失败次数": "失败{count}次",
"间隔定时规则": "每{value}{unit}",
"SOCK5代理,例如 IP:PORT": "SOCK5代理,例如 IP:PORT",
+10 -175
View File
@@ -87,7 +87,6 @@ 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('序号'),
@@ -107,7 +106,6 @@ const Dependence = () => {
key: 'status',
width: 120,
dataIndex: 'status',
filteredValue: statusFilter.length ? statusFilter : null,
filters: [
{
text: intl.get('队列中'),
@@ -235,9 +233,6 @@ 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[]>([]);
@@ -245,26 +240,9 @@ 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;
@@ -278,146 +256,23 @@ const Dependence = () => {
});
const getDependencies = (status?: number[]) => {
const query = listQuery.current;
const requestId = ++latestListRequest.current;
setLoading(true);
request
.get(
`${config.apiPrefix}dependencies?searchValue=${query.searchText}&type=${
query.type
}&status=${status ?? query.statusFilter}`,
`${
config.apiPrefix
}dependencies?searchValue=${searchText}&type=${type}&status=${
status || ''
}`,
)
.then(({ code, data }) => {
if (
alive.current &&
requestId === latestListRequest.current &&
code === 200
) {
if (code === 200) {
setValue(data);
}
})
.finally(() => {
if (alive.current && requestId === latestListRequest.current) {
setLoading(false);
}
});
.finally(() => 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);
@@ -595,16 +450,12 @@ const Dependence = () => {
};
const onSearch = (value: string) => {
const trimmed = value.trim();
if (trimmed !== searchText) {
latestListRequest.current += 1;
setSearchText(trimmed);
}
setSearchText(value.trim());
};
useEffect(() => {
getDependencies();
}, [searchText, type, refreshGeneration]);
}, [searchText, type]);
useEffect(() => {
if (logDependence) {
@@ -655,7 +506,6 @@ const Dependence = () => {
}, []);
const onTabChange = (activeKey: string) => {
latestListRequest.current += 1;
setSelectedRowIds([]);
setType(activeKey);
};
@@ -701,9 +551,7 @@ const Dependence = () => {
scroll={{ x: 768, y: height }}
loading={loading}
onChange={(pagination, filters) => {
const status = (filters?.status as number[]) || [];
setStatusFilter(status);
getDependencies(status);
getDependencies(filters?.status as number[]);
}}
/>
</DndProvider>
@@ -715,19 +563,6 @@ 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' }}
+15 -93
View File
@@ -1,7 +1,7 @@
const assert = require('node:assert/strict');
const test = require('node:test');
test('dependency listing uses stored status until explicitly refreshed', async (t) => {
test('dependency listing marks cache entries missing from disk for reinstall', async (t) => {
const moduleStubs = new Map();
const stubModule = (modulePath, exports) => {
const resolved = require.resolve(modulePath);
@@ -23,47 +23,24 @@ test('dependency listing uses stored status until explicitly refreshed', async (
};
const DependenceTypes = { nodejs: 0, python3: 1, linux: 2 };
const docs = [
{
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'),
},
{ id: 1, name: 'missing-package', type: 0, status: 1 },
{ id: 2, name: 'present-package', type: 0, status: 1 },
];
const updates = [];
const probes = [];
const DependenceModel = {
findAll: async ({ where }) => {
if (where.status === DependenceStatus.installed) {
return docs
.filter((doc) => doc.status === DependenceStatus.installed)
.map((doc) => ({ ...doc }));
return docs;
}
return docs.map((doc) => ({ ...doc }));
return docs.map((doc) => ({
...doc,
status: updates.some((update) => update.ids.includes(doc.id))
? DependenceStatus.installFailed
: doc.status,
}));
},
update: async ({ status }, { where }) => {
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) });
updates.push({ status, ids: where.id });
},
};
@@ -88,12 +65,8 @@ test('dependency listing uses stored status until explicitly refreshed', async (
getPid: async () => 0,
getUninstallCommand: () => '',
killTask: async () => {},
promiseExecSuccess: async (command) => {
probes.push(command);
return command === 'check:present-package'
? 'present-package 1.0.0\n'
: '';
},
promiseExecSuccess: async (command) =>
command === 'check:present-package' ? 'present-package 1.0.0\n' : '',
});
stubModule('../../back/config/const', {
LINUX_DEPENDENCE_COMMAND: { Alpine: {} },
@@ -131,29 +104,15 @@ test('dependency listing uses stored status until explicitly refreshed', async (
const DependenceService = require('../../back/services/dependence').default;
const service = new DependenceService({}, {});
const query = {
const result = await service.dependencies({
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 })),
[
@@ -164,41 +123,4 @@ test('dependency listing uses stored status until explicitly refreshed', async (
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');
});
-62
View File
@@ -1,62 +0,0 @@
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);
});
+3 -20
View File
@@ -35,15 +35,8 @@ 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) =>
ids.includes(d.id) &&
(where.status === undefined || d.status === where.status) &&
(where.updatedAt === undefined ||
d.updatedAt === where.updatedAt),
)
.filter((d) => where.id.includes(d.id))
.forEach((d) => Object.assign(d, values));
},
destroy: async ({ where }) => removed.push(...where.id),
@@ -70,7 +63,7 @@ function serviceFor(docs) {
return { service, probes, removed, uninstalls };
}
test('explicit refresh preserves Python sources and detects missing named packages', async () => {
test('listing preserves Python source results and still detects missing named packages', async () => {
const docs = [
' requirements with spaces/requirements.txt ',
'requirements-dev.in',
@@ -78,13 +71,7 @@ test('explicit refresh preserves Python sources and detects missing named packag
'git+https://example.invalid/repo.git',
'https://example.invalid/pkg.whl',
'missing-package',
].map((name, i) => ({
id: i + 1,
name,
type: 1,
status: 1,
updatedAt: new Date('2026-01-01T00:00:00Z'),
}));
].map((name, i) => ({ id: i + 1, name, type: 1, status: 1 }));
const { service, probes } = serviceFor(docs);
for (let i = 0; i < 2; i++) {
await service.dependencies({
@@ -93,10 +80,6 @@ test('explicit refresh preserves Python sources and detects missing named packag
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],
-146
View File
@@ -1,146 +0,0 @@
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);
});