mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-17 07:20:51 +08:00
fix: synchronize runtime locales and harden online updates
Translate UI, scheduler, validation, preload and maintenance messages dynamically, and propagate locale changes between workers. Harden update checks, downloads and terminal states, verify Debian pnpm, and ignore generated test artifacts.
This commit is contained in:
@@ -38,3 +38,12 @@ __pycache__
|
|||||||
|
|
||||||
# Local security audit artifacts
|
# Local security audit artifacts
|
||||||
/audit/
|
/audit/
|
||||||
|
|
||||||
|
# Local browser tests, screenshots, reports, and temporary test output
|
||||||
|
/.playwright-cli/
|
||||||
|
/output/
|
||||||
|
/playwright-report/
|
||||||
|
/test-results/
|
||||||
|
/blob-report/
|
||||||
|
/coverage/
|
||||||
|
/.nyc_output/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextFunction, Request, Response, Router } from 'express';
|
import { NextFunction, Request, Response, Router } from 'express';
|
||||||
import { celebrate, Joi } from 'celebrate';
|
import { celebrate, Joi } from 'celebrate';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
import {
|
import {
|
||||||
diagnoseClientIp,
|
diagnoseClientIp,
|
||||||
getTrustProxyConfig,
|
getTrustProxyConfig,
|
||||||
@@ -36,7 +37,7 @@ export default (app: Router) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.send({
|
res.send({
|
||||||
code: 400,
|
code: 400,
|
||||||
message: error instanceof Error ? error.message : '配置更新失败',
|
message: error instanceof Error ? error.message : t('配置更新失败'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Container } from 'typedi';
|
|||||||
import config from './config';
|
import config from './config';
|
||||||
import Logger from './loaders/logger';
|
import Logger from './loaders/logger';
|
||||||
import { errStack } from './shared/errors';
|
import { errStack } from './shared/errors';
|
||||||
|
import { initializeLanguageSync } from './shared/languageSync';
|
||||||
import { type GrpcServerService } from './services/grpc';
|
import { type GrpcServerService } from './services/grpc';
|
||||||
import { type HttpServerService } from './services/http';
|
import { type HttpServerService } from './services/http';
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ class Application {
|
|||||||
if (cluster.isPrimary) {
|
if (cluster.isPrimary) {
|
||||||
await this.initializeDatabase();
|
await this.initializeDatabase();
|
||||||
}
|
}
|
||||||
|
await initializeLanguageSync();
|
||||||
if (cluster.isPrimary) {
|
if (cluster.isPrimary) {
|
||||||
this.startMasterProcess();
|
this.startMasterProcess();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import config from '../config';
|
|||||||
import { UnauthorizedError, expressjwt } from 'express-jwt';
|
import { UnauthorizedError, expressjwt } from 'express-jwt';
|
||||||
import { getPlatform, getToken } from '../config/util';
|
import { getPlatform, getToken } from '../config/util';
|
||||||
import rewrite from 'express-urlrewrite';
|
import rewrite from 'express-urlrewrite';
|
||||||
import { errors } from 'celebrate';
|
import validationErrors from '../middlewares/validationErrors';
|
||||||
import { serveEnv } from '../config/serverEnv';
|
import { serveEnv } from '../config/serverEnv';
|
||||||
import { shareStore } from '../shared/store';
|
import { shareStore } from '../shared/store';
|
||||||
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
|
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
|
||||||
@@ -197,7 +197,7 @@ export default ({ app }: { app: Application }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(errors());
|
app.use(validationErrors);
|
||||||
app.use(uniqueConstraintError);
|
app.use(uniqueConstraintError);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default async () => {
|
|||||||
assertSubscriptionAlias(doc.alias);
|
assertSubscriptionAlias(doc.alias);
|
||||||
await subscriptionService.handleTask(doc, !sub.is_disabled);
|
await subscriptionService.handleTask(doc, !sub.is_disabled);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.warn('跳过订阅 %s 的无效定时配置: %s', sub.id, error);
|
Logger.warn(t('跳过订阅 %s 的无效定时配置: %s'), sub.id, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { errors, isCelebrateError } from 'celebrate';
|
||||||
|
import { ErrorRequestHandler } from 'express';
|
||||||
|
import { t } from '../shared/i18n';
|
||||||
|
|
||||||
|
const respond = errors();
|
||||||
|
|
||||||
|
// Schemas are compiled once at startup; resolve their messages per request so
|
||||||
|
// changing the system language also changes errors from those existing schemas.
|
||||||
|
const validationErrors: ErrorRequestHandler = (error, req, res, next) => {
|
||||||
|
if (isCelebrateError(error)) {
|
||||||
|
for (const detail of error.details.values()) {
|
||||||
|
detail.message = detail.details.map((item) => t(item.message)).join('. ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return respond(error, req, res, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default validationErrors;
|
||||||
@@ -5,7 +5,7 @@ import { isValidCronSchedule } from '../shared/cronSchedule';
|
|||||||
import { scheduleStacks } from './data';
|
import { scheduleStacks } from './data';
|
||||||
import { runCron } from '../shared/runCron';
|
import { runCron } from '../shared/runCron';
|
||||||
import Logger from '../loaders/logger';
|
import Logger from '../loaders/logger';
|
||||||
import { tf } from '../shared/i18n';
|
import { t, tf } from '../shared/i18n';
|
||||||
|
|
||||||
// Validate the entire batch before replacing any existing jobs.
|
// Validate the entire batch before replacing any existing jobs.
|
||||||
const isValidCronField = (cron: string): boolean => {
|
const isValidCronField = (cron: string): boolean => {
|
||||||
@@ -67,7 +67,7 @@ const addCron = (
|
|||||||
createCronJob(
|
createCronJob(
|
||||||
schedule,
|
schedule,
|
||||||
async () => {
|
async () => {
|
||||||
Logger.info('[schedule][准备运行任务] 命令: %s', item.command);
|
Logger.info(t('[schedule][准备运行任务] 命令: %s'), item.command);
|
||||||
await runCron(
|
await runCron(
|
||||||
item.command,
|
item.command,
|
||||||
item,
|
item,
|
||||||
@@ -104,7 +104,7 @@ const addCron = (
|
|||||||
scheduleStacks.set(id, jobs);
|
scheduleStacks.set(id, jobs);
|
||||||
jobs.forEach((job) => job.start());
|
jobs.forEach((job) => job.start());
|
||||||
Logger.info(
|
Logger.info(
|
||||||
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s',
|
t('[schedule][创建定时任务] 任务ID: %s, 规则数: %s'),
|
||||||
id,
|
id,
|
||||||
jobs.length,
|
jobs.length,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { t } from '../shared/i18n';
|
||||||
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
|
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
|
||||||
import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron';
|
import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron';
|
||||||
import { scheduleStacks } from './data';
|
import { scheduleStacks } from './data';
|
||||||
@@ -9,19 +10,19 @@ const delCron = (
|
|||||||
) => {
|
) => {
|
||||||
for (const id of call.request.ids) {
|
for (const id of call.request.ids) {
|
||||||
if (scheduleStacks.has(id)) {
|
if (scheduleStacks.has(id)) {
|
||||||
Logger.info(
|
Logger.info(t('[schedule][取消定时任务] 任务ID: %s'), id);
|
||||||
'[schedule][取消定时任务] 任务ID: %s',
|
|
||||||
id,
|
|
||||||
);
|
|
||||||
// 防御性过滤历史调度栈中的空任务,
|
// 防御性过滤历史调度栈中的空任务,
|
||||||
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
|
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
|
||||||
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
|
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
|
||||||
scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => {
|
scheduleStacks
|
||||||
|
.get(id)
|
||||||
|
?.filter((x) => x != null)
|
||||||
|
.forEach((x) => {
|
||||||
try {
|
try {
|
||||||
x.cancel();
|
x.cancel();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
Logger.warn(
|
Logger.warn(
|
||||||
'[schedule][取消任务失败] 任务ID: %s, 错误: %s',
|
t('[schedule][取消任务失败] 任务ID: %s, 错误: %s'),
|
||||||
id,
|
id,
|
||||||
error?.message || error,
|
error?.message || error,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -641,7 +641,7 @@ export default class CronService {
|
|||||||
stopped.add(pid);
|
stopped.add(pid);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][停止任务失败] PID: %s, 错误: %s',
|
t('[panel][停止任务失败] PID: %s, 错误: %s'),
|
||||||
pid,
|
pid,
|
||||||
asError(error).message
|
asError(error).message
|
||||||
);
|
);
|
||||||
@@ -695,7 +695,7 @@ export default class CronService {
|
|||||||
await killTask(instance.pid);
|
await killTask(instance.pid);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`,
|
t("[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s"), instanceId, instance.pid, error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -791,20 +791,20 @@ export default class CronService {
|
|||||||
const result = await completed;
|
const result = await completed;
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
|
t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
|
||||||
id,
|
id,
|
||||||
result.error.message
|
result.error.message
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
|
t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'),
|
||||||
id,
|
id,
|
||||||
result.code
|
result.code
|
||||||
);
|
);
|
||||||
return { ...cron, pid: cp.pid, ...result } as any;
|
return { ...cron, pid: cp.pid, ...result } as any;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][创建任务失败] 任务ID: %s, 错误: %s',
|
t('[panel][创建任务失败] 任务ID: %s, 错误: %s'),
|
||||||
cronId,
|
cronId,
|
||||||
asError(error).message
|
asError(error).message
|
||||||
);
|
);
|
||||||
@@ -813,7 +813,7 @@ export default class CronService {
|
|||||||
if (absolutePath) await logStreamManager.closeStream(absolutePath);
|
if (absolutePath) await logStreamManager.closeStream(absolutePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][关闭任务日志失败] %s',
|
t('[panel][关闭任务日志失败] %s'),
|
||||||
asError(error).message
|
asError(error).message
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -845,7 +845,7 @@ export default class CronService {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][清理任务状态失败] %s',
|
t('[panel][清理任务状态失败] %s'),
|
||||||
asError(error).message
|
asError(error).message
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { t } from '../shared/i18n';
|
||||||
import { performance } from 'perf_hooks';
|
import { performance } from 'perf_hooks';
|
||||||
import Logger from '../loaders/logger';
|
import Logger from '../loaders/logger';
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ export class MetricsService {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
metrics: this.getMetrics(),
|
metrics: this.getMetrics(),
|
||||||
};
|
};
|
||||||
Logger.info('性能指标报告:', report);
|
Logger.info(t('性能指标报告:'), report);
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-12
@@ -1,3 +1,4 @@
|
|||||||
|
import { t } from '../shared/i18n';
|
||||||
import { Service, Inject } from 'typedi';
|
import { Service, Inject } from 'typedi';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { createCronJob, CronJob } from '../shared/cronScheduler';
|
import { createCronJob, CronJob } from '../shared/cronScheduler';
|
||||||
@@ -83,7 +84,7 @@ export default class ScheduleService {
|
|||||||
let cp: ChildProcessWithoutNullStreams | undefined;
|
let cp: ChildProcessWithoutNullStreams | undefined;
|
||||||
let result: ProcessResult = { code: null, signal: null };
|
let result: ProcessResult = { code: null, signal: null };
|
||||||
try {
|
try {
|
||||||
this.logger.info('[panel][开始执行任务] 任务ID: %s', others.id);
|
this.logger.info(t('[panel][开始执行任务] 任务ID: %s'), others.id);
|
||||||
await callbacks.onBefore?.(startTime);
|
await callbacks.onBefore?.(startTime);
|
||||||
cp = spawn(command, { shell: '/bin/bash' });
|
cp = spawn(command, { shell: '/bin/bash' });
|
||||||
const child = cp;
|
const child = cp;
|
||||||
@@ -103,7 +104,7 @@ export default class ScheduleService {
|
|||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
|
t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
|
||||||
others.id,
|
others.id,
|
||||||
result.error.message,
|
result.error.message,
|
||||||
);
|
);
|
||||||
@@ -111,7 +112,7 @@ export default class ScheduleService {
|
|||||||
await callbacks.onError?.(result.error.message);
|
await callbacks.onError?.(result.error.message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][任务错误回调失败] %s',
|
t('[panel][任务错误回调失败] %s'),
|
||||||
asError(error).message,
|
asError(error).message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -127,12 +128,12 @@ export default class ScheduleService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
result.error ??= asError(error);
|
result.error ??= asError(error);
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][任务结束回调失败] %s',
|
t('[panel][任务结束回调失败] %s'),
|
||||||
asError(error).message,
|
asError(error).message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
|
t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'),
|
||||||
others.id,
|
others.id,
|
||||||
result.code,
|
result.code,
|
||||||
);
|
);
|
||||||
@@ -141,7 +142,7 @@ export default class ScheduleService {
|
|||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
// Queue/setup failures must not become unhandled rejections in detached callers.
|
// Queue/setup failures must not become unhandled rejections in detached callers.
|
||||||
rejectStart(asError(error));
|
rejectStart(asError(error));
|
||||||
this.logger.error('[panel][任务队列失败] %s', asError(error).message);
|
this.logger.error(t('[panel][任务队列失败] %s'), asError(error).message);
|
||||||
return { ...others, code: null, signal: null, error: asError(error) };
|
return { ...others, code: null, signal: null, error: asError(error) };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,7 +157,7 @@ export default class ScheduleService {
|
|||||||
) {
|
) {
|
||||||
const _id = this.formatId(id);
|
const _id = this.formatId(id);
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
|
t('[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s'),
|
||||||
_id,
|
_id,
|
||||||
schedule,
|
schedule,
|
||||||
name,
|
name,
|
||||||
@@ -183,7 +184,7 @@ export default class ScheduleService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// A persisted invalid subscription must not become an unhandled startup rejection.
|
// A persisted invalid subscription must not become an unhandled startup rejection.
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s',
|
t('[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s'),
|
||||||
_id,
|
_id,
|
||||||
schedule,
|
schedule,
|
||||||
asError(error).message,
|
asError(error).message,
|
||||||
@@ -204,7 +205,7 @@ export default class ScheduleService {
|
|||||||
|
|
||||||
async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
|
async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
|
||||||
const _id = this.formatId(id);
|
const _id = this.formatId(id);
|
||||||
this.logger.info('[panel][取消定时任务] 任务名: %s', name);
|
this.logger.info(t('[panel][取消定时任务] 任务名: %s'), name);
|
||||||
if (this.scheduleStacks.has(_id)) {
|
if (this.scheduleStacks.has(_id)) {
|
||||||
this.scheduleStacks.get(_id)?.cancel();
|
this.scheduleStacks.get(_id)?.cancel();
|
||||||
this.scheduleStacks.delete(_id);
|
this.scheduleStacks.delete(_id);
|
||||||
@@ -219,7 +220,7 @@ export default class ScheduleService {
|
|||||||
) {
|
) {
|
||||||
const _id = this.formatId(id);
|
const _id = this.formatId(id);
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s',
|
t('[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s'),
|
||||||
_id,
|
_id,
|
||||||
name,
|
name,
|
||||||
command,
|
command,
|
||||||
@@ -236,7 +237,7 @@ export default class ScheduleService {
|
|||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
|
t('[panel][执行任务失败] 命令: %s, 错误信息: %j'),
|
||||||
command,
|
command,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
@@ -264,7 +265,7 @@ export default class ScheduleService {
|
|||||||
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
|
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
|
||||||
const _id = this.formatId(id);
|
const _id = this.formatId(id);
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
'[panel][取消interval任务] 任务ID: %s, 任务名: %s',
|
t('[panel][取消interval任务] 任务ID: %s, 任务名: %s'),
|
||||||
_id,
|
_id,
|
||||||
name,
|
name,
|
||||||
);
|
);
|
||||||
|
|||||||
+20
-9
@@ -1,3 +1,4 @@
|
|||||||
|
import { t } from '../shared/i18n';
|
||||||
import { Service, Inject } from 'typedi';
|
import { Service, Inject } from 'typedi';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
@@ -64,7 +65,7 @@ export default class SshKeyService {
|
|||||||
} catch {}
|
} catch {}
|
||||||
await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' });
|
await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('生成私钥文件失败', error);
|
this.logger.error(t('生成私钥文件失败'), error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ export default class SshKeyService {
|
|||||||
const filePath = resolveSubscriptionPath(this.sshPath, alias);
|
const filePath = resolveSubscriptionPath(this.sshPath, alias);
|
||||||
await rmPath(filePath);
|
await rmPath(filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('删除私钥文件失败', error);
|
this.logger.error(t('删除私钥文件失败'), error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +83,13 @@ export default class SshKeyService {
|
|||||||
host: string,
|
host: string,
|
||||||
proxy?: string,
|
proxy?: string,
|
||||||
) {
|
) {
|
||||||
if (!/^[a-zA-Z0-9.-]+$/.test(host) ||
|
if (
|
||||||
(proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy))) {
|
!/^[a-zA-Z0-9.-]+$/.test(host) ||
|
||||||
throw Object.assign(new Error('Invalid SSH host or proxy'), { status: 400 });
|
(proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy))
|
||||||
|
) {
|
||||||
|
throw Object.assign(new Error('Invalid SSH host or proxy'), {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (host === 'github.com') {
|
if (host === 'github.com') {
|
||||||
host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`;
|
host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`;
|
||||||
@@ -92,7 +97,10 @@ export default class SshKeyService {
|
|||||||
const proxyStr = proxy
|
const proxyStr = proxy
|
||||||
? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n`
|
? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n`
|
||||||
: '';
|
: '';
|
||||||
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n${proxyStr}`;
|
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(
|
||||||
|
this.sshPath,
|
||||||
|
alias,
|
||||||
|
)}\n StrictHostKeyChecking no\n${proxyStr}`;
|
||||||
await writeFileWithLock(
|
await writeFileWithLock(
|
||||||
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
|
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
|
||||||
config,
|
config,
|
||||||
@@ -108,7 +116,7 @@ export default class SshKeyService {
|
|||||||
const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config');
|
const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config');
|
||||||
await rmPath(filePath);
|
await rmPath(filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`删除ssh配置文件${alias}失败`, error);
|
this.logger.error(t('删除ssh配置文件%s失败'), alias, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +155,7 @@ export default class SshKeyService {
|
|||||||
);
|
);
|
||||||
await this.generateSingleSshConfig(alias, host, doc.proxy);
|
await this.generateSingleSshConfig(alias, host, doc.proxy);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn('跳过订阅 %s 的无效 SSH 配置: %s', doc.id, error);
|
this.logger.warn(t('跳过订阅 %s 的无效 SSH 配置: %s'), doc.id, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +175,10 @@ export default class SshKeyService {
|
|||||||
|
|
||||||
private async generateGlobalSshConfig(alias: string) {
|
private async generateGlobalSshConfig(alias: string) {
|
||||||
// Create a config that matches all hosts, making this key globally available
|
// Create a config that matches all hosts, making this key globally available
|
||||||
const config = `Host *\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n`;
|
const config = `Host *\n IdentityFile ${resolveSubscriptionPath(
|
||||||
|
this.sshPath,
|
||||||
|
alias,
|
||||||
|
)}\n StrictHostKeyChecking no\n`;
|
||||||
await writeFileWithLock(
|
await writeFileWithLock(
|
||||||
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
|
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
|
||||||
config,
|
config,
|
||||||
|
|||||||
+16
-16
@@ -5,7 +5,7 @@ import { promisify } from 'util';
|
|||||||
import { shellQuote } from '../shared/shellQuote';
|
import { shellQuote } from '../shared/shellQuote';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { Agent, request } from 'undici';
|
import { request } from 'undici';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { Inject, Service } from 'typedi';
|
import { Inject, Service } from 'typedi';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
@@ -282,26 +282,24 @@ export default class SystemService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async checkUpdate() {
|
public async checkUpdate() {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||||
try {
|
try {
|
||||||
const currentVersionContent = await parseVersion(config.versionFile);
|
const currentVersionContent = await parseVersion(config.versionFile);
|
||||||
|
|
||||||
let lastVersionContent;
|
const { body, statusCode } = await request(
|
||||||
try {
|
|
||||||
const { body } = await request(
|
|
||||||
`${config.lastVersionFile}?t=${Date.now()}`,
|
`${config.lastVersionFile}?t=${Date.now()}`,
|
||||||
{
|
{ signal: controller.signal },
|
||||||
dispatcher: new Agent({
|
|
||||||
keepAliveTimeout: 30000,
|
|
||||||
keepAliveMaxTimeout: 30000,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const text = await body.text();
|
const text = await body.text();
|
||||||
lastVersionContent = parseContentVersion(text);
|
if (statusCode !== 200) throw new Error('Version request failed');
|
||||||
} catch (error) {}
|
const lastVersionContent = parseContentVersion(text);
|
||||||
|
if (
|
||||||
if (!lastVersionContent) {
|
!lastVersionContent ||
|
||||||
lastVersionContent = currentVersionContent;
|
typeof lastVersionContent.version !== 'string' ||
|
||||||
|
!/^\d+\.\d+\.\d+$/.test(lastVersionContent.version)
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid version response');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -319,8 +317,10 @@ export default class SystemService {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return {
|
return {
|
||||||
code: 400,
|
code: 400,
|
||||||
message: error.message,
|
message: t('检查更新失败,请检查网络后重试'),
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export default class UserService {
|
|||||||
t('你于') +
|
t('你于') +
|
||||||
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
|
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
|
||||||
t('在') +
|
t('在') +
|
||||||
address +
|
t(address) +
|
||||||
' ' +
|
' ' +
|
||||||
req.platform +
|
req.platform +
|
||||||
t('端') +
|
t('端') +
|
||||||
@@ -219,7 +219,7 @@ export default class UserService {
|
|||||||
t('你于') +
|
t('你于') +
|
||||||
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
|
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
|
||||||
t('在') +
|
t('在') +
|
||||||
address +
|
t(address) +
|
||||||
' ' +
|
' ' +
|
||||||
req.platform +
|
req.platform +
|
||||||
t('端') +
|
t('端') +
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { t } from './i18n';
|
||||||
import { createTask, ScheduledTask } from 'node-cron';
|
import { createTask, ScheduledTask } from 'node-cron';
|
||||||
import { parseCronSchedule } from './cronSchedule';
|
import { parseCronSchedule } from './cronSchedule';
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ export function createCronJob(
|
|||||||
if (cancelled || !started || !accepts(index, date)) return;
|
if (cancelled || !started || !accepts(index, date)) return;
|
||||||
if (missed) {
|
if (missed) {
|
||||||
options.logger.warn(
|
options.logger.warn(
|
||||||
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s',
|
t('[schedule][补执行迟到任务] 任务: %s, 计划时间: %s'),
|
||||||
options.name,
|
options.name,
|
||||||
date.toISOString(),
|
date.toISOString(),
|
||||||
);
|
);
|
||||||
@@ -45,7 +46,7 @@ export function createCronJob(
|
|||||||
await callback(date);
|
await callback(date);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.logger.error(
|
options.logger.error(
|
||||||
'[schedule][定时回调失败] 任务: %s, 错误: %s',
|
t('[schedule][定时回调失败] 任务: %s, 错误: %s'),
|
||||||
options.name,
|
options.name,
|
||||||
error instanceof Error ? error.message : String(error),
|
error instanceof Error ? error.message : String(error),
|
||||||
);
|
);
|
||||||
|
|||||||
+219
-92
@@ -1,112 +1,229 @@
|
|||||||
import { shareStore } from './store';
|
|
||||||
|
|
||||||
const messages: Record<string, Record<string, string>> = {
|
const messages: Record<string, Record<string, string>> = {
|
||||||
zh: {},
|
zh: {},
|
||||||
en: {
|
en: {
|
||||||
'资源已存在,请检查重复的名称或值': 'Resource already exists; check for duplicate names or values',
|
'[schedule][停止已运行任务] 任务ID: %s, PID: %s':
|
||||||
'暂无权限': 'Access denied',
|
'[schedule][Stopping running task] Task ID: %s, PID: %s',
|
||||||
'参数错误': 'Invalid parameter',
|
|
||||||
'参数不正确': 'Invalid parameter',
|
'接口已下线,请使用 /configs/detail 接口':
|
||||||
'文件无法访问': 'File not accessible',
|
'This endpoint has been retired. Use /configs/detail instead.',
|
||||||
'文件不存在': 'File not found',
|
'接口已下线,请使用 /logs/detail 接口':
|
||||||
'路径不存在': 'Path does not exist',
|
'This endpoint has been retired. Use /logs/detail instead.',
|
||||||
'路径不正确': 'Invalid path',
|
'接口已下线,请使用 /scripts/detail 接口':
|
||||||
|
'This endpoint has been retired. Use /scripts/detail instead.',
|
||||||
|
'仅支持 PNG、JPEG、GIF、WebP、AVIF 图片':
|
||||||
|
'Only PNG, JPEG, GIF, WebP and AVIF images are supported',
|
||||||
|
请先初始化: 'Please initialize the panel first',
|
||||||
|
请先关闭两步验证: 'Please disable two-factor authentication first',
|
||||||
|
'失败次数过多,请稍后重试':
|
||||||
|
'Too many failed attempts. Please try again later.',
|
||||||
|
配置更新失败: 'Failed to update configuration',
|
||||||
|
'trust proxy 配置格式无效': 'Invalid trust proxy configuration',
|
||||||
|
'代理层数不能超过 20': 'Proxy hop count cannot exceed 20',
|
||||||
|
'环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖':
|
||||||
|
'QL_TRUST_PROXY is active and cannot be overridden by system settings',
|
||||||
|
系统配置不存在: 'System configuration does not exist',
|
||||||
|
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本':
|
||||||
|
'Version history only supports UTF-8 text scripts up to 1 MiB',
|
||||||
|
脚本不存在: 'Script does not exist',
|
||||||
|
请选择脚本文件: 'Please select a script file',
|
||||||
|
'历史版本读取失败,请检查历史文件':
|
||||||
|
'Failed to read version history. Check the history file.',
|
||||||
|
历史版本不存在或已清理:
|
||||||
|
'This history version does not exist or has been removed',
|
||||||
|
'文件已变化,请刷新历史版本后重试':
|
||||||
|
'The file has changed. Refresh version history and try again.',
|
||||||
|
'历史版本容量不足,无法安全保存':
|
||||||
|
'Version history storage is full. The file cannot be saved safely.',
|
||||||
|
'跳过订阅 %s 的无效定时配置: %s':
|
||||||
|
'Skipping invalid schedule for subscription %s: %s',
|
||||||
|
'[schedule][准备运行任务] 命令: %s':
|
||||||
|
'[schedule][Preparing task] Command: %s',
|
||||||
|
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s':
|
||||||
|
'[schedule][Creating scheduled task] Task ID: %s, schedules: %s',
|
||||||
|
'[schedule][取消定时任务] 任务ID: %s':
|
||||||
|
'[schedule][Cancelling scheduled task] Task ID: %s',
|
||||||
|
'[schedule][取消任务失败] 任务ID: %s, 错误: %s':
|
||||||
|
'[schedule][Failed to cancel task] Task ID: %s, error: %s',
|
||||||
|
'[panel][停止任务失败] PID: %s, 错误: %s':
|
||||||
|
'[panel][Failed to stop task] PID: %s, error: %s',
|
||||||
|
'[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s':
|
||||||
|
'[panel][Failed to stop instance] Instance ID: %s, PID: %s, error: %s',
|
||||||
|
'[panel][执行任务失败] 任务ID: %s, 错误: %s':
|
||||||
|
'[panel][Task failed] Task ID: %s, error: %s',
|
||||||
|
'[panel][执行任务结束] 任务ID: %s, 退出码: %j':
|
||||||
|
'[panel][Task finished] Task ID: %s, exit code: %j',
|
||||||
|
'[panel][创建任务失败] 任务ID: %s, 错误: %s':
|
||||||
|
'[panel][Failed to create task] Task ID: %s, error: %s',
|
||||||
|
'[panel][关闭任务日志失败] %s': '[panel][Failed to close task log] %s',
|
||||||
|
'[panel][清理任务状态失败] %s': '[panel][Failed to clear task status] %s',
|
||||||
|
'性能指标报告:': 'Performance metrics:',
|
||||||
|
'[panel][开始执行任务] 任务ID: %s': '[panel][Starting task] Task ID: %s',
|
||||||
|
'[panel][任务错误回调失败] %s': '[panel][Task error callback failed] %s',
|
||||||
|
'[panel][任务结束回调失败] %s':
|
||||||
|
'[panel][Task completion callback failed] %s',
|
||||||
|
'[panel][任务队列失败] %s': '[panel][Task queue failed] %s',
|
||||||
|
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s':
|
||||||
|
'[panel][Creating cron task] Task ID: %s, cron: %s, name: %s, command: %s',
|
||||||
|
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s':
|
||||||
|
'[panel][Skipping invalid schedule] Task ID: %s, cron: %s, error: %s',
|
||||||
|
'[panel][取消定时任务] 任务名: %s':
|
||||||
|
'[panel][Cancelling scheduled task] Name: %s',
|
||||||
|
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s':
|
||||||
|
'[panel][Creating interval task] Task ID: %s, name: %s, command: %s',
|
||||||
|
'[panel][执行任务失败] 命令: %s, 错误信息: %j':
|
||||||
|
'[panel][Task failed] Command: %s, error: %j',
|
||||||
|
'[panel][取消interval任务] 任务ID: %s, 任务名: %s':
|
||||||
|
'[panel][Cancelling interval task] Task ID: %s, name: %s',
|
||||||
|
生成私钥文件失败: 'Failed to generate private key file',
|
||||||
|
删除私钥文件失败: 'Failed to delete private key file',
|
||||||
|
'删除ssh配置文件%s失败': 'Failed to delete SSH configuration file %s',
|
||||||
|
'跳过订阅 %s 的无效 SSH 配置: %s':
|
||||||
|
'Skipping invalid SSH configuration for subscription %s: %s',
|
||||||
|
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s':
|
||||||
|
'[schedule][Running late task] Task: %s, scheduled time: %s',
|
||||||
|
'[schedule][定时回调失败] 任务: %s, 错误: %s':
|
||||||
|
'[schedule][Scheduled callback failed] Task: %s, error: %s',
|
||||||
|
'[schedule][任务队列] 空闲中...': '[schedule][Task queue] Idle...',
|
||||||
|
'[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s':
|
||||||
|
'[schedule][Task queued] Running: %s, waiting: %s',
|
||||||
|
'[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s':
|
||||||
|
'[schedule][Processing task] Running: %s, waiting: %s',
|
||||||
|
'[schedule][任务处理成功] 参数 %s':
|
||||||
|
'[schedule][Task succeeded] Arguments: %s',
|
||||||
|
'[schedule][任务处理错误] 参数 %s': '[schedule][Task failed] Arguments: %s',
|
||||||
|
'[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s':
|
||||||
|
'[schedule][Task finished] Running: %s, waiting: %s',
|
||||||
|
'[schedule][任务重复运行] 通知失败 %s':
|
||||||
|
'[schedule][Duplicate task] Notification failed: %s',
|
||||||
|
'[schedule][任务重复运行] 参数 %s':
|
||||||
|
'[schedule][Duplicate task] Arguments: %s',
|
||||||
|
'[schedule][停止已运行任务] 任务ID: %s':
|
||||||
|
'[schedule][Stopping running task] Task ID: %s',
|
||||||
|
'[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s':
|
||||||
|
'[schedule][Failed to check running task] Task ID: %s, error: %s',
|
||||||
|
'[schedule][开始执行任务] 参数 %s':
|
||||||
|
'[schedule][Starting task] Arguments: %s',
|
||||||
|
'[schedule][任务标准错误] 命令: %s, 信息: %s':
|
||||||
|
'[schedule][Task stderr] Command: %s, message: %s',
|
||||||
|
'[schedule][执行任务失败] 命令: %s, 错误: %s':
|
||||||
|
'[schedule][Task failed] Command: %s, error: %s',
|
||||||
|
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j':
|
||||||
|
'[schedule][Task finished] Task ID: %s, exit code: %j',
|
||||||
|
'[schedule][创建任务失败] 命令: %s, 错误: %s':
|
||||||
|
'[schedule][Failed to create task] Command: %s, error: %s',
|
||||||
|
|
||||||
|
'检查更新失败,请检查网络后重试':
|
||||||
|
'Failed to check for updates. Check your network and try again.',
|
||||||
|
'资源已存在,请检查重复的名称或值':
|
||||||
|
'Resource already exists; check for duplicate names or values',
|
||||||
|
暂无权限: 'Access denied',
|
||||||
|
参数错误: 'Invalid parameter',
|
||||||
|
参数不正确: 'Invalid parameter',
|
||||||
|
文件无法访问: 'File not accessible',
|
||||||
|
文件不存在: 'File not found',
|
||||||
|
路径不存在: 'Path does not exist',
|
||||||
|
路径不正确: 'Invalid path',
|
||||||
'通知发送失败,请检查参数': 'Notification failed, check parameters',
|
'通知发送失败,请检查参数': 'Notification failed, check parameters',
|
||||||
'通知发送失败,请检查系统设置/通知配置': 'Notification failed, check system settings',
|
'通知发送失败,请检查系统设置/通知配置':
|
||||||
'通知发送成功': 'Notification sent successfully',
|
'Notification failed, check system settings',
|
||||||
'设置时区失败': 'Failed to set timezone',
|
通知发送成功: 'Notification sent successfully',
|
||||||
'任务未找到': 'Task not found',
|
设置时区失败: 'Failed to set timezone',
|
||||||
'密码不能设置为admin': 'Password cannot be admin',
|
任务未找到: 'Task not found',
|
||||||
'更新成功': 'Update successful',
|
密码不能设置为admin: 'Password cannot be admin',
|
||||||
'未知错误': 'Unknown error',
|
更新成功: 'Update successful',
|
||||||
'验证失败': 'Verification failed',
|
未知错误: 'Unknown error',
|
||||||
'实例不存在或已停止': 'Instance does not exist or stopped',
|
验证失败: 'Verification failed',
|
||||||
'实例已停止': 'Instance stopped',
|
实例不存在或已停止: 'Instance does not exist or stopped',
|
||||||
'确认停止实例': 'Confirm to stop instance',
|
实例已停止: 'Instance stopped',
|
||||||
'确认停止运行实例': 'Confirm to stop running instance',
|
确认停止实例: 'Confirm to stop instance',
|
||||||
'确认停止': 'Confirm to stop',
|
确认停止运行实例: 'Confirm to stop running instance',
|
||||||
'确认停止定时任务': 'Confirm to stop scheduled task',
|
确认停止: 'Confirm to stop',
|
||||||
'确认删除': 'Confirm to delete',
|
确认停止定时任务: 'Confirm to stop scheduled task',
|
||||||
'确认删除定时任务': 'Confirm to delete scheduled task',
|
确认删除: 'Confirm to delete',
|
||||||
'确认删除选中的定时任务吗': 'Confirm to delete selected tasks?',
|
确认删除定时任务: 'Confirm to delete scheduled task',
|
||||||
'确认运行': 'Confirm to run',
|
确认删除选中的定时任务吗: 'Confirm to delete selected tasks?',
|
||||||
'确认运行定时任务': 'Confirm to run scheduled task',
|
确认运行: 'Confirm to run',
|
||||||
'确认保存': 'Confirm to save',
|
确认运行定时任务: 'Confirm to run scheduled task',
|
||||||
'确认保存文件': 'Confirm to save file',
|
确认保存: 'Confirm to save',
|
||||||
'确认重启': 'Confirm restart',
|
确认保存文件: 'Confirm to save file',
|
||||||
'确认启用': 'Confirm to enable',
|
确认重启: 'Confirm restart',
|
||||||
'确认禁用': 'Confirm to disable',
|
确认启用: 'Confirm to enable',
|
||||||
'确认': 'Confirm',
|
确认禁用: 'Confirm to disable',
|
||||||
'删除成功': 'Deleted successfully',
|
确认: 'Confirm',
|
||||||
'操作成功': 'Operation successful',
|
删除成功: 'Deleted successfully',
|
||||||
'参数不完整': 'Incomplete parameters',
|
操作成功: 'Operation successful',
|
||||||
'默认路径不支持删除': 'Default path cannot be deleted',
|
参数不完整: 'Incomplete parameters',
|
||||||
'必须在日志目录下': 'Must be within log directory',
|
默认路径不支持删除: 'Default path cannot be deleted',
|
||||||
|
必须在日志目录下: 'Must be within log directory',
|
||||||
'备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite',
|
'备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite',
|
||||||
'如果恢复失败,可进入容器执行': 'If restore fails, run in container:',
|
'如果恢复失败,可进入容器执行': 'If restore fails, run in container:',
|
||||||
'系统将在': 'System will',
|
系统将在: 'System will',
|
||||||
'秒后自动刷新': 'refresh in seconds',
|
秒后自动刷新: 'refresh in seconds',
|
||||||
'生成数据中...': 'Generating data...',
|
'生成数据中...': 'Generating data...',
|
||||||
'每条数据 name 或者 value 字段不能为空,参考导出文件格式': 'Each entry must have name and value, see export format',
|
'每条数据 name 或者 value 字段不能为空,参考导出文件格式':
|
||||||
'不支持当前依赖类型': 'Unsupported dependency type',
|
'Each entry must have name and value, see export format',
|
||||||
'依赖已存在': 'Dependency already exists',
|
不支持当前依赖类型: 'Unsupported dependency type',
|
||||||
'依赖不存在': 'Dependency does not exist',
|
依赖已存在: 'Dependency already exists',
|
||||||
'该脚本正在运行中': 'Script is running',
|
依赖不存在: 'Dependency does not exist',
|
||||||
'该脚本未在运行中': 'Script is not running',
|
该脚本正在运行中: 'Script is running',
|
||||||
'文件内容为空': 'File content is empty',
|
该脚本未在运行中: 'Script is not running',
|
||||||
'文件名不能为空': 'File name cannot be empty',
|
文件内容为空: 'File content is empty',
|
||||||
'标签不能为空': 'Label cannot be empty',
|
文件名不能为空: 'File name cannot be empty',
|
||||||
'名称不能为空': 'Name cannot be empty',
|
标签不能为空: 'Label cannot be empty',
|
||||||
'名称不能为保留关键字': 'Name cannot be reserved keyword',
|
名称不能为空: 'Name cannot be empty',
|
||||||
'名称已存在': 'Name already exists',
|
名称不能为保留关键字: 'Name cannot be reserved keyword',
|
||||||
'密码错误': 'Incorrect password',
|
名称已存在: 'Name already exists',
|
||||||
'用户不存在': 'User does not exist',
|
密码错误: 'Incorrect password',
|
||||||
'请输入用户名和密码': 'Please enter username and password',
|
用户不存在: 'User does not exist',
|
||||||
'无权访问': 'Access denied',
|
请输入用户名和密码: 'Please enter username and password',
|
||||||
'登录成功': 'Login successful',
|
无权访问: 'Access denied',
|
||||||
'退出成功': 'Logout successful',
|
登录成功: 'Login successful',
|
||||||
|
退出成功: 'Logout successful',
|
||||||
'Token 已失效': 'Token expired',
|
'Token 已失效': 'Token expired',
|
||||||
'Token 无效': 'Invalid token',
|
'Token 无效': 'Invalid token',
|
||||||
'两步骤验证已开启': '2FA enabled',
|
两步骤验证已开启: '2FA enabled',
|
||||||
'两步骤验证已关闭': '2FA disabled',
|
两步骤验证已关闭: '2FA disabled',
|
||||||
'验证码错误': 'Invalid verification code',
|
验证码错误: 'Invalid verification code',
|
||||||
'验证码已过期': 'Verification code expired',
|
验证码已过期: 'Verification code expired',
|
||||||
'请先开启两步骤验证': 'Please enable 2FA first',
|
请先开启两步骤验证: 'Please enable 2FA first',
|
||||||
'两步骤验证密钥不能为空': '2FA secret cannot be empty',
|
两步骤验证密钥不能为空: '2FA secret cannot be empty',
|
||||||
'用户已存在': 'User already exists',
|
用户已存在: 'User already exists',
|
||||||
'用户名不能为admin': 'Username cannot be admin',
|
用户名不能为admin: 'Username cannot be admin',
|
||||||
'不能删除自己': 'Cannot delete yourself',
|
不能删除自己: 'Cannot delete yourself',
|
||||||
'不能禁用自己': 'Cannot disable yourself',
|
不能禁用自己: 'Cannot disable yourself',
|
||||||
'文件路径无效': 'Invalid file path',
|
文件路径无效: 'Invalid file path',
|
||||||
'保存成功': 'Saved successfully',
|
保存成功: 'Saved successfully',
|
||||||
'client_id 或 client_seret 有误': 'Invalid client_id or client_secret',
|
'client_id 或 client_seret 有误': 'Invalid client_id or client_secret',
|
||||||
'订阅执行完成': 'Subscription completed',
|
订阅执行完成: 'Subscription completed',
|
||||||
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行': 'wxPusher requires at least one of TopicIds or Uids',
|
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行':
|
||||||
|
'wxPusher requires at least one of TopicIds or Uids',
|
||||||
'wxPusher SPT 不能为空': 'wxPusher SPT cannot be empty',
|
'wxPusher SPT 不能为空': 'wxPusher SPT cannot be empty',
|
||||||
'wxPusher SPT 最多支持 10 个': 'wxPusher SPT supports at most 10 tokens',
|
'wxPusher SPT 最多支持 10 个': 'wxPusher SPT supports at most 10 tokens',
|
||||||
'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title',
|
'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title',
|
||||||
'绝对路径必须在日志目录内或使用 /dev/null':
|
'绝对路径必须在日志目录内或使用 /dev/null':
|
||||||
'Absolute path must be within log directory or use /dev/null',
|
'Absolute path must be within log directory or use /dev/null',
|
||||||
'请先登录': 'Please login first',
|
请先登录: 'Please login first',
|
||||||
'运行中...': 'Running...',
|
'运行中...': 'Running...',
|
||||||
'日志不存在...': 'Log does not exist...',
|
'日志不存在...': 'Log does not exist...',
|
||||||
'未分类': 'Uncategorized',
|
未分类: 'Uncategorized',
|
||||||
'任务重复运行': 'Duplicate task execution',
|
任务重复运行: 'Duplicate task execution',
|
||||||
'日志设置为忽略': 'Log set to ignore',
|
日志设置为忽略: 'Log set to ignore',
|
||||||
'定时规则不能为空': 'Schedule rule cannot be empty',
|
定时规则不能为空: 'Schedule rule cannot be empty',
|
||||||
'无效的定时规则': 'Invalid schedule rule',
|
无效的定时规则: 'Invalid schedule rule',
|
||||||
'日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符':
|
'日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符':
|
||||||
'Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators',
|
'Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators',
|
||||||
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters',
|
日志名称不能超过100个字符: 'Log name cannot exceed 100 characters',
|
||||||
'错误的用户名密码,请重试': 'Incorrect username or password, please try again',
|
'错误的用户名密码,请重试':
|
||||||
|
'Incorrect username or password, please try again',
|
||||||
'该 IP 已被列入黑名单': 'This IP address has been blocked',
|
'该 IP 已被列入黑名单': 'This IP address has been blocked',
|
||||||
'已加入 IP 黑名单': 'IP address added to the blacklist',
|
'已加入 IP 黑名单': 'IP address added to the blacklist',
|
||||||
'已移出 IP 黑名单': 'IP address removed from the blacklist',
|
'已移出 IP 黑名单': 'IP address removed from the blacklist',
|
||||||
'青龙快讯': 'QingLong',
|
青龙快讯: 'QingLong',
|
||||||
'登录通知': 'Login Notification',
|
登录通知: 'Login Notification',
|
||||||
'你于': 'You at ',
|
你于: 'You at ',
|
||||||
'在': ' in ',
|
在: ' in ',
|
||||||
端: '',
|
端: ' client',
|
||||||
|
内网IP: 'Private IP',
|
||||||
登录失败: 'login failed',
|
登录失败: 'login failed',
|
||||||
',ip地址': ', IP: ',
|
',ip地址': ', IP: ',
|
||||||
'任务#%s': 'Task#%s',
|
'任务#%s': 'Task#%s',
|
||||||
@@ -131,12 +248,13 @@ const messages: Record<string, Record<string, string>> = {
|
|||||||
青龙: 'QingLong',
|
青龙: 'QingLong',
|
||||||
'【蛟龙】测试通知 https://t.me/jiao_long':
|
'【蛟龙】测试通知 https://t.me/jiao_long':
|
||||||
'[JiaoLong] Test notification https://t.me/jiao_long',
|
'[JiaoLong] Test notification https://t.me/jiao_long',
|
||||||
'生成token': 'Generate token',
|
生成token: 'Generate token',
|
||||||
'删除日志': 'Delete logs',
|
删除日志: 'Delete logs',
|
||||||
'## 开始执行... %s\n': '## Start executing... %s\n',
|
'## 开始执行... %s\n': '## Start executing... %s\n',
|
||||||
'执行before命令...': 'Execute before command...',
|
'执行before命令...': 'Execute before command...',
|
||||||
'执行after命令...': 'Execute after command...',
|
'执行after命令...': 'Execute after command...',
|
||||||
'## 执行结束... %s 耗时 %s 秒': '## Execution finished... %s elapsed %s seconds',
|
'## 执行结束... %s 耗时 %s 秒':
|
||||||
|
'## Execution finished... %s elapsed %s seconds',
|
||||||
'调度器注册失败,任务创建已回滚':
|
'调度器注册失败,任务创建已回滚':
|
||||||
'Scheduler registration failed, task creation rolled back',
|
'Scheduler registration failed, task creation rolled back',
|
||||||
'调度器注册失败,任务更新已回滚':
|
'调度器注册失败,任务更新已回滚':
|
||||||
@@ -152,9 +270,18 @@ const messages: Record<string, Record<string, string>> = {
|
|||||||
|
|
||||||
let currentLang: string = 'zh';
|
let currentLang: string = 'zh';
|
||||||
|
|
||||||
export function setLang(lang: string) {
|
export function setLang(lang: string, persist = true) {
|
||||||
currentLang = lang || 'zh';
|
currentLang = lang || 'zh';
|
||||||
shareStore.setLang(currentLang);
|
if (!persist) return;
|
||||||
|
// Translation is also used by standalone scheduler and CLI helpers. Load
|
||||||
|
// persistent storage only when changing the language, not when reading it.
|
||||||
|
const { shareStore } = require('./store') as typeof import('./store');
|
||||||
|
const language = currentLang;
|
||||||
|
return shareStore.setLang(language).then(() => {
|
||||||
|
if (currentLang === language && process.connected && process.send) {
|
||||||
|
process.send({ type: 'language-changed', lang: language }, () => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en) */
|
/** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en) */
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import cluster from 'cluster';
|
||||||
|
import { setLang, systemLang } from './i18n';
|
||||||
|
|
||||||
|
function languageMessage(
|
||||||
|
message: unknown,
|
||||||
|
): message is { type: string; lang: string } {
|
||||||
|
const value = message as { type?: string; lang?: string } | null;
|
||||||
|
return (
|
||||||
|
value?.type === 'language-changed' && ['zh', 'en'].includes(value.lang!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each worker owns a translation cache. Relay saved settings to live workers;
|
||||||
|
// a replacement worker restores the persisted language before serving requests.
|
||||||
|
export async function initializeLanguageSync() {
|
||||||
|
if (cluster.isPrimary) {
|
||||||
|
cluster.on('message', (_worker, message) => {
|
||||||
|
if (!languageMessage(message)) return;
|
||||||
|
setLang(message.lang, false);
|
||||||
|
for (const worker of Object.values(cluster.workers || {})) {
|
||||||
|
if (worker?.isConnected()) worker.send(message, () => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
process.on('message', (message) => {
|
||||||
|
if (languageMessage(message)) setLang(message.lang, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { shareStore } = await import('./store');
|
||||||
|
setLang((await shareStore.getLang()) || systemLang(), false);
|
||||||
|
}
|
||||||
+18
-10
@@ -79,29 +79,36 @@ class TaskLimit {
|
|||||||
private handleEvents() {
|
private handleEvents() {
|
||||||
this.cronLimit.on('add', () => {
|
this.cronLimit.on('add', () => {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
`[schedule][任务加入队列] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
|
t('[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s'),
|
||||||
|
this.cronLimitActiveCount,
|
||||||
|
this.cronLimitPendingCount,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
this.cronLimit.on('active', () => {
|
this.cronLimit.on('active', () => {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
`[schedule][开始处理任务] 运行中任务数: ${
|
t('[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s'),
|
||||||
this.cronLimitActiveCount + 1
|
this.cronLimitActiveCount + 1,
|
||||||
}, 等待中任务数: ${this.cronLimitPendingCount}`,
|
this.cronLimitPendingCount,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
this.cronLimit.on('completed', (param) => {
|
this.cronLimit.on('completed', (param) => {
|
||||||
Logger.info(`[schedule][任务处理成功] 参数 ${JSON.stringify(param)}`);
|
Logger.info(t('[schedule][任务处理成功] 参数 %s'), JSON.stringify(param));
|
||||||
});
|
});
|
||||||
this.cronLimit.on('error', (error) => {
|
this.cronLimit.on('error', (error) => {
|
||||||
Logger.error(`[schedule][任务处理错误] 参数 ${JSON.stringify(error)}`);
|
Logger.error(
|
||||||
|
t('[schedule][任务处理错误] 参数 %s'),
|
||||||
|
JSON.stringify(error),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
this.cronLimit.on('next', () => {
|
this.cronLimit.on('next', () => {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
`[schedule][任务处理结束] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
|
t('[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s'),
|
||||||
|
this.cronLimitActiveCount,
|
||||||
|
this.cronLimitPendingCount,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
this.cronLimit.on('idle', () => {
|
this.cronLimit.on('idle', () => {
|
||||||
Logger.info(`[schedule][任务队列] 空闲中...`);
|
Logger.info(t(`[schedule][任务队列] 空闲中...`));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,13 +179,14 @@ class TaskLimit {
|
|||||||
(err, res) => {
|
(err, res) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
`[schedule][任务重复运行] 通知失败 ${JSON.stringify(err)}`,
|
t('[schedule][任务重复运行] 通知失败 %s'),
|
||||||
|
JSON.stringify(err),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Logger.warn(`[schedule][任务重复运行] 参数 ${JSON.stringify(cron)}`);
|
Logger.warn(t('[schedule][任务重复运行] 参数 %s'), JSON.stringify(cron));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.queuedCrons.set(cron.id, result);
|
this.queuedCrons.set(cron.id, result);
|
||||||
|
|||||||
+14
-8
@@ -1,3 +1,4 @@
|
|||||||
|
import { t } from './i18n';
|
||||||
import { spawn } from 'cross-spawn';
|
import { spawn } from 'cross-spawn';
|
||||||
import taskLimit from './pLimit';
|
import taskLimit from './pLimit';
|
||||||
import Logger from '../loaders/logger';
|
import Logger from '../loaders/logger';
|
||||||
@@ -37,7 +38,9 @@ export function runCron(
|
|||||||
existingCron.status === CrontabStatus.queued)
|
existingCron.status === CrontabStatus.queued)
|
||||||
) {
|
) {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
|
t('[schedule][停止已运行任务] 任务ID: %s, PID: %s'),
|
||||||
|
cron.id,
|
||||||
|
existingCron.pid,
|
||||||
);
|
);
|
||||||
await killTask(existingCron.pid, true);
|
await killTask(existingCron.pid, true);
|
||||||
// Mark old running instances as stopped
|
// Mark old running instances as stopped
|
||||||
@@ -59,7 +62,9 @@ export function runCron(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
|
t('[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s'),
|
||||||
|
cron.id,
|
||||||
|
error,
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -67,17 +72,18 @@ export function runCron(
|
|||||||
// In particular, invalidation can happen while stopping a previous run.
|
// In particular, invalidation can happen while stopping a previous run.
|
||||||
if (!isCurrent()) return;
|
if (!isCurrent()) return;
|
||||||
Logger.info(
|
Logger.info(
|
||||||
`[schedule][开始执行任务] 参数 ${JSON.stringify({
|
t('[schedule][开始执行任务] 参数 %s'),
|
||||||
|
JSON.stringify({
|
||||||
...cron,
|
...cron,
|
||||||
command: cmd,
|
command: cmd,
|
||||||
})}`,
|
}),
|
||||||
);
|
);
|
||||||
const cp = spawn(cmd, { shell: '/bin/bash' });
|
const cp = spawn(cmd, { shell: '/bin/bash' });
|
||||||
|
|
||||||
const { completed } = observeChildProcess(cp, {
|
const { completed } = observeChildProcess(cp, {
|
||||||
onStderr: async (message) => {
|
onStderr: async (message) => {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
'[schedule][任务标准错误] 命令: %s, 信息: %s',
|
t('[schedule][任务标准错误] 命令: %s, 信息: %s'),
|
||||||
cmd,
|
cmd,
|
||||||
message,
|
message,
|
||||||
);
|
);
|
||||||
@@ -86,20 +92,20 @@ export function runCron(
|
|||||||
const result = await completed;
|
const result = await completed;
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
'[schedule][执行任务失败] 命令: %s, 错误: %s',
|
t('[schedule][执行任务失败] 命令: %s, 错误: %s'),
|
||||||
cmd,
|
cmd,
|
||||||
result.error.message,
|
result.error.message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Logger.info(
|
Logger.info(
|
||||||
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j',
|
t('[schedule][执行任务结束] 任务ID: %s, 退出码: %j'),
|
||||||
cron.id,
|
cron.id,
|
||||||
result.code,
|
result.code,
|
||||||
);
|
);
|
||||||
return { ...cron, command: cmd, pid: cp.pid, ...result } as any;
|
return { ...cron, command: cmd, pid: cp.pid, ...result } as any;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
'[schedule][创建任务失败] 命令: %s, 错误: %s',
|
t('[schedule][创建任务失败] 命令: %s, 错误: %s'),
|
||||||
cmd,
|
cmd,
|
||||||
asError(error).message,
|
asError(error).message,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express, { Application, Request } from 'express';
|
import express, { Application, Request } from 'express';
|
||||||
import { AuthDataType, SystemModel } from '../data/system';
|
import { AuthDataType, SystemModel } from '../data/system';
|
||||||
import { normalizeClientIp } from './clientIp';
|
import { normalizeClientIp } from './clientIp';
|
||||||
|
import { t } from './i18n';
|
||||||
|
|
||||||
const DEFAULT_TRUST_PROXY = 'loopback';
|
const DEFAULT_TRUST_PROXY = 'loopback';
|
||||||
|
|
||||||
@@ -30,10 +31,10 @@ export function resolveTrustProxy(value?: string): TrustProxyValue {
|
|||||||
function validateTrustProxy(value: string): string {
|
function validateTrustProxy(value: string): string {
|
||||||
const setting = normalizeSetting(value);
|
const setting = normalizeSetting(value);
|
||||||
if (setting.length > 500 || /[\r\n]/.test(setting)) {
|
if (setting.length > 500 || /[\r\n]/.test(setting)) {
|
||||||
throw new Error('trust proxy 配置格式无效');
|
throw new Error(t('trust proxy 配置格式无效'));
|
||||||
}
|
}
|
||||||
if (/^\d+$/.test(setting) && Number(setting) > 20) {
|
if (/^\d+$/.test(setting) && Number(setting) > 20) {
|
||||||
throw new Error('代理层数不能超过 20');
|
throw new Error(t('代理层数不能超过 20'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const probe = express();
|
const probe = express();
|
||||||
@@ -73,7 +74,7 @@ export async function initializeTrustProxy(app: Application) {
|
|||||||
|
|
||||||
export async function updateTrustProxy(value: string) {
|
export async function updateTrustProxy(value: string) {
|
||||||
if (getEnvironmentSetting()) {
|
if (getEnvironmentSetting()) {
|
||||||
throw new Error('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖');
|
throw new Error(t('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const trustProxy = validateTrustProxy(value);
|
const trustProxy = validateTrustProxy(value);
|
||||||
@@ -81,7 +82,7 @@ export async function updateTrustProxy(value: string) {
|
|||||||
where: { type: AuthDataType.systemConfig },
|
where: { type: AuthDataType.systemConfig },
|
||||||
});
|
});
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
throw new Error('系统配置不存在');
|
throw new Error(t('系统配置不存在'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const plain = doc.get({ plain: true });
|
const plain = doc.get({ plain: true });
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export const nodePreloadSource: string = [
|
|||||||
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
|
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
|
||||||
"printf '%s\\\\n' '__sitecustomize__' &&",
|
"printf '%s\\\\n' '__sitecustomize__' &&",
|
||||||
'node -e \'require("fs").writeFileSync(process.argv[1], JSON.stringify(process.env))\' "$output"`;',
|
'node -e \'require("fs").writeFileSync(process.argv[1], JSON.stringify(process.env))\' "$output"`;',
|
||||||
" if (task_before) console.log('执行前置命令\\n');",
|
" if (task_before) console.log(process.env.QL_LANG === 'en' ? 'Executing before command\\n' : '执行前置命令\\n');",
|
||||||
' const hookArgs = JSON.parse(process.env.QL_CLI_PRELOAD_ARGS || JSON.stringify([fileName]));',
|
' const hookArgs = JSON.parse(process.env.QL_CLI_PRELOAD_ARGS || JSON.stringify([fileName]));',
|
||||||
" const res = execFileSync('/bin/bash', ['-c', commands, 'ql-before', file_task_before, tempFile, task_before || '', ...hookArgs], {",
|
" const res = execFileSync('/bin/bash', ['-c', commands, 'ql-before', file_task_before, tempFile, task_before || '', ...hookArgs], {",
|
||||||
" encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,",
|
" encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,",
|
||||||
@@ -115,7 +115,7 @@ export const nodePreloadSource: string = [
|
|||||||
' console.log(output);',
|
' console.log(output);',
|
||||||
' }',
|
' }',
|
||||||
' if (task_before) {',
|
' if (task_before) {',
|
||||||
" console.log('执行前置命令结束\\n');",
|
" console.log(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');",
|
||||||
' }',
|
' }',
|
||||||
' } catch (error) {',
|
' } catch (error) {',
|
||||||
" if (!error.message.includes('spawnSync /bin/bash E2BIG')) {",
|
" if (!error.message.includes('spawnSync /bin/bash E2BIG')) {",
|
||||||
@@ -124,7 +124,7 @@ export const nodePreloadSource: string = [
|
|||||||
' // environment variable is too large',
|
' // environment variable is too large',
|
||||||
' }',
|
' }',
|
||||||
' if (task_before) {',
|
' if (task_before) {',
|
||||||
" console.log('执行前置命令结束\\n');",
|
" console.log(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');",
|
||||||
' }',
|
' }',
|
||||||
' }',
|
' }',
|
||||||
'',
|
'',
|
||||||
@@ -214,7 +214,7 @@ export const pythonPreloadSource: string = [
|
|||||||
' ',
|
' ',
|
||||||
' task_before = os.getenv("task_before", "")',
|
' task_before = os.getenv("task_before", "")',
|
||||||
' if task_before:',
|
' if task_before:',
|
||||||
' print("执行前置命令\\n")',
|
' print("Executing before command\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令\\n")',
|
||||||
' command = """file="$1"; output="$2"; inline="$3"; shift 3',
|
' command = """file="$1"; output="$2"; inline="$3"; shift 3',
|
||||||
'source "$file" "$@" &&',
|
'source "$file" "$@" &&',
|
||||||
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
|
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
|
||||||
@@ -244,12 +244,12 @@ export const pythonPreloadSource: string = [
|
|||||||
' if len(output) > 0:',
|
' if len(output) > 0:',
|
||||||
' print(output)',
|
' print(output)',
|
||||||
' if task_before:',
|
' if task_before:',
|
||||||
' print("执行前置命令结束\\n")',
|
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
|
||||||
'',
|
'',
|
||||||
' except subprocess.CalledProcessError as error:',
|
' except subprocess.CalledProcessError as error:',
|
||||||
' print(f"\\ue926 run task before error: {error}")',
|
' print(f"\\ue926 run task before error: {error}")',
|
||||||
' if task_before:',
|
' if task_before:',
|
||||||
' print("执行前置命令结束\\n")',
|
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
|
||||||
' except OSError as error:',
|
' except OSError as error:',
|
||||||
' error_message = str(error)',
|
' error_message = str(error)',
|
||||||
' if "Argument list too long" not in error_message:',
|
' if "Argument list too long" not in error_message:',
|
||||||
@@ -257,11 +257,11 @@ export const pythonPreloadSource: string = [
|
|||||||
' # else:',
|
' # else:',
|
||||||
' # environment variable is too large',
|
' # environment variable is too large',
|
||||||
' if task_before:',
|
' if task_before:',
|
||||||
' print("执行前置命令结束\\n")',
|
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
|
||||||
' except Exception as error:',
|
' except Exception as error:',
|
||||||
' print(f"\\ue926 run task before error: {error}")',
|
' print(f"\\ue926 run task before error: {error}")',
|
||||||
' if task_before:',
|
' if task_before:',
|
||||||
' print("执行前置命令结束\\n")',
|
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
|
||||||
'',
|
'',
|
||||||
' import task_before',
|
' import task_before',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -11,13 +11,21 @@ import { installPanelDependencies, startPanel, stopPanel } from './operator';
|
|||||||
// The Bash image updater and local CLI must enforce the same build provenance.
|
// The Bash image updater and local CLI must enforce the same build provenance.
|
||||||
const artifacts = require('./upgradeArtifacts.cjs') as {
|
const artifacts = require('./upgradeArtifacts.cjs') as {
|
||||||
releaseBranch(env: NodeJS.ProcessEnv): string;
|
releaseBranch(env: NodeJS.ProcessEnv): string;
|
||||||
verifyUpgrade(source: string, staticRoot: string): Promise<unknown>;
|
verifyUpgrade(
|
||||||
|
source: string,
|
||||||
|
staticRoot: string,
|
||||||
|
env?: NodeJS.ProcessEnv,
|
||||||
|
): Promise<unknown>;
|
||||||
waitForHealth(
|
waitForHealth(
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
withUpgradeLock<T>(tmp: string, operation: () => Promise<T>): Promise<T>;
|
withUpgradeLock<T>(
|
||||||
|
tmp: string,
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
env?: NodeJS.ProcessEnv,
|
||||||
|
): Promise<T>;
|
||||||
stageUpgrade(options: {
|
stageUpgrade(options: {
|
||||||
root: string;
|
root: string;
|
||||||
tmp: string;
|
tmp: string;
|
||||||
@@ -30,6 +38,7 @@ const artifacts = require('./upgradeArtifacts.cjs') as {
|
|||||||
selectedUpgrade(
|
selectedUpgrade(
|
||||||
tmp: string,
|
tmp: string,
|
||||||
branch: string,
|
branch: string,
|
||||||
|
env?: NodeJS.ProcessEnv,
|
||||||
): Promise<{ source: string; static: string }>;
|
): Promise<{ source: string; static: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,7 +78,9 @@ export async function stageUpgrade(
|
|||||||
context: LocalContext,
|
context: LocalContext,
|
||||||
mirror: 'github' | 'gitee',
|
mirror: 'github' | 'gitee',
|
||||||
): Promise<{ source: string; static: string }> {
|
): Promise<{ source: string; static: string }> {
|
||||||
return artifacts.withUpgradeLock(context.paths.dir_tmp!, () =>
|
return artifacts.withUpgradeLock(
|
||||||
|
context.paths.dir_tmp!,
|
||||||
|
() =>
|
||||||
artifacts.stageUpgrade({
|
artifacts.stageUpgrade({
|
||||||
root: context.root,
|
root: context.root,
|
||||||
tmp: context.paths.dir_tmp!,
|
tmp: context.paths.dir_tmp!,
|
||||||
@@ -80,6 +91,7 @@ export async function stageUpgrade(
|
|||||||
checkedProcess(program, args, { env: context.env, capture }),
|
checkedProcess(program, args, { env: context.env, capture }),
|
||||||
install: (source) => installPanelDependencies(context, source),
|
install: (source) => installPanelDependencies(context, source),
|
||||||
}),
|
}),
|
||||||
|
context.env,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +101,7 @@ async function selectedUpgrade(
|
|||||||
return artifacts.selectedUpgrade(
|
return artifacts.selectedUpgrade(
|
||||||
context.paths.dir_tmp!,
|
context.paths.dir_tmp!,
|
||||||
releaseBranch(context),
|
releaseBranch(context),
|
||||||
|
context.env,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +272,7 @@ export async function reloadPanel(
|
|||||||
const staticRoot = selected.static;
|
const staticRoot = selected.static;
|
||||||
await validateTree(source, context.env);
|
await validateTree(source, context.env);
|
||||||
await validateTree(staticRoot, context.env);
|
await validateTree(staticRoot, context.env);
|
||||||
await artifacts.verifyUpgrade(source, staticRoot);
|
await artifacts.verifyUpgrade(source, staticRoot, context.env);
|
||||||
const entries = await fs.readdir(source);
|
const entries = await fs.readdir(source);
|
||||||
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
|
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
|
||||||
const relativeData = path.relative(context.root, context.data);
|
const relativeData = path.relative(context.root, context.data);
|
||||||
@@ -284,7 +297,9 @@ export async function reloadPanel(
|
|||||||
if (selection)
|
if (selection)
|
||||||
replacements.push({ source: selection.source, target: selection.target });
|
replacements.push({ source: selection.source, target: selection.target });
|
||||||
try {
|
try {
|
||||||
return await artifacts.withUpgradeLock(context.paths.dir_tmp!, () =>
|
return await artifacts.withUpgradeLock(
|
||||||
|
context.paths.dir_tmp!,
|
||||||
|
() =>
|
||||||
replaceAndReload(context, replacements, {
|
replaceAndReload(context, replacements, {
|
||||||
stop: stopPanel,
|
stop: stopPanel,
|
||||||
start: async (ctx) => {
|
start: async (ctx) => {
|
||||||
@@ -293,6 +308,7 @@ export async function reloadPanel(
|
|||||||
return service;
|
return service;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
context.env,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (selection)
|
if (selection)
|
||||||
|
|||||||
@@ -7,11 +7,39 @@ const { spawn } = require('node:child_process');
|
|||||||
const http = require('node:http');
|
const http = require('node:http');
|
||||||
const { setTimeout: delay } = require('node:timers/promises');
|
const { setTimeout: delay } = require('node:timers/promises');
|
||||||
|
|
||||||
|
const upgradeEnglish = {
|
||||||
|
'更新包下载及校验成功...': 'Update package downloaded and verified...',
|
||||||
|
'更新及启动检查成功...':
|
||||||
|
'Update and startup checks completed successfully...',
|
||||||
|
'更新失败: %s': 'Update failed: %s',
|
||||||
|
'构建清单无效。': 'Invalid or dirty build manifest',
|
||||||
|
'缺少构建产物: %s': 'Missing build output: %s',
|
||||||
|
'源码与构建依赖不一致。': 'Source and build lockfiles differ',
|
||||||
|
'构建包含非法文件: %s': 'Unsupported build entry: %s',
|
||||||
|
'构建文件缺失、损坏或混入旧文件。': 'Build checksum mismatch',
|
||||||
|
'已有更新或重载正在执行。': 'Another upgrade is running',
|
||||||
|
'GitHub 下载失败,切换 Gitee...': 'GitHub download failed; retrying Gitee',
|
||||||
|
'升级归档路径无效。': 'Invalid archive paths',
|
||||||
|
'升级归档包含符号链接。': 'Archive contains symlinks',
|
||||||
|
'暂存升级指针无效。': 'Invalid staged upgrade pointer',
|
||||||
|
'暂存目录无效。': 'Invalid staging directory',
|
||||||
|
'暂存升级就绪记录不匹配。': 'Invalid upgrade readiness',
|
||||||
|
'保留备份: %s': 'Retained backup: %s',
|
||||||
|
'后端启动检查失败。': 'Backend failed health check',
|
||||||
|
'保留生命周期快照: %s': 'Retained lifecycle snapshot: %s',
|
||||||
|
};
|
||||||
|
|
||||||
|
function upgradeMessage(key, env = process.env, ...values) {
|
||||||
|
const template = env.QL_LANG === 'en' ? upgradeEnglish[key] ?? key : key;
|
||||||
|
let index = 0;
|
||||||
|
return template.replace(/%s/g, () => String(values[index++] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||||
const releaseBranch = (env) =>
|
const releaseBranch = (env) =>
|
||||||
['develop', 'debian-dev'].includes(env.QL_BRANCH) ? 'develop' : 'master';
|
['develop', 'debian-dev'].includes(env.QL_BRANCH) ? 'develop' : 'master';
|
||||||
|
|
||||||
async function manifestAt(staticRoot) {
|
async function manifestAt(staticRoot, env = process.env) {
|
||||||
const manifest = JSON.parse(
|
const manifest = JSON.parse(
|
||||||
await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'),
|
await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'),
|
||||||
);
|
);
|
||||||
@@ -24,22 +52,20 @@ async function manifestAt(staticRoot) {
|
|||||||
typeof manifest.files !== 'object' ||
|
typeof manifest.files !== 'object' ||
|
||||||
Array.isArray(manifest.files)
|
Array.isArray(manifest.files)
|
||||||
)
|
)
|
||||||
throw new Error('Invalid or dirty build manifest / 构建清单无效。');
|
throw new Error(upgradeMessage('构建清单无效。', env));
|
||||||
for (const required of ['build/app.js', 'dist/index.html'])
|
for (const required of ['build/app.js', 'dist/index.html'])
|
||||||
if (!Object.hasOwn(manifest.files, required))
|
if (!Object.hasOwn(manifest.files, required))
|
||||||
throw new Error(`Missing build output / 缺少构建产物: ${required}`);
|
throw new Error(upgradeMessage('缺少构建产物: %s', env, required));
|
||||||
return manifest;
|
return manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyUpgrade(source, staticRoot) {
|
async function verifyUpgrade(source, staticRoot, env = process.env) {
|
||||||
const manifest = await manifestAt(staticRoot);
|
const manifest = await manifestAt(staticRoot, env);
|
||||||
if (
|
if (
|
||||||
hash(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))) !==
|
hash(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))) !==
|
||||||
manifest.lockfileSha256
|
manifest.lockfileSha256
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error(upgradeMessage('源码与构建依赖不一致。', env));
|
||||||
'Source and build lockfiles differ / 源码与构建依赖不一致。',
|
|
||||||
);
|
|
||||||
await fs.access(path.join(source, 'package.json'));
|
await fs.access(path.join(source, 'package.json'));
|
||||||
await fs.access(path.join(source, 'sample/config.sample.sh'));
|
await fs.access(path.join(source, 'sample/config.sample.sh'));
|
||||||
const files = {};
|
const files = {};
|
||||||
@@ -50,8 +76,7 @@ async function verifyUpgrade(source, staticRoot) {
|
|||||||
const filename = path.join(directory, entry.name);
|
const filename = path.join(directory, entry.name);
|
||||||
if (entry.isDirectory()) await visit(filename, `${name}/`);
|
if (entry.isDirectory()) await visit(filename, `${name}/`);
|
||||||
else if (entry.isFile()) files[name] = hash(await fs.readFile(filename));
|
else if (entry.isFile()) files[name] = hash(await fs.readFile(filename));
|
||||||
else
|
else throw new Error(upgradeMessage('构建包含非法文件: %s', env, name));
|
||||||
throw new Error(`Unsupported build entry / 构建包含非法文件: ${name}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await visit(staticRoot);
|
await visit(staticRoot);
|
||||||
@@ -61,13 +86,11 @@ async function verifyUpgrade(source, staticRoot) {
|
|||||||
JSON.stringify(actual) !== JSON.stringify(expected) ||
|
JSON.stringify(actual) !== JSON.stringify(expected) ||
|
||||||
actual.some((name) => files[name] !== manifest.files[name])
|
actual.some((name) => files[name] !== manifest.files[name])
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error(upgradeMessage('构建文件缺失、损坏或混入旧文件。', env));
|
||||||
'Build checksum mismatch / 构建文件缺失、损坏或混入旧文件。',
|
|
||||||
);
|
|
||||||
return manifest;
|
return manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withUpgradeLock(tmp, operation) {
|
async function withUpgradeLock(tmp, operation, env = process.env) {
|
||||||
await fs.mkdir(tmp, { recursive: true });
|
await fs.mkdir(tmp, { recursive: true });
|
||||||
const lock = path.join(tmp, 'upgrade.lock');
|
const lock = path.join(tmp, 'upgrade.lock');
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
@@ -123,9 +146,7 @@ async function withUpgradeLock(tmp, operation) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!stale)
|
if (!stale)
|
||||||
throw new Error(
|
throw new Error(upgradeMessage('已有更新或重载正在执行。', env));
|
||||||
'Another upgrade is running / 已有更新或重载正在执行。',
|
|
||||||
);
|
|
||||||
// Removing the observed unique marker is a compare-and-delete. A
|
// Removing the observed unique marker is a compare-and-delete. A
|
||||||
// contender cannot unlink the marker belonging to a replacement lock.
|
// contender cannot unlink the marker belonging to a replacement lock.
|
||||||
// Legacy pid files are supported; new owners never reuse that filename.
|
// Legacy pid files are supported; new owners never reuse that filename.
|
||||||
@@ -150,7 +171,16 @@ async function withUpgradeLock(tmp, operation) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
|
async function stageUpgrade({
|
||||||
|
root,
|
||||||
|
tmp,
|
||||||
|
env,
|
||||||
|
mirror,
|
||||||
|
fallbackMirror,
|
||||||
|
run,
|
||||||
|
install,
|
||||||
|
signal,
|
||||||
|
}) {
|
||||||
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
|
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
|
||||||
const branch = releaseBranch(env);
|
const branch = releaseBranch(env);
|
||||||
try {
|
try {
|
||||||
@@ -158,9 +188,10 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
|
|||||||
const archive = path.join(workspace, `${repository}.zip`);
|
const archive = path.join(workspace, `${repository}.zip`);
|
||||||
const url =
|
const url =
|
||||||
mirror === 'github'
|
mirror === 'github'
|
||||||
? `https://github.com/whyour/${repository}/archive/${ref}.zip`
|
? `https://codeload.github.com/whyour/${repository}/zip/${ref}`
|
||||||
: `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`;
|
: `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`;
|
||||||
await run('curl', [
|
const download = (target) =>
|
||||||
|
run('curl', [
|
||||||
'--fail',
|
'--fail',
|
||||||
'--location',
|
'--location',
|
||||||
'--proto',
|
'--proto',
|
||||||
@@ -171,10 +202,24 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
|
|||||||
'30',
|
'30',
|
||||||
'--max-time',
|
'--max-time',
|
||||||
'600',
|
'600',
|
||||||
|
'--speed-limit',
|
||||||
|
'1024',
|
||||||
|
'--speed-time',
|
||||||
|
'30',
|
||||||
'--output',
|
'--output',
|
||||||
archive,
|
archive,
|
||||||
url,
|
target,
|
||||||
]);
|
]);
|
||||||
|
try {
|
||||||
|
await download(url);
|
||||||
|
} catch (error) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if (mirror !== 'github' || fallbackMirror !== 'gitee') throw error;
|
||||||
|
console.warn(upgradeMessage('GitHub 下载失败,切换 Gitee...', env));
|
||||||
|
await download(
|
||||||
|
`https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const listing = await run('unzip', ['-Z1', archive], true);
|
const listing = await run('unzip', ['-Z1', archive], true);
|
||||||
const expected = `${repository}-${ref}`;
|
const expected = `${repository}-${ref}`;
|
||||||
const names = listing.stdout.split('\n').filter(Boolean);
|
const names = listing.stdout.split('\n').filter(Boolean);
|
||||||
@@ -189,21 +234,21 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
|
|||||||
name.split('/')[0] !== expected,
|
name.split('/')[0] !== expected,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
throw new Error('Invalid archive paths / 升级归档路径无效。');
|
throw new Error(upgradeMessage('升级归档路径无效。', env));
|
||||||
const modes = await run('unzip', ['-Z', '-l', archive], true);
|
const modes = await run('unzip', ['-Z', '-l', archive], true);
|
||||||
if (modes.stdout.split('\n').some((line) => /^l[rwx-]{9}\s/.test(line)))
|
if (modes.stdout.split('\n').some((line) => /^l[rwx-]{9}\s/.test(line)))
|
||||||
throw new Error('Archive contains symlinks / 升级归档包含符号链接。');
|
throw new Error(upgradeMessage('升级归档包含符号链接。', env));
|
||||||
await run('unzip', ['-oq', archive, '-d', workspace]);
|
await run('unzip', ['-oq', archive, '-d', workspace]);
|
||||||
return path.join(workspace, expected);
|
return path.join(workspace, expected);
|
||||||
}
|
}
|
||||||
// Static branches are mutable snapshots. Their manifest pins the exact
|
// Static branches are mutable snapshots. Their manifest pins the exact
|
||||||
// source revision, avoiding a race between two independent branch reads.
|
// source revision, avoiding a race between two independent branch reads.
|
||||||
const staticRoot = await extract('qinglong-static', branch);
|
const staticRoot = await extract('qinglong-static', branch);
|
||||||
const manifest = await manifestAt(staticRoot);
|
const manifest = await manifestAt(staticRoot, env);
|
||||||
const downloadedSource = await extract('qinglong', manifest.sourceCommit);
|
const downloadedSource = await extract('qinglong', manifest.sourceCommit);
|
||||||
const source = path.join(workspace, `qinglong-${branch}`);
|
const source = path.join(workspace, `qinglong-${branch}`);
|
||||||
await fs.rename(downloadedSource, source);
|
await fs.rename(downloadedSource, source);
|
||||||
await verifyUpgrade(source, staticRoot);
|
await verifyUpgrade(source, staticRoot, env);
|
||||||
let dependenciesChanged = false;
|
let dependenciesChanged = false;
|
||||||
for (const name of ['package.json', 'pnpm-lock.yaml']) {
|
for (const name of ['package.json', 'pnpm-lock.yaml']) {
|
||||||
const current = await fs
|
const current = await fs
|
||||||
@@ -222,7 +267,7 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
|
|||||||
if (dependenciesChanged) {
|
if (dependenciesChanged) {
|
||||||
await install(source);
|
await install(source);
|
||||||
await fs.access(path.join(source, 'node_modules'));
|
await fs.access(path.join(source, 'node_modules'));
|
||||||
await verifyUpgrade(source, staticRoot);
|
await verifyUpgrade(source, staticRoot, env);
|
||||||
}
|
}
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const selected = { source, static: staticRoot };
|
const selected = { source, static: staticRoot };
|
||||||
@@ -269,7 +314,7 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectedUpgrade(tmp, branch) {
|
async function selectedUpgrade(tmp, branch, env = process.env) {
|
||||||
const pointer = JSON.parse(
|
const pointer = JSON.parse(
|
||||||
await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'),
|
await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'),
|
||||||
);
|
);
|
||||||
@@ -277,7 +322,7 @@ async function selectedUpgrade(tmp, branch) {
|
|||||||
typeof pointer.directory !== 'string' ||
|
typeof pointer.directory !== 'string' ||
|
||||||
!/^upgrade-[A-Za-z0-9_-]+$/.test(pointer.directory)
|
!/^upgrade-[A-Za-z0-9_-]+$/.test(pointer.directory)
|
||||||
)
|
)
|
||||||
throw new Error('Invalid staged upgrade pointer / 暂存升级指针无效。');
|
throw new Error(upgradeMessage('暂存升级指针无效。', env));
|
||||||
const workspace = path.join(tmp, pointer.directory);
|
const workspace = path.join(tmp, pointer.directory);
|
||||||
const selected = {
|
const selected = {
|
||||||
source: path.join(workspace, `qinglong-${branch}`),
|
source: path.join(workspace, `qinglong-${branch}`),
|
||||||
@@ -286,22 +331,22 @@ async function selectedUpgrade(tmp, branch) {
|
|||||||
for (const directory of [workspace, selected.source, selected.static]) {
|
for (const directory of [workspace, selected.source, selected.static]) {
|
||||||
const stat = await fs.lstat(directory);
|
const stat = await fs.lstat(directory);
|
||||||
if (!stat.isDirectory() || stat.isSymbolicLink())
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
||||||
throw new Error('Invalid staging directory / 暂存目录无效。');
|
throw new Error(upgradeMessage('暂存目录无效。', env));
|
||||||
}
|
}
|
||||||
const ready = JSON.parse(
|
const ready = JSON.parse(
|
||||||
await fs.readFile(path.join(workspace, 'ready.json'), 'utf8'),
|
await fs.readFile(path.join(workspace, 'ready.json'), 'utf8'),
|
||||||
);
|
);
|
||||||
if (ready.source !== selected.source || ready.static !== selected.static)
|
if (ready.source !== selected.source || ready.static !== selected.static)
|
||||||
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
|
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
|
||||||
const manifest = await verifyUpgrade(selected.source, selected.static);
|
const manifest = await verifyUpgrade(selected.source, selected.static, env);
|
||||||
if (ready.sourceCommit !== manifest.sourceCommit)
|
if (ready.sourceCommit !== manifest.sourceCommit)
|
||||||
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
|
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
|
||||||
if (ready.dependenciesChanged)
|
if (ready.dependenciesChanged)
|
||||||
await fs.access(path.join(selected.source, 'node_modules'));
|
await fs.access(path.join(selected.source, 'node_modules'));
|
||||||
return selected;
|
return selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replaceAndReload(replacements, lifecycle) {
|
async function replaceAndReload(replacements, lifecycle, env = process.env) {
|
||||||
const changed = [];
|
const changed = [];
|
||||||
let attemptedStart = false;
|
let attemptedStart = false;
|
||||||
lifecycle.signal?.throwIfAborted();
|
lifecycle.signal?.throwIfAborted();
|
||||||
@@ -373,7 +418,7 @@ async function replaceAndReload(replacements, lifecycle) {
|
|||||||
await fs
|
await fs
|
||||||
.rm(item.backup, { recursive: true, force: true })
|
.rm(item.backup, { recursive: true, force: true })
|
||||||
.catch(() =>
|
.catch(() =>
|
||||||
console.error(`Retained backup / 保留备份: ${item.backup}`),
|
console.error(upgradeMessage('保留备份: %s', env, item.backup)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,7 +431,7 @@ async function reloadSystem({
|
|||||||
env,
|
env,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
}) {
|
}) {
|
||||||
const selected = await selectedUpgrade(tmp, releaseBranch(env));
|
const selected = await selectedUpgrade(tmp, releaseBranch(env), env);
|
||||||
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
|
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
|
||||||
const relative = path.relative(root, data);
|
const relative = path.relative(root, data);
|
||||||
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative))
|
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative))
|
||||||
@@ -404,7 +449,7 @@ async function reloadSystem({
|
|||||||
target: path.join(config, 'config.sample.sh'),
|
target: path.join(config, 'config.sample.sh'),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await replaceAndReload(replacements, lifecycle);
|
await replaceAndReload(replacements, lifecycle, env);
|
||||||
// Cleanup after a committed installation must not report a failed update.
|
// Cleanup after a committed installation must not report a failed update.
|
||||||
await fs
|
await fs
|
||||||
.rm(path.join(tmp, `upgrade-ready-${releaseBranch(env)}.json`), {
|
.rm(path.join(tmp, `upgrade-ready-${releaseBranch(env)}.json`), {
|
||||||
@@ -472,7 +517,7 @@ async function waitForHealth(env, timeout = 60000, signal) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error('Backend failed health check / 后端启动检查失败。');
|
throw new Error(upgradeMessage('后端启动检查失败。', env));
|
||||||
}
|
}
|
||||||
|
|
||||||
function runProcess(env, program, args, capture = false, signal) {
|
function runProcess(env, program, args, capture = false, signal) {
|
||||||
@@ -575,9 +620,7 @@ esac
|
|||||||
await fs
|
await fs
|
||||||
.rm(directory, { recursive: true, force: true })
|
.rm(directory, { recursive: true, force: true })
|
||||||
.catch(() =>
|
.catch(() =>
|
||||||
console.error(
|
console.error(upgradeMessage('保留生命周期快照: %s', env, directory)),
|
||||||
`Retained lifecycle snapshot / 保留生命周期快照: ${directory}`,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -595,7 +638,9 @@ async function main() {
|
|||||||
);
|
);
|
||||||
const run = (program, args, capture) =>
|
const run = (program, args, capture) =>
|
||||||
runProcess(env, program, args, capture, controller.signal);
|
runProcess(env, program, args, capture, controller.signal);
|
||||||
await withUpgradeLock(tmp, () =>
|
await withUpgradeLock(
|
||||||
|
tmp,
|
||||||
|
() =>
|
||||||
withLifecycleHooks(root, tmp, env, async (hook) => {
|
withLifecycleHooks(root, tmp, env, async (hook) => {
|
||||||
const lifecycle = {
|
const lifecycle = {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
@@ -615,11 +660,14 @@ async function main() {
|
|||||||
tmp,
|
tmp,
|
||||||
env,
|
env,
|
||||||
mirror: process.argv[3],
|
mirror: process.argv[3],
|
||||||
|
// The Bash updater chooses its source automatically. An explicitly
|
||||||
|
// selected CLI mirror does not opt into this network-only failover.
|
||||||
|
fallbackMirror: 'gitee',
|
||||||
run,
|
run,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
install: (source) => hook('install', [source], controller.signal),
|
install: (source) => hook('install', [source], controller.signal),
|
||||||
});
|
});
|
||||||
console.log('更新包下载及校验成功...');
|
console.log(upgradeMessage('更新包下载及校验成功...', env));
|
||||||
if (process.argv[4] !== 'true') return;
|
if (process.argv[4] !== 'true') return;
|
||||||
} else if (process.argv[2] !== 'reload')
|
} else if (process.argv[2] !== 'reload')
|
||||||
throw new Error('Unknown upgrade command.');
|
throw new Error('Unknown upgrade command.');
|
||||||
@@ -632,12 +680,14 @@ async function main() {
|
|||||||
config: env.dir_config || path.join(root, 'data/config'),
|
config: env.dir_config || path.join(root, 'data/config'),
|
||||||
lifecycle,
|
lifecycle,
|
||||||
});
|
});
|
||||||
console.log('更新及启动检查成功...');
|
console.log(upgradeMessage('更新及启动检查成功...', env));
|
||||||
}),
|
}),
|
||||||
|
env,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
upgradeMessage,
|
||||||
releaseBranch,
|
releaseBranch,
|
||||||
verifyUpgrade,
|
verifyUpgrade,
|
||||||
withUpgradeLock,
|
withUpgradeLock,
|
||||||
@@ -652,6 +702,6 @@ module.exports = {
|
|||||||
};
|
};
|
||||||
if (require.main === module)
|
if (require.main === module)
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error(`更新失败: ${error.message}`);
|
console.error(upgradeMessage('更新失败: %s', process.env, error.message));
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -214,8 +214,13 @@ async function reconcile(
|
|||||||
if (added || removed) {
|
if (added || removed) {
|
||||||
try {
|
try {
|
||||||
await api.call('system/notify', 'PUT', {
|
await api.call('system/notify', 'PUT', {
|
||||||
title: `${namespace} 订阅同步`,
|
title: translate(context.env, '%s 订阅同步', namespace),
|
||||||
content: `新增 ${added} 个任务,删除 ${removed} 个任务`,
|
content: translate(
|
||||||
|
context.env,
|
||||||
|
'新增 %s 个任务,删除 %s 个任务',
|
||||||
|
added,
|
||||||
|
removed,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
operationOutput(
|
operationOutput(
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
// Migrated from shell/lang/en.sh; the original remains for differential evaluation.
|
// Migrated from shell/lang/en.sh; the original remains for differential evaluation.
|
||||||
export const english: Record<string, string> = {
|
export const english: Record<string, string> = {
|
||||||
'需要双因素验证,请调用 user two-factor-login。': 'Two-factor authentication required; use user two-factor-login.',
|
'%s 订阅同步': '%s subscription sync',
|
||||||
'QL_URL 和 QL_ACCESS_TOKEN 必须同时提供。': 'Provide QL_URL and QL_ACCESS_TOKEN together.',
|
'新增 %s 个任务,删除 %s 个任务': 'Added %s tasks, removed %s tasks',
|
||||||
'接口返回 JSON,未保存下载文件。': 'The endpoint returned JSON; no download was saved.',
|
'需要双因素验证,请调用 user two-factor-login。':
|
||||||
|
'Two-factor authentication required; use user two-factor-login.',
|
||||||
|
'QL_URL 和 QL_ACCESS_TOKEN 必须同时提供。':
|
||||||
|
'Provide QL_URL and QL_ACCESS_TOKEN together.',
|
||||||
|
'接口返回 JSON,未保存下载文件。':
|
||||||
|
'The endpoint returned JSON; no download was saved.',
|
||||||
'已选择的 CLI 安装路径必须为绝对路径。':
|
'已选择的 CLI 安装路径必须为绝对路径。':
|
||||||
'The selected CLI installation path must be absolute.',
|
'The selected CLI installation path must be absolute.',
|
||||||
'保留 CLI 入口仅支持已确认的 2.x 升级载荷。':
|
'保留 CLI 入口仅支持已确认的 2.x 升级载荷。':
|
||||||
@@ -152,9 +157,12 @@ export const english: Record<string, string> = {
|
|||||||
'应用凭据和 %s 权限': 'application credentials and %s permission',
|
'应用凭据和 %s 权限': 'application credentials and %s permission',
|
||||||
未知: 'unknown',
|
未知: 'unknown',
|
||||||
'请检查%s。': 'Check %s.',
|
'请检查%s。': 'Check %s.',
|
||||||
'资源冲突,请检查重复的名称或值。': 'Resource conflict; check for duplicate names or values.',
|
'资源冲突,请检查重复的名称或值。':
|
||||||
'服务端错误,请检查服务状态和日志。': 'Server error; check service status and logs.',
|
'Resource conflict; check for duplicate names or values.',
|
||||||
'请求未成功,请检查请求参数和服务日志。': 'Request unsuccessful; check request parameters and service logs.',
|
'服务端错误,请检查服务状态和日志。':
|
||||||
|
'Server error; check service status and logs.',
|
||||||
|
'请求未成功,请检查请求参数和服务日志。':
|
||||||
|
'Request unsuccessful; check request parameters and service logs.',
|
||||||
'API 请求失败(%s %s)。%s%s': 'API request failed (%s %s). %s%s',
|
'API 请求失败(%s %s)。%s%s': 'API request failed (%s %s). %s%s',
|
||||||
|
|
||||||
' 执行结果可能未知,请先检查%s状态再考虑重试。':
|
' 执行结果可能未知,请先检查%s状态再考虑重试。':
|
||||||
@@ -169,8 +177,7 @@ export const english: Record<string, string> = {
|
|||||||
'API request rejected (code %s). Check %s.',
|
'API request rejected (code %s). Check %s.',
|
||||||
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。':
|
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。':
|
||||||
'CLI config must be an owned private regular file (0600).',
|
'CLI config must be an owned private regular file (0600).',
|
||||||
'CLI 配置无效,请运行 ql login。':
|
'CLI 配置无效,请运行 ql login。': 'Invalid CLI config. Run ql login.',
|
||||||
'Invalid CLI config. Run ql login.',
|
|
||||||
'尚未登录,请运行 ql login。': 'Not logged in. Run ql login.',
|
'尚未登录,请运行 ql login。': 'Not logged in. Run ql login.',
|
||||||
'无法读取 CLI 配置,请检查文件权限并运行 ql login。':
|
'无法读取 CLI 配置,请检查文件权限并运行 ql login。':
|
||||||
'Cannot read CLI config. Check file permissions and run ql login.',
|
'Cannot read CLI config. Check file permissions and run ql login.',
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ test('upgrade staging extracts both archives before publishing readiness and rej
|
|||||||
['-qry', path.join(archives, 'qinglong.zip'), `qinglong-${sourceCommit}`],
|
['-qry', path.join(archives, 'qinglong.zip'), `qinglong-${sourceCommit}`],
|
||||||
{ cwd: payloads },
|
{ cwd: payloads },
|
||||||
);
|
);
|
||||||
await assert.rejects(stageUpgrade(ctx, 'github'), /symlinks/);
|
await assert.rejects(stageUpgrade(ctx, 'github'), /symlinks|符号链接/);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
(await fs.readdir(ctx.paths.dir_tmp)).sort(),
|
(await fs.readdir(ctx.paths.dir_tmp)).sort(),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ test(
|
|||||||
assert.notEqual(before.dev, (await fs.stat(root)).dev);
|
assert.notEqual(before.dev, (await fs.stat(root)).dev);
|
||||||
const context = createContext(
|
const context = createContext(
|
||||||
{ root, 'data-dir': data },
|
{ root, 'data-dir': data },
|
||||||
{ PATH: '/nonexistent' },
|
{ PATH: '/nonexistent', QL_LANG: 'en' },
|
||||||
);
|
);
|
||||||
t.after(async () => {
|
t.after(async () => {
|
||||||
await stopPanel(context);
|
await stopPanel(context);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ test(
|
|||||||
[
|
[
|
||||||
'--test',
|
'--test',
|
||||||
'--test-reporter=tap',
|
'--test-reporter=tap',
|
||||||
path.resolve(__dirname, '../preload.test.cjs'),
|
path.resolve(__dirname, '../internal/preload.test.cjs'),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
env,
|
env,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ test(
|
|||||||
context.paths.dir_tmp,
|
context.paths.dir_tmp,
|
||||||
);
|
);
|
||||||
installations.push(directory);
|
installations.push(directory);
|
||||||
|
await fs.mkdir(path.join(directory, 'node_modules'), { recursive: true });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -70,9 +71,16 @@ test(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
assert.equal(pointer.directory, path.basename(workspace));
|
assert.equal(pointer.directory, path.basename(workspace));
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
await fs.readFile(path.join(staged.static, 'build-info.json'), 'utf8'),
|
||||||
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
JSON.parse(await fs.readFile(path.join(workspace, 'ready.json'), 'utf8')),
|
JSON.parse(await fs.readFile(path.join(workspace, 'ready.json'), 'utf8')),
|
||||||
staged,
|
{
|
||||||
|
...staged,
|
||||||
|
sourceCommit: manifest.sourceCommit,
|
||||||
|
dependenciesChanged: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await fs.readFile(path.join(root, 'package.json'), 'utf8'),
|
await fs.readFile(path.join(root, 'package.json'), 'utf8'),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ RUN set -x && \
|
|||||||
apt-get update && \
|
apt-get update && \
|
||||||
apt-get install --no-install-recommends -y libatomic1 && \
|
apt-get install --no-install-recommends -y libatomic1 && \
|
||||||
npm i -g pnpm@8.3.1 && \
|
npm i -g pnpm@8.3.1 && \
|
||||||
|
test "$(pnpm --version)" = "8.3.1" && \
|
||||||
cd /tmp/build && \
|
cd /tmp/build && \
|
||||||
pnpm install --prod --frozen-lockfile
|
pnpm install --prod --frozen-lockfile
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ RUN set -x && \
|
|||||||
git config --global user.name "qinglong" && \
|
git config --global user.name "qinglong" && \
|
||||||
git config --global http.postBuffer 524288000 && \
|
git config --global http.postBuffer 524288000 && \
|
||||||
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
|
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
|
||||||
|
test "$(pnpm --version)" = "8.3.1" && \
|
||||||
npm cache clean --force && \
|
npm cache clean --force && \
|
||||||
rm -rf /root/.cache && \
|
rm -rf /root/.cache && \
|
||||||
rm -rf /root/.npm && \
|
rm -rf /root/.npm && \
|
||||||
@@ -114,7 +116,8 @@ USER root
|
|||||||
|
|
||||||
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
|
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
|
||||||
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
|
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
|
||||||
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
|
&& chmod +x /usr/local/bin/task /usr/local/bin/ql \
|
||||||
|
&& test "$(pnpm --version)" = "8.3.1"
|
||||||
|
|
||||||
WORKDIR ${QL_DIR}
|
WORKDIR ${QL_DIR}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -236,7 +236,7 @@ find_cron_api() {
|
|||||||
|
|
||||||
update_auth_config() {
|
update_auth_config() {
|
||||||
local body="$1"
|
local body="$1"
|
||||||
local tip="$2"
|
local tip=$(t "$2")
|
||||||
local currentTimeStamp=$(date +%s)
|
local currentTimeStamp=$(date +%s)
|
||||||
local api=$(ql_api_request \
|
local api=$(ql_api_request \
|
||||||
"http://localhost:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \
|
"http://localhost:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \
|
||||||
|
|||||||
@@ -108,4 +108,42 @@ declare -gA LANG_MESSAGES=(
|
|||||||
['暂不支持此系统部署 %s']='Unsupported system for deployment: %s'
|
['暂不支持此系统部署 %s']='Unsupported system for deployment: %s'
|
||||||
# update.sh
|
# update.sh
|
||||||
['命令输入错误...\n']='Invalid command...\n'
|
['命令输入错误...\n']='Invalid command...\n'
|
||||||
|
['%s 删除任务%s']='%s removed tasks%s'
|
||||||
|
['%s 新增任务']='%s added tasks'
|
||||||
|
['新增任务通知']='New task notification'
|
||||||
|
['失效']='obsolete'
|
||||||
|
['新']='new'
|
||||||
|
['%s 命令使用方法:']='Usage: %s'
|
||||||
|
['更新并重启青龙']='Update and restart QingLong'
|
||||||
|
['运行自定义脚本']='Run custom scripts'
|
||||||
|
['更新单个脚本文件']='Update a script'
|
||||||
|
['更新单个仓库的脚本']='Update scripts from a repository'
|
||||||
|
['删除旧日志']='Delete old logs'
|
||||||
|
['启动tg-bot']='Start tg-bot'
|
||||||
|
['检测青龙环境并修复']='Check and repair the QingLong environment'
|
||||||
|
['查看青龙运行日志']='View QingLong service logs'
|
||||||
|
['重置登录错误次数']='Reset failed login attempts'
|
||||||
|
['禁用两步登录']='Disable two-factor login'
|
||||||
|
['修改登录密码']='Change login password'
|
||||||
|
['修改登录用户名']='Change login username'
|
||||||
|
['禁用两步验证']='Disable two-factor authentication'
|
||||||
|
['重置密码']='Reset password'
|
||||||
|
['重置用户名']='Reset username'
|
||||||
|
[', 任务状态更新失败(%s)']=', task status update failed (%s)'
|
||||||
|
[', 状态更新失败(%s)']=', status update failed (%s)'
|
||||||
|
['<未识别出活动名称>']='<Unrecognized task name>'
|
||||||
|
['task命令运行本程序自动添加进crontab的脚本,需要输入脚本的绝对路径或去掉 “%s/” 目录后的相对路径(定时任务中请写作相对路径),用法为:']='task runs scripts registered in crontab. Use an absolute path or a path relative to %s/ (use relative paths in scheduled tasks). Usage:'
|
||||||
|
['1.%s <file_name> # 依次执行,如果设置了随机延迟,将随机延迟一定秒数']='1.%s <file_name> # Run sequentially, with the configured random delay'
|
||||||
|
['2.%s <file_name> now # 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日志,同时记录在日志文件中']='2.%s <file_name> now # Run immediately, displaying and saving logs'
|
||||||
|
['3.%s <file_name> conc <环境变量名称> <账号编号,空格分隔>(可选的) # 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日志,直接记录在日志文件中,且可指定账号执行']='3.%s <file_name> conc <variable> [account-numbers...] # Run selected accounts concurrently, saving logs'
|
||||||
|
['4.%s <file_name> desi <环境变量名称> <账号编号,空格分隔> # 指定账号执行,无论是否设置了随机延迟,均立即运行']='4.%s <file_name> desi <variable> <account-numbers...> # Run selected accounts immediately'
|
||||||
|
['\n当前有以下脚本可以运行:']='\nAvailable scripts:'
|
||||||
|
['\n暂无脚本可以执行']='\nNo scripts available'
|
||||||
|
['🚀 1. 检测配置文件...']='🚀 1. Checking configuration...'
|
||||||
|
['🔄 2. 启动 nginx...']='🔄 2. Starting nginx...'
|
||||||
|
['⚙️ 3. 启动 pm2 服务...']='⚙️ 3. Starting pm2 services...'
|
||||||
|
['🤖 4. 启动 bot...']='🤖 4. Starting bot...'
|
||||||
|
['🛠️ 5. 执行自定义脚本...']='🛠️ 5. Running custom scripts...'
|
||||||
|
['🎉 启动成功!']='🎉 Started successfully!'
|
||||||
|
['\n## 执行结束... %s 耗时 %s 秒']='\n## Execution finished... %s elapsed %s seconds'
|
||||||
)
|
)
|
||||||
|
|||||||
+8
-8
@@ -45,7 +45,7 @@ gen_array_scripts() {
|
|||||||
let i++
|
let i++
|
||||||
array_scripts[i]=$file
|
array_scripts[i]=$file
|
||||||
array_scripts_name[i]=$(grep -- "new Env" "$file" | awk -F '[(]' '{print $2}' | awk -F '[)]' '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1)
|
array_scripts_name[i]=$(grep -- "new Env" "$file" | awk -F '[(]' '{print $2}' | awk -F '[)]' '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1)
|
||||||
[[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="<未识别出活动名称>"
|
[[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="$(t '<未识别出活动名称>')"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
cd "$dir_current" || return $?
|
cd "$dir_current" || return $?
|
||||||
@@ -54,18 +54,18 @@ gen_array_scripts() {
|
|||||||
## 使用说明
|
## 使用说明
|
||||||
usage() {
|
usage() {
|
||||||
gen_array_scripts
|
gen_array_scripts
|
||||||
echo -e "task命令运行本程序自动添加进crontab的脚本,需要输入脚本的绝对路径或去掉 “$dir_scripts/” 目录后的相对路径(定时任务中请写作相对路径),用法为:"
|
t 'task命令运行本程序自动添加进crontab的脚本,需要输入脚本的绝对路径或去掉 “%s/” 目录后的相对路径(定时任务中请写作相对路径),用法为:' "$dir_scripts"
|
||||||
echo -e "1.$cmd_task <file_name> # 依次执行,如果设置了随机延迟,将随机延迟一定秒数"
|
t '1.%s <file_name> # 依次执行,如果设置了随机延迟,将随机延迟一定秒数' "$cmd_task"
|
||||||
echo -e "2.$cmd_task <file_name> now # 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日志,同时记录在日志文件中"
|
t '2.%s <file_name> now # 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日志,同时记录在日志文件中' "$cmd_task"
|
||||||
echo -e "3.$cmd_task <file_name> conc <环境变量名称> <账号编号,空格分隔>(可选的) # 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日志,直接记录在日志文件中,且可指定账号执行"
|
t '3.%s <file_name> conc <环境变量名称> <账号编号,空格分隔>(可选的) # 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日志,直接记录在日志文件中,且可指定账号执行' "$cmd_task"
|
||||||
echo -e "4.$cmd_task <file_name> desi <环境变量名称> <账号编号,空格分隔> # 指定账号执行,无论是否设置了随机延迟,均立即运行"
|
t '4.%s <file_name> desi <环境变量名称> <账号编号,空格分隔> # 指定账号执行,无论是否设置了随机延迟,均立即运行' "$cmd_task"
|
||||||
if [[ ${#array_scripts[*]} -gt 0 ]]; then
|
if [[ ${#array_scripts[*]} -gt 0 ]]; then
|
||||||
echo -e "\n当前有以下脚本可以运行:"
|
t '\n当前有以下脚本可以运行:'
|
||||||
for ((i = 0; i < ${#array_scripts[*]}; i++)); do
|
for ((i = 0; i < ${#array_scripts[*]}; i++)); do
|
||||||
echo -e "$(($i + 1)). ${array_scripts_name[i]}:${array_scripts[i]}"
|
echo -e "$(($i + 1)). ${array_scripts_name[i]}:${array_scripts[i]}"
|
||||||
done
|
done
|
||||||
else
|
else
|
||||||
echo -e "\n暂无脚本可以执行"
|
t '\n暂无脚本可以执行'
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function run() {
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
if (task_before) {
|
if (task_before) {
|
||||||
console.log('执行前置命令\n');
|
console.log(process.env.QL_LANG === 'en' ? 'Executing before command\n' : '执行前置命令\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = execSync(commands.join(' && '), {
|
const res = execSync(commands.join(' && '), {
|
||||||
@@ -122,7 +122,7 @@ function run() {
|
|||||||
console.log(output);
|
console.log(output);
|
||||||
}
|
}
|
||||||
if (task_before) {
|
if (task_before) {
|
||||||
console.log('执行前置命令结束\n');
|
console.log(process.env.QL_LANG === 'en' ? 'Before command finished\n' : '执行前置命令结束\n');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!error.message.includes('spawnSync /bin/bash E2BIG')) {
|
if (!error.message.includes('spawnSync /bin/bash E2BIG')) {
|
||||||
@@ -131,7 +131,7 @@ function run() {
|
|||||||
// environment variable is too large
|
// environment variable is too large
|
||||||
}
|
}
|
||||||
if (task_before) {
|
if (task_before) {
|
||||||
console.log('执行前置命令结束\n');
|
console.log(process.env.QL_LANG === 'en' ? 'Before command finished\n' : '执行前置命令结束\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ def run():
|
|||||||
if task_before:
|
if task_before:
|
||||||
escaped_task_before = task_before.replace('"', '\\"').replace("$", "\\$")
|
escaped_task_before = task_before.replace('"', '\\"').replace("$", "\\$")
|
||||||
commands.append(f"eval '{escaped_task_before}'")
|
commands.append(f"eval '{escaped_task_before}'")
|
||||||
print("执行前置命令\n")
|
print("Executing before command\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令\n")
|
||||||
|
|
||||||
commands.append(f"echo -e '{split_str}'")
|
commands.append(f"echo -e '{split_str}'")
|
||||||
|
|
||||||
@@ -88,12 +88,12 @@ def run():
|
|||||||
if len(output) > 0:
|
if len(output) > 0:
|
||||||
print(output)
|
print(output)
|
||||||
if task_before:
|
if task_before:
|
||||||
print("执行前置命令结束\n")
|
print("Before command finished\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\n")
|
||||||
|
|
||||||
except subprocess.CalledProcessError as error:
|
except subprocess.CalledProcessError as error:
|
||||||
print(f"\ue926 run task before error: {error}")
|
print(f"\ue926 run task before error: {error}")
|
||||||
if task_before:
|
if task_before:
|
||||||
print("执行前置命令结束\n")
|
print("Before command finished\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\n")
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
error_message = str(error)
|
error_message = str(error)
|
||||||
if "Argument list too long" not in error_message:
|
if "Argument list too long" not in error_message:
|
||||||
@@ -101,11 +101,11 @@ def run():
|
|||||||
# else:
|
# else:
|
||||||
# environment variable is too large
|
# environment variable is too large
|
||||||
if task_before:
|
if task_before:
|
||||||
print("执行前置命令结束\n")
|
print("Before command finished\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\n")
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
print(f"\ue926 run task before error: {error}")
|
print(f"\ue926 run task before error: {error}")
|
||||||
if task_before:
|
if task_before:
|
||||||
print("执行前置命令结束\n")
|
print("Before command finished\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\n")
|
||||||
|
|
||||||
import task_before
|
import task_before
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -395,7 +395,7 @@ handle_task_start() {
|
|||||||
if [[ $ID ]]; then
|
if [[ $ID ]]; then
|
||||||
local error=$(update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp")
|
local error=$(update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp")
|
||||||
if [[ $error ]]; then
|
if [[ $error ]]; then
|
||||||
error_message=", 任务状态更新失败(${error})"
|
error_message=$(t ', 任务状态更新失败(%s)' "$error")
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
t '## 开始执行... %s\n' "${begin_time}${error_message}"
|
t '## 开始执行... %s\n' "${begin_time}${error_message}"
|
||||||
@@ -437,7 +437,7 @@ handle_task_end() {
|
|||||||
if [[ $ID ]]; then
|
if [[ $ID ]]; then
|
||||||
local error=$(update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time" "$exit_code")
|
local error=$(update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time" "$exit_code")
|
||||||
if [[ $error ]]; then
|
if [[ $error ]]; then
|
||||||
error_message=", 状态更新失败(${error})"
|
error_message=$(t ', 状态更新失败(%s)' "$error")
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -46,28 +46,28 @@ log_with_style() {
|
|||||||
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
|
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
|
||||||
}
|
}
|
||||||
|
|
||||||
log_with_style "INFO" "🚀 1. 检测配置文件..."
|
|
||||||
import_config "$@"
|
import_config "$@"
|
||||||
|
log_with_style "INFO" "$(t '🚀 1. 检测配置文件...')"
|
||||||
make_dir /etc/nginx/conf.d
|
make_dir /etc/nginx/conf.d
|
||||||
make_dir /run/nginx
|
make_dir /run/nginx
|
||||||
fix_config
|
fix_config
|
||||||
|
|
||||||
pm2 l &>/dev/null
|
pm2 l &>/dev/null
|
||||||
|
|
||||||
log_with_style "INFO" "🔄 2. 启动 nginx..."
|
log_with_style "INFO" "$(t '🔄 2. 启动 nginx...')"
|
||||||
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
|
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
|
||||||
|
|
||||||
log_with_style "INFO" "⚙️ 3. 启动 pm2 服务..."
|
log_with_style "INFO" "$(t '⚙️ 3. 启动 pm2 服务...')"
|
||||||
reload_pm2
|
reload_pm2
|
||||||
|
|
||||||
if [[ $command != "reload" ]]; then
|
if [[ $command != "reload" ]]; then
|
||||||
if [[ $AutoStartBot == true ]]; then
|
if [[ $AutoStartBot == true ]]; then
|
||||||
log_with_style "INFO" "🤖 4. 启动 bot..."
|
log_with_style "INFO" "$(t '🤖 4. 启动 bot...')"
|
||||||
nohup ql bot >"$dir_log/bot.log" 2>&1 &
|
nohup ql bot >"$dir_log/bot.log" 2>&1 &
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $EnableExtraShell == true ]]; then
|
if [[ $EnableExtraShell == true ]]; then
|
||||||
log_with_style "INFO" "🛠️ 5. 执行自定义脚本..."
|
log_with_style "INFO" "$(t '🛠️ 5. 执行自定义脚本...')"
|
||||||
nohup ql extra >"$dir_log/extra.log" 2>&1 &
|
nohup ql extra >"$dir_log/extra.log" 2>&1 &
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -75,4 +75,4 @@ if [[ $command != "reload" ]]; then
|
|||||||
pm2 save
|
pm2 save
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_with_style "SUCCESS" "🎉 启动成功!"
|
log_with_style "SUCCESS" "$(t '🎉 启动成功!')"
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ set -x
|
|||||||
startup_message() {
|
startup_message() {
|
||||||
local format="$1"
|
local format="$1"
|
||||||
shift
|
shift
|
||||||
|
if [[ ${QL_LANG:-zh} == en ]]; then
|
||||||
|
case "$format" in
|
||||||
|
'未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装') format='Module not found. Run: npm i -g @whyour/qinglong' ;;
|
||||||
|
'请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务') format='Set export QL_DIR=%s in your system environment, then run qinglong to start the service' ;;
|
||||||
|
'QL_DIR 必须为绝对路径,例如 /ql') format='QL_DIR must be an absolute path, e.g. /ql' ;;
|
||||||
|
'请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量') format='Set QL_DATA_DIR to an absolute path ending in /data, e.g. /ql/data, in your system environment' ;;
|
||||||
|
'QL_DATA_DIR 必须为以 /data 结尾的绝对路径,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中') format='QL_DATA_DIR must be an absolute path ending in /data, e.g. /ql/data. Move existing data into that directory' ;;
|
||||||
|
'暂不支持此系统部署 %s') format='Unsupported system for deployment: %s' ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
printf -- "$format\n" "$@" >&2
|
printf -- "$format\n" "$@" >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-19
@@ -31,7 +31,7 @@ diff_cron() {
|
|||||||
## 输出是否有新的或失效的定时任务,$1:新的或失效的任务清单文件路径,$2:新/失效
|
## 输出是否有新的或失效的定时任务,$1:新的或失效的任务清单文件路径,$2:新/失效
|
||||||
output_list_add_drop() {
|
output_list_add_drop() {
|
||||||
local list=$1
|
local list=$1
|
||||||
local type=$2
|
local type=$(t "$2")
|
||||||
if [[ -s $list ]]; then
|
if [[ -s $list ]]; then
|
||||||
t '检测到有%s的定时任务:' "$type"
|
t '检测到有%s的定时任务:' "$type"
|
||||||
cat $list
|
cat $list
|
||||||
@@ -67,7 +67,7 @@ del_cron() {
|
|||||||
done
|
done
|
||||||
if [[ $ids ]]; then
|
if [[ $ids ]]; then
|
||||||
result=$(del_cron_api "$ids")
|
result=$(del_cron_api "$ids")
|
||||||
notify_api "$path 删除任务${result}" "$detail"
|
notify_api "$(t '%s 删除任务%s' "$path" "$result")" "$detail"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ add_cron() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
notify_api "$path 新增任务" "$detail"
|
notify_api "$(t '%s 新增任务' "$path")" "$detail"
|
||||||
}
|
}
|
||||||
|
|
||||||
## 更新仓库
|
## 更新仓库
|
||||||
@@ -199,7 +199,7 @@ update_raw() {
|
|||||||
cron_line=${cron_line//$'\r'/}
|
cron_line=${cron_line//$'\r'/}
|
||||||
result=$(add_cron_api "$cron_line" "${cmd_task} ${filename}" "$cron_name" "$SUB_ID")
|
result=$(add_cron_api "$cron_line" "${cmd_task} ${filename}" "$cron_name" "$SUB_ID")
|
||||||
echo -e "$result\n"
|
echo -e "$result\n"
|
||||||
notify_api "新增任务通知" "\n$result"
|
notify_api "$(t '新增任务通知')" "\n$result"
|
||||||
# update_cron_api "$cron_line:$cmd_task $filename:$cron_name:$cron_id"
|
# update_cron_api "$cron_line:$cmd_task $filename:$cron_name:$cron_id"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
@@ -276,19 +276,19 @@ show_service_logs() {
|
|||||||
|
|
||||||
## 脚本用法
|
## 脚本用法
|
||||||
usage() {
|
usage() {
|
||||||
t "$cmd_update 命令使用方法:"
|
t '%s 命令使用方法:' "$cmd_update"
|
||||||
echo -e "1. $cmd_update update # 更新并重启青龙"
|
echo -e "1. $cmd_update update # $(t '更新并重启青龙')"
|
||||||
echo -e "2. $cmd_update extra # 运行自定义脚本"
|
echo -e "2. $cmd_update extra # $(t '运行自定义脚本')"
|
||||||
echo -e "3. $cmd_update raw <fileurl> # 更新单个脚本文件"
|
echo -e "3. $cmd_update raw <fileurl> # $(t '更新单个脚本文件')"
|
||||||
echo -e "4. $cmd_update repo <repourl> <path> <blacklist> <dependence> <branch> <extensions> # 更新单个仓库的脚本"
|
echo -e "4. $cmd_update repo <repourl> <path> <blacklist> <dependence> <branch> <extensions> # $(t '更新单个仓库的脚本')"
|
||||||
echo -e "5. $cmd_update rmlog <days> # 删除旧日志"
|
echo -e "5. $cmd_update rmlog <days> # $(t '删除旧日志')"
|
||||||
echo -e "6. $cmd_update bot # 启动tg-bot"
|
echo -e "6. $cmd_update bot # $(t '启动tg-bot')"
|
||||||
echo -e "7. $cmd_update check # 检测青龙环境并修复"
|
echo -e "7. $cmd_update check # $(t '检测青龙环境并修复')"
|
||||||
echo -e "8. $cmd_update log [--lines <n>] [--nostream] # 查看青龙运行日志"
|
echo -e "8. $cmd_update log [--lines <n>] [--nostream] # $(t '查看青龙运行日志')"
|
||||||
echo -e "9. $cmd_update resetlet # 重置登录错误次数"
|
echo -e "9. $cmd_update resetlet # $(t '重置登录错误次数')"
|
||||||
echo -e "10. $cmd_update resettfa # 禁用两步登录"
|
echo -e "10. $cmd_update resettfa # $(t '禁用两步登录')"
|
||||||
echo -e "11. $cmd_update resetpwd # 修改登录密码"
|
echo -e "11. $cmd_update resetpwd # $(t '修改登录密码')"
|
||||||
echo -e "12. $cmd_update resetname # 修改登录用户名"
|
echo -e "12. $cmd_update resetname # $(t '修改登录用户名')"
|
||||||
}
|
}
|
||||||
|
|
||||||
reload_qinglong() {
|
reload_qinglong() {
|
||||||
@@ -504,7 +504,9 @@ main() {
|
|||||||
local begin_time=$(format_time "$time_format" "$time")
|
local begin_time=$(format_time "$time_format" "$time")
|
||||||
|
|
||||||
if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then
|
if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then
|
||||||
eval echo -e "\#\# 开始执行... $begin_time\\\n" $cmd
|
local start_message
|
||||||
|
printf -v start_message '%q' "$(t '## 开始执行... %s\n' "$begin_time")"
|
||||||
|
eval "printf '%s\n' $start_message $cmd"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp"
|
[[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp"
|
||||||
@@ -577,7 +579,9 @@ main() {
|
|||||||
[[ $ID ]] && update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time"
|
[[ $ID ]] && update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time"
|
||||||
|
|
||||||
if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then
|
if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then
|
||||||
eval echo -e "\\\n\#\# 执行结束... $end_time 耗时 $diff_time 秒 " $cmd
|
local end_message
|
||||||
|
printf -v end_message '%q' "$(t '\n## 执行结束... %s 耗时 %s 秒' "$end_time" "$diff_time") "
|
||||||
|
eval "printf '%s\n' $end_message $cmd"
|
||||||
fi
|
fi
|
||||||
return "$command_status"
|
return "$command_status"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,6 +3,6 @@ const upgrade = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
|
|||||||
module.exports = upgrade;
|
module.exports = upgrade;
|
||||||
if (require.main === module)
|
if (require.main === module)
|
||||||
upgrade.main().catch((error) => {
|
upgrade.main().catch((error) => {
|
||||||
console.error(`更新失败: ${error.message}`);
|
console.error(upgrade.upgradeMessage('更新失败: %s', process.env, error.message));
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
|
|||||||
+16
-5
@@ -48,7 +48,8 @@ export default function () {
|
|||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [systemInfo, setSystemInfo] = useState<TSystemInfo>();
|
const [systemInfo, setSystemInfo] = useState<TSystemInfo>();
|
||||||
const [siteTitle, setSiteTitle] = useState(
|
const [siteTitle, setSiteTitle] = useState(
|
||||||
() => localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'),
|
() =>
|
||||||
|
localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'),
|
||||||
);
|
);
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [initLoading, setInitLoading] = useState<boolean>(true);
|
const [initLoading, setInitLoading] = useState<boolean>(true);
|
||||||
@@ -221,15 +222,25 @@ export default function () {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.onload = () => {
|
window.onload = () => {
|
||||||
const timing = performance.timing;
|
const timing = performance.timing;
|
||||||
console.log(`白屏时间: ${timing.responseStart - timing.navigationStart}`);
|
|
||||||
console.log(
|
console.log(
|
||||||
`请求完毕至DOM加载: ${timing.domInteractive - timing.responseEnd}`,
|
intl.get('白屏时间', {
|
||||||
|
duration: timing.responseStart - timing.navigationStart,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
`解释dom树耗时: ${timing.domComplete - timing.domInteractive}`,
|
intl.get('请求完毕至DOM加载', {
|
||||||
|
duration: timing.domInteractive - timing.responseEnd,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
`从开始至load总耗时: ${timing.loadEventEnd - timing.navigationStart}`,
|
intl.get('解释DOM树耗时', {
|
||||||
|
duration: timing.domComplete - timing.domInteractive,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
intl.get('从开始至load总耗时', {
|
||||||
|
duration: timing.loadEventEnd - timing.navigationStart,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
+23
-1
@@ -1,4 +1,19 @@
|
|||||||
{
|
{
|
||||||
|
"选中条数": "Selected {count} items",
|
||||||
|
"登录失败次数": "Failed {count} time(s)",
|
||||||
|
"间隔定时规则": "Every {value} {unit}",
|
||||||
|
"SOCK5代理,例如 IP:PORT": "SOCKS5 proxy, e.g. IP:PORT",
|
||||||
|
"HTTP/SOCK5代理,例如 http://127.0.0.1:1080": "HTTP/SOCKS5 proxy, e.g. http://127.0.0.1:1080",
|
||||||
|
"确认操作对象": "Confirm {operation} {object}",
|
||||||
|
"确认批量操作": "Confirm {operation} selected {object}?",
|
||||||
|
"检查更新失败,请检查网络后重试": "Failed to check for updates. Check your network and try again.",
|
||||||
|
"确认操作": "Confirm {operation}",
|
||||||
|
"分页总数": "{start}-{end} of {total} items",
|
||||||
|
"总条数": "{total} items",
|
||||||
|
"内网IP": "Private IP",
|
||||||
|
"容器更新修复命令": "1. Run on the host: docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR <container_name>",
|
||||||
|
"WxPusher(极简推送SPT-推荐)": "WxPusher (Simple Push SPT, recommended)",
|
||||||
|
"新版本可用,当前版本": "Version {version} is available. You are using {current}.",
|
||||||
"1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>": "1. Execute 'docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR <container_name>' on the host machine",
|
"1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>": "1. Execute 'docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR <container_name>' on the host machine",
|
||||||
"2. 容器内执行 ql check、ql update": "2. Execute 'ql check' and 'ql update' inside the container",
|
"2. 容器内执行 ql check、ql update": "2. Execute 'ql check' and 'ql update' inside the container",
|
||||||
"24小时未运行": "Idle 24h+",
|
"24小时未运行": "Idle 24h+",
|
||||||
@@ -709,5 +724,12 @@
|
|||||||
"脚本通知": "Script notification",
|
"脚本通知": "Script notification",
|
||||||
"系统通知": "System notification",
|
"系统通知": "System notification",
|
||||||
"Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python supports package names, Git URLs, and existing requirements.txt or pyproject.toml files under the scripts or repository directory.",
|
"Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python supports package names, Git URLs, and existing requirements.txt or pyproject.toml files under the scripts or repository directory.",
|
||||||
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "Package name, Git URL, requirements.txt or pyproject.toml path"
|
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "Package name, Git URL, requirements.txt or pyproject.toml path",
|
||||||
|
"控制台诗句": "A rain hat and cloak, a song among the hills. The moon, the breeze and I spend our idle days together.",
|
||||||
|
"运行环境信息": "QingLong runtime:\n\nSystem: {system}\nBrowser: {browser}\nEngine: {engine}",
|
||||||
|
"WebSocket错误": "WebSocket error:",
|
||||||
|
"白屏时间": "Time to first response: {duration} ms",
|
||||||
|
"请求完毕至DOM加载": "Response end to DOM interactive: {duration} ms",
|
||||||
|
"解释DOM树耗时": "DOM completion time: {duration} ms",
|
||||||
|
"从开始至load总耗时": "Total load time: {duration} ms"
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -1,4 +1,19 @@
|
|||||||
{
|
{
|
||||||
|
"选中条数": "已选择 {count} 项",
|
||||||
|
"登录失败次数": "失败{count}次",
|
||||||
|
"间隔定时规则": "每{value}{unit}",
|
||||||
|
"SOCK5代理,例如 IP:PORT": "SOCK5代理,例如 IP:PORT",
|
||||||
|
"HTTP/SOCK5代理,例如 http://127.0.0.1:1080": "HTTP/SOCK5代理,例如 http://127.0.0.1:1080",
|
||||||
|
"确认操作对象": "确认{operation}{object}",
|
||||||
|
"确认批量操作": "确认{operation}选中的{object}吗",
|
||||||
|
"检查更新失败,请检查网络后重试": "检查更新失败,请检查网络后重试",
|
||||||
|
"确认操作": "确认{operation}",
|
||||||
|
"分页总数": "第 {start}-{end} 条/总共 {total} 条",
|
||||||
|
"总条数": "共 {total} 个",
|
||||||
|
"内网IP": "内网IP",
|
||||||
|
"容器更新修复命令": "1. 宿主机执行 docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR <容器名>",
|
||||||
|
"WxPusher(极简推送SPT-推荐)": "WxPusher(极简推送SPT-推荐)",
|
||||||
|
"新版本可用,当前版本": "新版本 {version} 可用,你使用的版本为 {current}。",
|
||||||
"1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>": "1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>",
|
"1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>": "1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>",
|
||||||
"2. 容器内执行 ql check、ql update": "2. 容器内执行 ql check、ql update",
|
"2. 容器内执行 ql check、ql update": "2. 容器内执行 ql check、ql update",
|
||||||
"24小时未运行": "24小时未运行",
|
"24小时未运行": "24小时未运行",
|
||||||
@@ -709,5 +724,12 @@
|
|||||||
"脚本通知": "脚本通知",
|
"脚本通知": "脚本通知",
|
||||||
"系统通知": "系统通知",
|
"系统通知": "系统通知",
|
||||||
"Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径",
|
"Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径",
|
||||||
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "支持包名、GitHub链接、requirements.txt或pyproject.toml路径"
|
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "支持包名、GitHub链接、requirements.txt或pyproject.toml路径",
|
||||||
|
"控制台诗句": "忘形雨笠烟蓑,知心牧唱樵歌。明月清风共我,闲人三个,从他今古消磨。",
|
||||||
|
"运行环境信息": "青龙运行环境:\n\n系统:{system}\n浏览器:{browser}\n内核:{engine}",
|
||||||
|
"WebSocket错误": "WebSocket错误:",
|
||||||
|
"白屏时间": "白屏时间: {duration} ms",
|
||||||
|
"请求完毕至DOM加载": "请求完毕至DOM加载: {duration} ms",
|
||||||
|
"解释DOM树耗时": "解释 DOM 树耗时: {duration} ms",
|
||||||
|
"从开始至load总耗时": "从开始至 load 总耗时: {duration} ms"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -364,13 +364,10 @@ const CronDetailModal = ({
|
|||||||
|
|
||||||
const enabledOrDisabledCron = () => {
|
const enabledOrDisabledCron = () => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: `确认${currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
|
title: intl.get('确认操作', { operation: currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') }),
|
||||||
}`,
|
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
{intl.get('确认')}
|
{intl.get('确认操作对象', { operation: currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用'), object: intl.get('定时任务') })}{' '}
|
||||||
{currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')}
|
|
||||||
{intl.get('定时任务')}{' '}
|
|
||||||
<Text style={{ wordBreak: 'break-all' }} type="warning">
|
<Text style={{ wordBreak: 'break-all' }} type="warning">
|
||||||
{currentCron.name}
|
{currentCron.name}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
@@ -398,13 +395,10 @@ const CronDetailModal = ({
|
|||||||
|
|
||||||
const pinOrUnPinCron = () => {
|
const pinOrUnPinCron = () => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: `确认${currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
|
title: intl.get('确认操作', { operation: currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') }),
|
||||||
}`,
|
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
{intl.get('确认')}
|
{intl.get('确认操作对象', { operation: currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'), object: intl.get('定时任务') })}{' '}
|
||||||
{currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')}
|
|
||||||
{intl.get('定时任务')}{' '}
|
|
||||||
<Text style={{ wordBreak: 'break-all' }} type="warning">
|
<Text style={{ wordBreak: 'break-all' }} type="warning">
|
||||||
{currentCron.name}
|
{currentCron.name}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
|
|||||||
+16
-18
@@ -544,13 +544,13 @@ const Crontab = () => {
|
|||||||
|
|
||||||
const enabledOrDisabledCron = (record: any, index: number) => {
|
const enabledOrDisabledCron = (record: any, index: number) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
|
title: intl.get('确认操作', {
|
||||||
}`,
|
operation:
|
||||||
|
record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用'),
|
||||||
|
}),
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
{intl.get('确认')}
|
{intl.get('确认操作对象', { operation: record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用'), object: intl.get('定时任务') })}{' '}
|
||||||
{record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')}
|
|
||||||
{intl.get('定时任务')}{' '}
|
|
||||||
<Text style={{ wordBreak: 'break-all' }} type="warning">
|
<Text style={{ wordBreak: 'break-all' }} type="warning">
|
||||||
{record.name}
|
{record.name}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
@@ -584,13 +584,13 @@ const Crontab = () => {
|
|||||||
|
|
||||||
const pinOrUnPinCron = (record: any, index: number) => {
|
const pinOrUnPinCron = (record: any, index: number) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
|
title: intl.get('确认操作', {
|
||||||
}`,
|
operation:
|
||||||
|
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'),
|
||||||
|
}),
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
{intl.get('确认')}
|
{intl.get('确认操作对象', { operation: record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'), object: intl.get('定时任务') })}{' '}
|
||||||
{record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')}
|
|
||||||
{intl.get('定时任务')}{' '}
|
|
||||||
<Text style={{ wordBreak: 'break-all' }} type="warning">
|
<Text style={{ wordBreak: 'break-all' }} type="warning">
|
||||||
{record.name}
|
{record.name}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
@@ -745,12 +745,12 @@ const Crontab = () => {
|
|||||||
|
|
||||||
const operateCrons = (operationStatus: number) => {
|
const operateCrons = (operationStatus: number) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: `确认${OperationName[operationStatus]}`,
|
title: intl.get('确认操作', {
|
||||||
|
operation: intl.get(OperationName[operationStatus]),
|
||||||
|
}),
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
{intl.get('确认')}
|
{intl.get('确认批量操作', { operation: intl.get(OperationName[operationStatus]), object: intl.get('定时任务') })}
|
||||||
{OperationName[operationStatus]}
|
|
||||||
{intl.get('选中的定时任务吗')}
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
onOk() {
|
onOk() {
|
||||||
@@ -1035,9 +1035,7 @@ const Crontab = () => {
|
|||||||
{intl.get('批量修改标签')}
|
{intl.get('批量修改标签')}
|
||||||
</Button>
|
</Button>
|
||||||
<span style={{ marginLeft: 8 }}>
|
<span style={{ marginLeft: 8 }}>
|
||||||
{intl.get('已选择')}
|
{intl.get('选中条数', { count: selectedRowIds.length })}
|
||||||
<a>{selectedRowIds?.length}</a>
|
|
||||||
{intl.get('项')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1051,7 +1049,7 @@ const Crontab = () => {
|
|||||||
simple: isPhone,
|
simple: isPhone,
|
||||||
total,
|
total,
|
||||||
showTotal: (total: number, range: number[]) =>
|
showTotal: (total: number, range: number[]) =>
|
||||||
`第 ${range[0]}-${range[1]} 条/总共 ${total} 条`,
|
intl.get('分页总数', { start: range[0], end: range[1], total }),
|
||||||
pageSizeOptions: [10, 20, 50, 100, 200, 500, total || 10000].sort(
|
pageSizeOptions: [10, 20, 50, 100, 200, 500, total || 10000].sort(
|
||||||
(a, b) => a - b,
|
(a, b) => a - b,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ const Dashboard = () => {
|
|||||||
const runtimePagination = {
|
const runtimePagination = {
|
||||||
pageSize: 5,
|
pageSize: 5,
|
||||||
showSizeChanger: false,
|
showSizeChanger: false,
|
||||||
showTotal: (total: number) => `共 ${total} 个`,
|
showTotal: (total: number) => intl.get('总条数', { total }),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <Spin size="large" style={{ display: 'block', marginTop: 100 }} />;
|
if (loading) return <Spin size="large" style={{ display: 'block', marginTop: 100 }} />;
|
||||||
@@ -348,7 +348,7 @@ const Dashboard = () => {
|
|||||||
data={{
|
data={{
|
||||||
target: Number(system.memUsagePercent),
|
target: Number(system.memUsagePercent),
|
||||||
total: 100,
|
total: 100,
|
||||||
name: '内存使用率',
|
name: intl.get('内存使用'),
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
textY: '70%',
|
textY: '70%',
|
||||||
|
|||||||
@@ -536,9 +536,7 @@ const Dependence = () => {
|
|||||||
{intl.get('批量强制删除')}
|
{intl.get('批量强制删除')}
|
||||||
</Button>
|
</Button>
|
||||||
<span style={{ marginLeft: 8 }}>
|
<span style={{ marginLeft: 8 }}>
|
||||||
{intl.get('已选择')}
|
{intl.get('选中条数', { count: selectedRowIds.length })}
|
||||||
<a>{selectedRowIds?.length}</a>
|
|
||||||
{intl.get('项')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Vendored
+18
-22
@@ -279,16 +279,13 @@ const Env = () => {
|
|||||||
|
|
||||||
const enabledOrDisabledEnv = (record: any, index: number) => {
|
const enabledOrDisabledEnv = (record: any, index: number) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: `确认${
|
title: intl.get('确认操作', {
|
||||||
record.status === Status.已禁用 ? intl.get('启用') : intl.get('禁用')
|
operation:
|
||||||
}`,
|
record.status === Status.已禁用 ? intl.get('启用') : intl.get('禁用'),
|
||||||
|
}),
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
{intl.get('确认')}
|
{intl.get('确认操作对象', { operation: record.status === Status.已禁用 ? intl.get('启用') : intl.get('禁用'), object: 'Env' })}{' '}
|
||||||
{record.status === Status.已禁用
|
|
||||||
? intl.get('启用')
|
|
||||||
: intl.get('禁用')}
|
|
||||||
Env{' '}
|
|
||||||
<Paragraph
|
<Paragraph
|
||||||
style={{ wordBreak: 'break-all', display: 'inline' }}
|
style={{ wordBreak: 'break-all', display: 'inline' }}
|
||||||
ellipsis={{ rows: 6, expandable: true }}
|
ellipsis={{ rows: 6, expandable: true }}
|
||||||
@@ -343,14 +340,13 @@ const Env = () => {
|
|||||||
|
|
||||||
const pinOrUnpinEnv = (record: any, index: number) => {
|
const pinOrUnpinEnv = (record: any, index: number) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: `确认${
|
title: intl.get('确认操作', {
|
||||||
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
|
operation:
|
||||||
}`,
|
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'),
|
||||||
|
}),
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
{intl.get('确认')}
|
{intl.get('确认操作对象', { operation: record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'), object: 'Env' })}{' '}
|
||||||
{record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')}
|
|
||||||
Env{' '}
|
|
||||||
<Paragraph
|
<Paragraph
|
||||||
style={{ wordBreak: 'break-all', display: 'inline' }}
|
style={{ wordBreak: 'break-all', display: 'inline' }}
|
||||||
ellipsis={{ rows: 6, expandable: true }}
|
ellipsis={{ rows: 6, expandable: true }}
|
||||||
@@ -543,12 +539,12 @@ const Env = () => {
|
|||||||
|
|
||||||
const operateEnvs = (operationStatus: number) => {
|
const operateEnvs = (operationStatus: number) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: `确认${OperationName[operationStatus]}`,
|
title: intl.get('确认操作', {
|
||||||
|
operation: intl.get(OperationName[operationStatus]),
|
||||||
|
}),
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
{intl.get('确认')}
|
{intl.get('确认批量操作', { operation: intl.get(OperationName[operationStatus]), object: intl.get('环境变量') })}
|
||||||
{OperationName[operationStatus]}
|
|
||||||
{intl.get('选中的变量吗')}
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
onOk() {
|
onOk() {
|
||||||
@@ -603,7 +599,9 @@ const Env = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (code === 200) {
|
if (code === 200) {
|
||||||
message.success(`${intl.get('成功上传')}${data.length}${intl.get('个环境变量')}`);
|
message.success(
|
||||||
|
`${intl.get('成功上传')}${data.length}${intl.get('个环境变量')}`,
|
||||||
|
);
|
||||||
getEnvs();
|
getEnvs();
|
||||||
}
|
}
|
||||||
setImportLoading(false);
|
setImportLoading(false);
|
||||||
@@ -708,9 +706,7 @@ const Env = () => {
|
|||||||
{intl.get('批量取消置顶')}
|
{intl.get('批量取消置顶')}
|
||||||
</Button>
|
</Button>
|
||||||
<span style={{ marginLeft: 8 }}>
|
<span style={{ marginLeft: 8 }}>
|
||||||
{intl.get('已选择')}
|
{intl.get('选中条数', { count: selectedRowIds.length })}
|
||||||
<a>{selectedRowIds?.length}</a>
|
|
||||||
{intl.get('项')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -79,16 +79,10 @@ const Error = () => {
|
|||||||
description={
|
description={
|
||||||
<Typography.Text type="danger">
|
<Typography.Text type="danger">
|
||||||
<div>{intl.get('请先按如下方式修复:')}</div>
|
<div>{intl.get('请先按如下方式修复:')}</div>
|
||||||
<div>
|
<div>{intl.get('容器更新修复命令')}</div>
|
||||||
1. 宿主机执行 docker run --rm -v
|
|
||||||
/var/run/docker.sock:/var/run/docker.sock
|
|
||||||
containrrr/watchtower -cR <容器名>
|
|
||||||
</div>
|
|
||||||
<div>{intl.get('2. 容器内执行 ql check、ql update')}</div>
|
<div>{intl.get('2. 容器内执行 ql check、ql update')}</div>
|
||||||
<div>
|
<div>
|
||||||
{intl.get(
|
{intl.get('3. 如果无法解决,容器内执行 ql log,拷贝执行结果')}
|
||||||
'3. 如果无法解决,容器内执行 ql log,拷贝执行结果',
|
|
||||||
)}
|
|
||||||
<Typography.Link href="https://github.com/whyour/qinglong/issues/new?assignees=&labels=&template=bug_report.yml">
|
<Typography.Link href="https://github.com/whyour/qinglong/issues/new?assignees=&labels=&template=bug_report.yml">
|
||||||
{intl.get('提交 issue')}
|
{intl.get('提交 issue')}
|
||||||
</Typography.Link>
|
</Typography.Link>
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ const Initialization = () => {
|
|||||||
>
|
>
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
autoSize={{ minRows: 1, maxRows: 5 }}
|
autoSize={{ minRows: 1, maxRows: 5 }}
|
||||||
placeholder={`请输入${x.label}`}
|
placeholder={`${intl.get('请输入')} ${x.label}`}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ const Login = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{intl.get('上次登录地点:')}
|
{intl.get('上次登录地点:')}
|
||||||
{lastaddr || '-'}
|
{lastaddr === '内网IP' ? intl.get('内网IP') : lastaddr || '-'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{intl.get('上次登录IP:')}
|
{intl.get('上次登录IP:')}
|
||||||
@@ -103,7 +103,7 @@ const Login = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{intl.get('上次登录状态:')}
|
{intl.get('上次登录状态:')}
|
||||||
{retries > 0 ? `失败${retries}次` : intl.get('成功')}
|
{retries > 0 ? intl.get('登录失败次数', { count: retries }) : intl.get('成功')}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
import { disableBody } from "@/utils";
|
import { disableBody } from '@/utils';
|
||||||
import config from "@/utils/config";
|
import config from '@/utils/config';
|
||||||
import { request } from "@/utils/http";
|
import { request } from '@/utils/http';
|
||||||
import WebSocketManager from "@/utils/websocket";
|
import WebSocketManager from '@/utils/websocket';
|
||||||
import Ansi from "ansi-to-react";
|
import Ansi from 'ansi-to-react';
|
||||||
import { Button, Modal, Statistic, message } from "antd";
|
import { Button, Modal, Statistic, message } from 'antd';
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import intl from "react-intl-universal";
|
import intl from 'react-intl-universal';
|
||||||
|
|
||||||
const { Countdown } = Statistic;
|
const { Countdown } = Statistic;
|
||||||
|
|
||||||
const CheckUpdate = ({ systemInfo }: any) => {
|
const CheckUpdate = ({ systemInfo }: any) => {
|
||||||
const [updateLoading, setUpdateLoading] = useState(false);
|
const [updateLoading, setUpdateLoading] = useState(false);
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState('');
|
||||||
const modalRef = useRef<any>();
|
const modalRef = useRef<any>();
|
||||||
const lastStatusRef = useRef<string | undefined>();
|
const lastStatusRef = useRef<string | undefined>();
|
||||||
|
const [updateStatus, setUpdateStatus] = useState<string>();
|
||||||
|
|
||||||
const checkUpgrade = () => {
|
const checkUpgrade = () => {
|
||||||
if (updateLoading) return;
|
if (updateLoading || modalRef.current) return;
|
||||||
setUpdateLoading(true);
|
setUpdateLoading(true);
|
||||||
message.loading(intl.get("检查更新中..."), 0);
|
message.loading(intl.get('检查更新中...'), 0);
|
||||||
request
|
request
|
||||||
.put(`${config.apiPrefix}system/update-check`)
|
.put(`${config.apiPrefix}system/update-check`)
|
||||||
.then(({ code, data }) => {
|
.then(({ code, data }) => {
|
||||||
@@ -33,7 +34,7 @@ const CheckUpdate = ({ systemInfo }: any) => {
|
|||||||
})
|
})
|
||||||
.catch((error: any) => {
|
.catch((error: any) => {
|
||||||
message.destroy();
|
message.destroy();
|
||||||
console.log(error);
|
message.error(intl.get('检查更新失败,请检查网络后重试'));
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setUpdateLoading(false);
|
setUpdateLoading(false);
|
||||||
@@ -43,25 +44,19 @@ const CheckUpdate = ({ systemInfo }: any) => {
|
|||||||
const showForceUpdateModal = (data: any) => {
|
const showForceUpdateModal = (data: any) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
width: 500,
|
width: 500,
|
||||||
title: intl.get("更新"),
|
title: intl.get('更新'),
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
<div>{intl.get("已经是最新版了!")}</div>
|
<div>{intl.get('已经是最新版了!')}</div>
|
||||||
<div style={{ fontSize: 12, fontWeight: 400, marginTop: 5 }}>
|
<div style={{ fontSize: 12, fontWeight: 400, marginTop: 5 }}>
|
||||||
{intl.get("青龙")} {data.lastVersion}{" "}
|
{intl.get('青龙')} {data.lastVersion}{' '}
|
||||||
{intl.get("是目前检测到的最新可用版本了。")}
|
{intl.get('是目前检测到的最新可用版本了。')}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
okText: intl.get("重新下载"),
|
okText: intl.get('重新下载'),
|
||||||
onOk() {
|
onOk() {
|
||||||
showUpdatingModal();
|
startUpdate();
|
||||||
request
|
|
||||||
.put(`${config.apiPrefix}system/update`)
|
|
||||||
.then((_data: any) => { })
|
|
||||||
.catch((error: any) => {
|
|
||||||
console.log(error);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -72,10 +67,12 @@ const CheckUpdate = ({ systemInfo }: any) => {
|
|||||||
width: 500,
|
width: 500,
|
||||||
title: (
|
title: (
|
||||||
<>
|
<>
|
||||||
<div>{intl.get("更新可用")}</div>
|
<div>{intl.get('更新可用')}</div>
|
||||||
<div style={{ fontSize: 12, fontWeight: 400, marginTop: 5 }}>
|
<div style={{ fontSize: 12, fontWeight: 400, marginTop: 5 }}>
|
||||||
{intl.get("新版本")} {lastVersion}{" "}
|
{intl.get('新版本可用,当前版本', {
|
||||||
{intl.get("可用,你使用的版本为")} {systemInfo.version}。
|
version: lastVersion,
|
||||||
|
current: systemInfo.version,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -84,52 +81,60 @@ const CheckUpdate = ({ systemInfo }: any) => {
|
|||||||
<Ansi>{lastLog}</Ansi>
|
<Ansi>{lastLog}</Ansi>
|
||||||
</pre>
|
</pre>
|
||||||
),
|
),
|
||||||
okText: intl.get("下载更新"),
|
okText: intl.get('下载更新'),
|
||||||
cancelText: intl.get("以后再说"),
|
cancelText: intl.get('以后再说'),
|
||||||
onOk() {
|
onOk() {
|
||||||
showUpdatingModal();
|
startUpdate();
|
||||||
request
|
|
||||||
.put(`${config.apiPrefix}system/update`)
|
|
||||||
.then((_data: any) => { })
|
|
||||||
.catch((error: any) => {
|
|
||||||
console.log(error);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const showUpdatingModal = () => {
|
const showUpdatingModal = () => {
|
||||||
setValue("");
|
setValue('');
|
||||||
|
lastStatusRef.current = 'running';
|
||||||
|
setUpdateStatus('running');
|
||||||
modalRef.current = Modal.info({
|
modalRef.current = Modal.info({
|
||||||
width: 600,
|
width: 600,
|
||||||
maskClosable: false,
|
maskClosable: false,
|
||||||
closable: false,
|
closable: false,
|
||||||
keyboard: false,
|
keyboard: false,
|
||||||
okButtonProps: { disabled: true },
|
okButtonProps: { disabled: true },
|
||||||
title: intl.get("下载更新中..."),
|
title: intl.get('下载更新中...'),
|
||||||
centered: true,
|
centered: true,
|
||||||
content: (
|
afterClose: () => {
|
||||||
<pre>
|
modalRef.current = undefined;
|
||||||
<Ansi>{value}</Ansi>
|
},
|
||||||
</pre>
|
content: <pre />,
|
||||||
),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startUpdate = async () => {
|
||||||
|
showUpdatingModal();
|
||||||
|
try {
|
||||||
|
const response = await request.put(`${config.apiPrefix}system/update`);
|
||||||
|
if (response.code !== 200) throw new Error(response.message);
|
||||||
|
} catch {
|
||||||
|
lastStatusRef.current = 'failed';
|
||||||
|
setUpdateStatus('failed');
|
||||||
|
message.error(intl.get('更新失败,请检查网络及日志或稍后再试'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const reloadSystem = (type?: string) => {
|
const reloadSystem = (type?: string) => {
|
||||||
request
|
request
|
||||||
.put(`${config.apiPrefix}update/${type}`)
|
.put(`${config.apiPrefix}update/${type}`)
|
||||||
.then((_data: any) => {
|
.then(({ code }: any) => {
|
||||||
|
if (code !== 200) return;
|
||||||
message.success({
|
message.success({
|
||||||
content: (
|
content: (
|
||||||
<span>
|
<span>
|
||||||
{intl.get("系统将在")}
|
{intl.get('系统将在')}
|
||||||
<Countdown
|
<Countdown
|
||||||
className="inline-countdown"
|
className="inline-countdown"
|
||||||
format="ss"
|
format="ss"
|
||||||
value={Date.now() + 1000 * 30}
|
value={Date.now() + 1000 * 30}
|
||||||
/>
|
/>
|
||||||
{intl.get("秒后自动刷新")}
|
{intl.get('秒后自动刷新')}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
duration: 30,
|
duration: 30,
|
||||||
@@ -148,15 +153,15 @@ const CheckUpdate = ({ systemInfo }: any) => {
|
|||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
width: 600,
|
width: 600,
|
||||||
maskClosable: false,
|
maskClosable: false,
|
||||||
title: intl.get("确认重启"),
|
title: intl.get('确认重启'),
|
||||||
centered: true,
|
centered: true,
|
||||||
content: intl.get("系统安装包下载成功,确认重启"),
|
content: intl.get('系统安装包下载成功,确认重启'),
|
||||||
okText: intl.get("重启"),
|
okText: intl.get('重启'),
|
||||||
onOk() {
|
onOk() {
|
||||||
reloadSystem("system");
|
reloadSystem('system');
|
||||||
},
|
},
|
||||||
onCancel() {
|
onCancel() {
|
||||||
modalRef.current.update({
|
modalRef.current?.update({
|
||||||
maskClosable: true,
|
maskClosable: true,
|
||||||
closable: true,
|
closable: true,
|
||||||
okButtonProps: { disabled: false },
|
okButtonProps: { disabled: false },
|
||||||
@@ -166,8 +171,8 @@ const CheckUpdate = ({ systemInfo }: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!value) return;
|
if (!modalRef.current) return;
|
||||||
const updateFailed = lastStatusRef.current === 'failed';
|
const updateFailed = updateStatus === 'failed';
|
||||||
|
|
||||||
modalRef.current.update({
|
modalRef.current.update({
|
||||||
maskClosable: updateFailed,
|
maskClosable: updateFailed,
|
||||||
@@ -182,27 +187,32 @@ const CheckUpdate = ({ systemInfo }: any) => {
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [value]);
|
}, [value, updateStatus]);
|
||||||
|
|
||||||
const handleMessage = useCallback((payload: any) => {
|
const handleMessage = useCallback((payload: any) => {
|
||||||
|
if (!modalRef.current || lastStatusRef.current !== 'running') return;
|
||||||
const { message: _message, status } = payload;
|
const { message: _message, status } = payload;
|
||||||
|
|
||||||
if (status === 'failed') {
|
if (status === 'failed') {
|
||||||
lastStatusRef.current = 'failed';
|
lastStatusRef.current = 'failed';
|
||||||
message.error(intl.get("更新失败,请检查网络及日志或稍后再试"));
|
setUpdateStatus('failed');
|
||||||
|
message.error(intl.get('更新失败,请检查网络及日志或稍后再试'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
lastStatusRef.current = 'success';
|
lastStatusRef.current = 'success';
|
||||||
|
setUpdateStatus('success');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
if (modalRef.current && lastStatusRef.current === 'success') {
|
||||||
showReloadModal();
|
showReloadModal();
|
||||||
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document
|
document
|
||||||
.querySelector("#log-identifier")
|
.querySelector('#log-identifier')
|
||||||
?.scrollIntoView({ behavior: "smooth" });
|
?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, 600);
|
}, 600);
|
||||||
|
|
||||||
if (_message) {
|
if (_message) {
|
||||||
@@ -212,24 +222,26 @@ const CheckUpdate = ({ systemInfo }: any) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ws = WebSocketManager.getInstance();
|
const ws = WebSocketManager.getInstance();
|
||||||
ws.subscribe("updateSystemVersion", handleMessage);
|
ws.subscribe('updateSystemVersion', handleMessage);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
ws.unsubscribe("updateSystemVersion", handleMessage);
|
ws.unsubscribe('updateSystemVersion', handleMessage);
|
||||||
|
modalRef.current?.destroy();
|
||||||
|
modalRef.current = undefined;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button type="primary" onClick={checkUpgrade}>
|
<Button type="primary" onClick={checkUpgrade}>
|
||||||
{intl.get("检查更新")}
|
{intl.get('检查更新')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => reloadSystem("reload")}
|
onClick={() => reloadSystem('reload')}
|
||||||
style={{ marginLeft: 8 }}
|
style={{ marginLeft: 8 }}
|
||||||
>
|
>
|
||||||
{intl.get("重新启动")}
|
{intl.get('重新启动')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,6 +33,11 @@
|
|||||||
gap: 40px;
|
gap: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notify-log-table .ant-table-thead > tr > th,
|
||||||
|
.login-log-table .ant-table-thead > tr > th {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.ql-container-wrapper.ql-setting-container {
|
.ql-container-wrapper.ql-setting-container {
|
||||||
.ant-tabs-tabpane > div {
|
.ant-tabs-tabpane > div {
|
||||||
padding-left: 2px;
|
padding-left: 2px;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const LoginLog = ({ data, height }: { data: Array<any>; height: number }) => {
|
|||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: intl.get('序号'),
|
title: intl.get('序号'),
|
||||||
width: 50,
|
width: 90,
|
||||||
render: (text: string, record: any, index: number) => index + 1,
|
render: (text: string, record: any, index: number) => index + 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -63,6 +63,7 @@ const LoginLog = ({ data, height }: { data: Array<any>; height: number }) => {
|
|||||||
dataIndex: 'address',
|
dataIndex: 'address',
|
||||||
width: 120,
|
width: 120,
|
||||||
key: 'address',
|
key: 'address',
|
||||||
|
render: (text: string) => (text === '内网IP' ? intl.get('内网IP') : text),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: intl.get('登录IP'),
|
title: intl.get('登录IP'),
|
||||||
@@ -74,13 +75,13 @@ const LoginLog = ({ data, height }: { data: Array<any>; height: number }) => {
|
|||||||
title: intl.get('登录设备'),
|
title: intl.get('登录设备'),
|
||||||
dataIndex: 'platform',
|
dataIndex: 'platform',
|
||||||
key: 'platform',
|
key: 'platform',
|
||||||
width: 80,
|
width: 140,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: intl.get('登录状态'),
|
title: intl.get('登录状态'),
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
width: 80,
|
width: 140,
|
||||||
render: (text: string, record: any) => (
|
render: (text: string, record: any) => (
|
||||||
<Tag color={LoginStatusColor[record.status]} style={{ marginRight: 0 }}>
|
<Tag color={LoginStatusColor[record.status]} style={{ marginRight: 0 }}>
|
||||||
{intl.get(LoginStatus[record.status])}
|
{intl.get(LoginStatus[record.status])}
|
||||||
@@ -116,12 +117,13 @@ const LoginLog = ({ data, height }: { data: Array<any>; height: number }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Table
|
<Table
|
||||||
|
className="login-log-table"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey={(record) => `${record.ip}-${record.timestamp}`}
|
rowKey={(record) => `${record.ip}-${record.timestamp}`}
|
||||||
size="middle"
|
size="middle"
|
||||||
scroll={{ x: 1000, y: height }}
|
scroll={{ x: 1120, y: height }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import intl from 'react-intl-universal';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Table, Tag } from 'antd';
|
import { Table, Tag } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
interface NotifyLogItem {
|
interface NotifyLogItem {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -40,7 +41,7 @@ const columns = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: intl.get('序号'),
|
title: intl.get('序号'),
|
||||||
width: 50,
|
width: 90,
|
||||||
render: (text: string, record: any, index: number) => {
|
render: (text: string, record: any, index: number) => {
|
||||||
return index + 1;
|
return index + 1;
|
||||||
},
|
},
|
||||||
@@ -79,7 +80,7 @@ const columns = [
|
|||||||
title: intl.get('发送状态'),
|
title: intl.get('发送状态'),
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
width: 90,
|
width: 140,
|
||||||
render: (text: string, record: NotifyLogItem) => {
|
render: (text: string, record: NotifyLogItem) => {
|
||||||
const statusKey = record.status ?? 1;
|
const statusKey = record.status ?? 1;
|
||||||
return (
|
return (
|
||||||
@@ -101,12 +102,13 @@ const NotifyLog = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Table
|
<Table
|
||||||
|
className="notify-log-table"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
size="middle"
|
size="middle"
|
||||||
scroll={{ x: 1000, y: height }}
|
scroll={{ x: 1240, y: height }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ const Subscription = () => {
|
|||||||
key: 'type',
|
key: 'type',
|
||||||
width: 130,
|
width: 130,
|
||||||
render: (text: string, record: any) => {
|
render: (text: string, record: any) => {
|
||||||
return (SubscriptionType as any)[record.type];
|
return intl.get((SubscriptionType as any)[record.type]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -121,7 +121,7 @@ const Subscription = () => {
|
|||||||
render: (text: string, record: any) => {
|
render: (text: string, record: any) => {
|
||||||
if (record.schedule_type === 'interval') {
|
if (record.schedule_type === 'interval') {
|
||||||
const { type, value } = record.interval_schedule;
|
const { type, value } = record.interval_schedule;
|
||||||
return `每${value}${(IntervalSchedule as any)[type]}`;
|
return intl.get('间隔定时规则', { value, unit: intl.get((IntervalSchedule as any)[type]) });
|
||||||
}
|
}
|
||||||
return record.schedule;
|
return record.schedule;
|
||||||
},
|
},
|
||||||
@@ -369,14 +369,13 @@ const Subscription = () => {
|
|||||||
|
|
||||||
const enabledOrDisabledSubscription = (record: any, index: number) => {
|
const enabledOrDisabledSubscription = (record: any, index: number) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: `确认${
|
title: intl.get('确认操作', {
|
||||||
record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用')
|
operation:
|
||||||
}`,
|
record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用'),
|
||||||
|
}),
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
{intl.get('确认')}
|
{intl.get('确认操作对象', { operation: record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用'), object: intl.get('定时订阅') })}{' '}
|
||||||
{record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用')}
|
|
||||||
{intl.get('定时订阅')}{' '}
|
|
||||||
<Text style={{ wordBreak: 'break-all' }} type="warning">
|
<Text style={{ wordBreak: 'break-all' }} type="warning">
|
||||||
{record.name}
|
{record.name}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
@@ -569,7 +568,7 @@ const Subscription = () => {
|
|||||||
simple: isPhone,
|
simple: isPhone,
|
||||||
defaultPageSize: 20,
|
defaultPageSize: 20,
|
||||||
showTotal: (total: number, range: number[]) =>
|
showTotal: (total: number, range: number[]) =>
|
||||||
`第 ${range[0]}-${range[1]} 条/总共 ${total} 条`,
|
intl.get('分页总数', { start: range[0], end: range[1], total }),
|
||||||
pageSizeOptions: [20, 100, 500, 1000] as any,
|
pageSizeOptions: [20, 100, 500, 1000] as any,
|
||||||
}}
|
}}
|
||||||
dataSource={value}
|
dataSource={value}
|
||||||
|
|||||||
@@ -150,10 +150,10 @@ const SubscriptionModal = ({
|
|||||||
precision={0}
|
precision={0}
|
||||||
min={1}
|
min={1}
|
||||||
value={intervalNumber}
|
value={intervalNumber}
|
||||||
style={{ width: 'calc(100% - 58px)' }}
|
style={{ width: 'calc(100% - 100px)' }}
|
||||||
onChange={numberChange}
|
onChange={numberChange}
|
||||||
/>
|
/>
|
||||||
<Select value={intervalType} onChange={intervalTypeChange}>
|
<Select value={intervalType} onChange={intervalTypeChange} style={{ width: 100 }}>
|
||||||
<Option value="days">{intl.get('天')}</Option>
|
<Option value="days">{intl.get('天')}</Option>
|
||||||
<Option value="hours">{intl.get('时')}</Option>
|
<Option value="hours">{intl.get('时')}</Option>
|
||||||
<Option value="minutes">{intl.get('分')}</Option>
|
<Option value="minutes">{intl.get('分')}</Option>
|
||||||
@@ -527,8 +527,8 @@ const SubscriptionModal = ({
|
|||||||
onPaste={onNamePaste}
|
onPaste={onNamePaste}
|
||||||
placeholder={
|
placeholder={
|
||||||
type === 'private-repo'
|
type === 'private-repo'
|
||||||
? 'SOCK5代理,例如 IP:PORT'
|
? intl.get('SOCK5代理,例如 IP:PORT')
|
||||||
: 'HTTP/SOCK5代理,例如 http://127.0.0.1:1080'
|
: intl.get('HTTP/SOCK5代理,例如 http://127.0.0.1:1080')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
+2
-4
@@ -80,7 +80,7 @@ export default {
|
|||||||
{ value: 'pushPlus', label: 'PushPlus' },
|
{ value: 'pushPlus', label: 'PushPlus' },
|
||||||
{ value: 'wePlusBot', label: intl.get('微加机器人') },
|
{ value: 'wePlusBot', label: intl.get('微加机器人') },
|
||||||
{ value: 'wxPusherBot', label: 'wxPusher' },
|
{ value: 'wxPusherBot', label: 'wxPusher' },
|
||||||
{ value: 'wxPusherSpt', label: 'WxPusher(极简推送SPT-推荐)' },
|
{ value: 'wxPusherSpt', label: intl.get('WxPusher(极简推送SPT-推荐)') },
|
||||||
{ value: 'openiLink', label: 'OpeniLink' },
|
{ value: 'openiLink', label: 'OpeniLink' },
|
||||||
{ value: 'wpush', label: 'WPUSH' },
|
{ value: 'wpush', label: 'WPUSH' },
|
||||||
{ value: 'chat', label: intl.get('群晖chat') },
|
{ value: 'chat', label: intl.get('群晖chat') },
|
||||||
@@ -431,9 +431,7 @@ export default {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'larkSecret',
|
label: 'larkSecret',
|
||||||
tip: intl.get(
|
tip: intl.get('飞书群组机器人加签密钥,安全设置中开启签名校验后获得'),
|
||||||
'飞书群组机器人加签密钥,安全设置中开启签名校验后获得',
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
email: [
|
email: [
|
||||||
|
|||||||
+6
-2
@@ -165,11 +165,15 @@ export default function browserType() {
|
|||||||
'color: blue;font-size: 14px;',
|
'color: blue;font-size: 14px;',
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
'%c忘形雨笠烟蓑,知心牧唱樵歌。明月清风共我,闲人三个,从他今古消磨。\n',
|
`%c${intl.get('控制台诗句')}\n`,
|
||||||
'color: yellow;font-size: 18px;',
|
'color: yellow;font-size: 18px;',
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
`%c青龙运行环境:\n\n系统:${result.system}/${result.systemVs}\n浏览器:${result.supporter}/${result.supporterVs}\n内核:${result.engine}/${result.engineVs}`,
|
`%c${intl.get('运行环境信息', {
|
||||||
|
system: `${result.system}/${result.systemVs}`,
|
||||||
|
browser: `${result.supporter}/${result.supporterVs}`,
|
||||||
|
engine: `${result.engine}/${result.engineVs}`,
|
||||||
|
})}`,
|
||||||
'color: green;font-size: 14px;font-weight: bold;',
|
'color: green;font-size: 14px;font-weight: bold;',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
import { loader } from '@monaco-editor/react';
|
import { loader } from '@monaco-editor/react';
|
||||||
import config from './config';
|
import config from './config';
|
||||||
|
import intl from 'react-intl-universal';
|
||||||
|
|
||||||
export function init(version: string) {
|
export function init(version: string) {
|
||||||
// monaco 编辑器配置cdn和locale
|
// monaco 编辑器配置cdn和locale
|
||||||
@@ -9,7 +10,7 @@ export function init(version: string) {
|
|||||||
},
|
},
|
||||||
'vs/nls': {
|
'vs/nls': {
|
||||||
availableLanguages: {
|
availableLanguages: {
|
||||||
'*': 'zh-cn',
|
'*': intl.getInitOptions().currentLocale === 'en' ? 'i-default' : 'zh-cn',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-5
@@ -1,11 +1,13 @@
|
|||||||
import SockJS from 'sockjs-client';
|
import SockJS from 'sockjs-client';
|
||||||
|
import intl from 'react-intl-universal';
|
||||||
import { SockMessageType } from './type';
|
import { SockMessageType } from './type';
|
||||||
|
|
||||||
class WebSocketManager {
|
class WebSocketManager {
|
||||||
private static instance: WebSocketManager | null = null;
|
private static instance: WebSocketManager | null = null;
|
||||||
private url: string;
|
private url: string;
|
||||||
private socket: WebSocket | null = null;
|
private socket: WebSocket | null = null;
|
||||||
private subscriptions: Map<SockMessageType, Set<(p: any) => void>> = new Map();
|
private subscriptions: Map<SockMessageType, Set<(p: any) => void>> =
|
||||||
|
new Map();
|
||||||
private options: {
|
private options: {
|
||||||
maxReconnectAttempts: number;
|
maxReconnectAttempts: number;
|
||||||
reconnectInterval: number;
|
reconnectInterval: number;
|
||||||
@@ -15,7 +17,10 @@ class WebSocketManager {
|
|||||||
private heartbeatTimeout: NodeJS.Timeout | null = null;
|
private heartbeatTimeout: NodeJS.Timeout | null = null;
|
||||||
private state: 'closed' | 'connecting' | 'open' = 'closed';
|
private state: 'closed' | 'connecting' | 'open' = 'closed';
|
||||||
|
|
||||||
constructor(url: string, options: Partial<typeof WebSocketManager.prototype.options> = {}) {
|
constructor(
|
||||||
|
url: string,
|
||||||
|
options: Partial<typeof WebSocketManager.prototype.options> = {},
|
||||||
|
) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.options = {
|
this.options = {
|
||||||
maxReconnectAttempts: options.maxReconnectAttempts || 5,
|
maxReconnectAttempts: options.maxReconnectAttempts || 5,
|
||||||
@@ -26,7 +31,10 @@ class WebSocketManager {
|
|||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static getInstance(url: string = '', options?: Partial<typeof WebSocketManager.prototype.options>): WebSocketManager {
|
public static getInstance(
|
||||||
|
url: string = '',
|
||||||
|
options?: Partial<typeof WebSocketManager.prototype.options>,
|
||||||
|
): WebSocketManager {
|
||||||
if (!WebSocketManager.instance) {
|
if (!WebSocketManager.instance) {
|
||||||
WebSocketManager.instance = new WebSocketManager(url, options);
|
WebSocketManager.instance = new WebSocketManager(url, options);
|
||||||
}
|
}
|
||||||
@@ -47,7 +55,9 @@ class WebSocketManager {
|
|||||||
this.socket = null;
|
this.socket = null;
|
||||||
this.reconnectAttempts++;
|
this.reconnectAttempts++;
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, this.options.reconnectInterval));
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, this.options.reconnectInterval),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.handleError(error);
|
this.handleError(error);
|
||||||
@@ -138,7 +148,7 @@ class WebSocketManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleError(error: any) {
|
private handleError(error: any) {
|
||||||
console.error('WebSocket错误:', error);
|
console.error(intl.get('WebSocket错误'), error);
|
||||||
this.emit('error', error);
|
this.emit('error', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
const { Joi, CelebrateError } = require('celebrate');
|
||||||
|
|
||||||
|
const storePath = require.resolve('../../back/shared/store');
|
||||||
|
require.cache[storePath] = {
|
||||||
|
id: storePath,
|
||||||
|
filename: storePath,
|
||||||
|
loaded: true,
|
||||||
|
exports: { shareStore: { setLang: async () => {} } },
|
||||||
|
};
|
||||||
|
const { setLang, t } = require('../../back/shared/i18n');
|
||||||
|
const validationErrors =
|
||||||
|
require('../../back/middlewares/validationErrors').default;
|
||||||
|
const {
|
||||||
|
scheduleSchema,
|
||||||
|
commonCronSchema,
|
||||||
|
} = require('../../back/validation/schedule');
|
||||||
|
const upgrade = require('../../cli/src/internal/maintenance/upgradeArtifacts.cjs');
|
||||||
|
|
||||||
|
function responseFor(schema, value) {
|
||||||
|
const validation = schema.validate(value).error;
|
||||||
|
assert.ok(validation);
|
||||||
|
const error = new CelebrateError('Validation failed', { celebrated: true });
|
||||||
|
error.details.set('body', validation);
|
||||||
|
let response;
|
||||||
|
const res = {
|
||||||
|
status(code) {
|
||||||
|
assert.equal(code, 400);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
send(body) {
|
||||||
|
response = body;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
validationErrors(error, {}, res, (err) => {
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
return response.validation.body.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('compiled validation schemas follow live language changes without altering field paths', () => {
|
||||||
|
const schema = Joi.object({
|
||||||
|
schedule: scheduleSchema,
|
||||||
|
log_name: commonCronSchema.log_name,
|
||||||
|
});
|
||||||
|
for (const [lang, schedule, empty, logName] of [
|
||||||
|
[
|
||||||
|
'en',
|
||||||
|
'Invalid schedule rule',
|
||||||
|
'Schedule rule cannot be empty',
|
||||||
|
'Log name cannot exceed 100 characters',
|
||||||
|
],
|
||||||
|
['zh', '无效的定时规则', '定时规则不能为空', '日志名称不能超过100个字符'],
|
||||||
|
[
|
||||||
|
'en',
|
||||||
|
'Invalid schedule rule',
|
||||||
|
'Schedule rule cannot be empty',
|
||||||
|
'Log name cannot exceed 100 characters',
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
setLang(lang);
|
||||||
|
assert.equal(responseFor(schema, { schedule: 'invalid' }), schedule);
|
||||||
|
assert.equal(responseFor(schema, { schedule: '' }), empty);
|
||||||
|
assert.equal(
|
||||||
|
responseFor(schema, { schedule: '* * * * *', log_name: 'x'.repeat(101) }),
|
||||||
|
logName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setLang('zh');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history, upload, initialization and proxy messages resolve dynamically and preserve user text', () => {
|
||||||
|
const keys = [
|
||||||
|
'请先初始化',
|
||||||
|
'请先关闭两步验证',
|
||||||
|
'仅支持 PNG、JPEG、GIF、WebP、AVIF 图片',
|
||||||
|
'历史版本容量不足,无法安全保存',
|
||||||
|
'文件已变化,请刷新历史版本后重试',
|
||||||
|
'trust proxy 配置格式无效',
|
||||||
|
];
|
||||||
|
setLang('en');
|
||||||
|
for (const key of keys) assert.doesNotMatch(t(key), /[\u4e00-\u9fff]/);
|
||||||
|
assert.equal(t('用户脚本自己的提示'), '用户脚本自己的提示');
|
||||||
|
setLang('zh');
|
||||||
|
for (const key of keys) assert.equal(t(key), key);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upgrade verification uses the supplied language and retains artifact details', async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-locale-upgrade-'));
|
||||||
|
try {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(root, 'build-info.json'),
|
||||||
|
JSON.stringify({ version: 1, dirty: true }),
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
upgrade.verifyUpgrade(root, root, { QL_LANG: 'en' }),
|
||||||
|
/Invalid or dirty build manifest/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
upgrade.verifyUpgrade(root, root, { QL_LANG: 'zh' }),
|
||||||
|
/构建清单无效/,
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(root, 'build-info.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
dirty: false,
|
||||||
|
sourceCommit: 'a'.repeat(40),
|
||||||
|
lockfileSha256: 'b'.repeat(64),
|
||||||
|
files: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
upgrade.verifyUpgrade(root, root, { QL_LANG: 'en' }),
|
||||||
|
/Missing build output: build\/app.js/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
upgrade.upgradeMessage('更新失败: %s', { QL_LANG: 'en' }, '文件名.txt'),
|
||||||
|
'Update failed: 文件名.txt',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Shell help switches language and preserves percent characters in script paths', async () => {
|
||||||
|
const bash = fs.access('/opt/homebrew/bin/bash').then(
|
||||||
|
() => '/opt/homebrew/bin/bash',
|
||||||
|
() => 'bash',
|
||||||
|
);
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-help-%-'));
|
||||||
|
try {
|
||||||
|
const source = await fs.readFile(
|
||||||
|
path.resolve(__dirname, '../../shell/otask.sh'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const functions = path.join(root, 'functions.sh');
|
||||||
|
await fs.writeFile(
|
||||||
|
functions,
|
||||||
|
source.slice(
|
||||||
|
0,
|
||||||
|
source.indexOf('\nhandle_task_start "${task_shell_params[@]}"'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const lang of ['en', 'zh']) {
|
||||||
|
const result = spawnSync(
|
||||||
|
await bash,
|
||||||
|
[
|
||||||
|
'-c',
|
||||||
|
'. shell/share.sh; . "shell/lang/$QL_LANG.sh"; . "$2"; dir_scripts="$1"; cmd_task=task; array_scripts=(); array_scripts_name=(); usage',
|
||||||
|
'--',
|
||||||
|
root,
|
||||||
|
functions,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: path.resolve(__dirname, '../..'),
|
||||||
|
env: { ...process.env, QL_LANG: lang },
|
||||||
|
encoding: 'utf8',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.ok(result.stdout.includes(root));
|
||||||
|
if (lang === 'en') assert.doesNotMatch(result.stdout, /[\u4e00-\u9fff]/);
|
||||||
|
else assert.match(result.stdout, /暂无脚本可以执行/);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { execFile } = require('node:child_process');
|
||||||
|
const { promisify } = require('node:util');
|
||||||
|
|
||||||
|
test('language changes reach live HTTP/scheduler workers and replacement workers restore the saved language', async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-language-cluster-'));
|
||||||
|
const root = path.resolve(__dirname, '../..');
|
||||||
|
try {
|
||||||
|
const state = path.join(dir, 'language.json');
|
||||||
|
await fs.writeFile(state, '"en"');
|
||||||
|
const fixture = path.join(dir, 'cluster.cjs');
|
||||||
|
await fs.writeFile(
|
||||||
|
fixture,
|
||||||
|
`
|
||||||
|
require(${JSON.stringify(require.resolve('ts-node/register/transpile-only'))});
|
||||||
|
const cluster = require('node:cluster');
|
||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const storePath = require.resolve(${JSON.stringify(
|
||||||
|
path.join(root, 'back/shared/store.ts'),
|
||||||
|
)});
|
||||||
|
require.cache[storePath] = { id: storePath, filename: storePath, loaded: true, exports: { shareStore: {
|
||||||
|
getLang: async () => JSON.parse(await fs.readFile(process.env.LANGUAGE_STATE, 'utf8')),
|
||||||
|
setLang: (lang) => fs.writeFile(process.env.LANGUAGE_STATE, JSON.stringify(lang)),
|
||||||
|
} } };
|
||||||
|
const { initializeLanguageSync } = require(${JSON.stringify(
|
||||||
|
path.join(root, 'back/shared/languageSync.ts'),
|
||||||
|
)});
|
||||||
|
const { setLang, t } = require(${JSON.stringify(
|
||||||
|
path.join(root, 'back/shared/i18n.ts'),
|
||||||
|
)});
|
||||||
|
(async () => {
|
||||||
|
await initializeLanguageSync();
|
||||||
|
if (cluster.isPrimary) {
|
||||||
|
const ready = new Map(), observed = new Set(); let phase = 'initial';
|
||||||
|
cluster.on('message', (worker, message) => {
|
||||||
|
if (message.kind === 'ready') {
|
||||||
|
ready.set(worker.id, message.value);
|
||||||
|
if (phase === 'initial' && ready.size === 2) {
|
||||||
|
if ([...ready.values()].some(value => value !== 'Update successful')) throw Error('Initial language was not restored');
|
||||||
|
phase = 'zh'; cluster.workers[1].send({ command: 'switch', lang: 'zh' });
|
||||||
|
} else if (phase === 'replacement' && ready.size === 3) {
|
||||||
|
if (message.value !== '更新成功') throw Error('Replacement lost saved language');
|
||||||
|
phase = 'en'; observed.clear(); cluster.workers[1].send({ command: 'switch', lang: 'en' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (message.kind === 'observed') {
|
||||||
|
if (message.value !== (phase === 'zh' ? '更新成功' : 'Update successful')) throw Error('Worker language did not change');
|
||||||
|
observed.add(worker.id);
|
||||||
|
if (phase === 'zh' && observed.size === 2) { phase = 'replacement'; cluster.fork(); }
|
||||||
|
else if (phase === 'en' && observed.size === 3) {
|
||||||
|
console.log(JSON.stringify({ initialWorkers: 2, updatedWorkers: 3, replacementRestored: true }));
|
||||||
|
for (const w of Object.values(cluster.workers)) w.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cluster.fork(); cluster.fork();
|
||||||
|
} else {
|
||||||
|
process.on('message', async message => {
|
||||||
|
if (message.command === 'switch') await setLang(message.lang);
|
||||||
|
if (message.type === 'language-changed') process.send({ kind: 'observed', value: t('更新成功') });
|
||||||
|
});
|
||||||
|
process.send({ kind: 'ready', value: t('更新成功') });
|
||||||
|
}
|
||||||
|
})().catch(error => { console.error(error); process.exit(1); });
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const result = await promisify(execFile)(process.execPath, [fixture], {
|
||||||
|
cwd: root,
|
||||||
|
timeout: 15000,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TS_NODE_PROJECT: path.join(root, 'back/tsconfig.json'),
|
||||||
|
LANGUAGE_STATE: state,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(JSON.parse(result.stdout), {
|
||||||
|
initialWorkers: 2,
|
||||||
|
updatedWorkers: 3,
|
||||||
|
replacementRestored: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -38,7 +38,7 @@ test('legacy main and extra schedules restore real jobs and healthy readiness',
|
|||||||
'./data': { scheduleStacks: stacks },
|
'./data': { scheduleStacks: stacks },
|
||||||
'../shared/runCron': { runCron() {} },
|
'../shared/runCron': { runCron() {} },
|
||||||
'../loaders/logger': { info() {}, warn() {} },
|
'../loaders/logger': { info() {}, warn() {} },
|
||||||
'../shared/i18n': { tf: format },
|
'../shared/i18n': { t: (s) => s, tf: format },
|
||||||
});
|
});
|
||||||
const crons = legacySchedules.map((schedule, index) => ({
|
const crons = legacySchedules.map((schedule, index) => ({
|
||||||
id: `legacy-${index}`, name: 'legacy', schedule, command: 'true',
|
id: `legacy-${index}`, name: 'legacy', schedule, command: 'true',
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ async function fixture() {
|
|||||||
'../shared/cronScheduler': { createCronJob: (schedule, callback) => ({ start() {}, cancel() {}, fire: callback }) },
|
'../shared/cronScheduler': { createCronJob: (schedule, callback) => ({ start() {}, cancel() {}, fire: callback }) },
|
||||||
'../shared/cronSchedule': { isValidCronSchedule: x => x !== 'invalid' },
|
'../shared/cronSchedule': { isValidCronSchedule: x => x !== 'invalid' },
|
||||||
'./data': { scheduleStacks: jobs }, '../shared/runCron': { runCron }, '../loaders/logger': logger,
|
'./data': { scheduleStacks: jobs }, '../shared/runCron': { runCron }, '../loaders/logger': logger,
|
||||||
'../shared/i18n': { tf: s => s },
|
'../shared/i18n': { t: (s) => s, tf: s => s },
|
||||||
});
|
});
|
||||||
const { delCron } = load('back/schedule/delCron.ts', { './data': { scheduleStacks: jobs }, '../loaders/logger': logger });
|
const { delCron } = load('back/schedule/delCron.ts', { './data': { scheduleStacks: jobs }, '../loaders/logger': logger });
|
||||||
function add(id = '1', command = 'old', replace = false) {
|
function add(id = '1', command = 'old', replace = false) {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ test('recovery reconciles a surviving scheduler after missed deletes and disable
|
|||||||
},
|
},
|
||||||
'../shared/runCron': {},
|
'../shared/runCron': {},
|
||||||
'../loaders/logger': { info() {}, warn() {} },
|
'../loaders/logger': { info() {}, warn() {} },
|
||||||
'../shared/i18n': { tf: (s) => s },
|
'../shared/i18n': { t: (s) => s, tf: (s) => s },
|
||||||
});
|
});
|
||||||
const client = {
|
const client = {
|
||||||
addCron: async (crons, replace) => {
|
addCron: async (crons, replace) => {
|
||||||
@@ -97,7 +97,7 @@ test('invalid replacement leaves the previous schedule intact', async () => {
|
|||||||
'./data': { scheduleStacks: stacks },
|
'./data': { scheduleStacks: stacks },
|
||||||
'../shared/runCron': {},
|
'../shared/runCron': {},
|
||||||
'../loaders/logger': {},
|
'../loaders/logger': {},
|
||||||
'../shared/i18n': { tf: (s) => s },
|
'../shared/i18n': { t: (s) => s, tf: (s) => s },
|
||||||
});
|
});
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
new Promise((resolve, reject) =>
|
new Promise((resolve, reject) =>
|
||||||
@@ -122,7 +122,7 @@ test('invalid persisted schedules cannot block recovery, valid jobs or HTTP heal
|
|||||||
'./data': { scheduleStacks: stacks },
|
'./data': { scheduleStacks: stacks },
|
||||||
'../shared/runCron': {},
|
'../shared/runCron': {},
|
||||||
'../loaders/logger': { info() {}, warn() {} },
|
'../loaders/logger': { info() {}, warn() {} },
|
||||||
'../shared/i18n': { tf: require('node:util').format },
|
'../shared/i18n': { t: (s) => s, tf: require('node:util').format },
|
||||||
});
|
});
|
||||||
const client = { addCron: (crons, replace) => new Promise((resolve, reject) => {
|
const client = { addCron: (crons, replace) => new Promise((resolve, reject) => {
|
||||||
addCron({ request: { crons, replace } }, (error) => error ? reject(error) : resolve());
|
addCron({ request: { crons, replace } }, (error) => error ? reject(error) : resolve());
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ for (const branch of ['master', 'develop', 'debian', 'debian-dev'])
|
|||||||
const f = await fixture(t, { branch, lockChange: true, tee: true });
|
const f = await fixture(t, { branch, lockChange: true, tee: true });
|
||||||
let result = await f.execute('update', 'false');
|
let result = await f.execute('update', 'false');
|
||||||
assert.equal(result.code, 0, result.stdout + result.stderr);
|
assert.equal(result.code, 0, result.stdout + result.stderr);
|
||||||
|
assert.ok(result.stdout.includes(' '), 'update logs retain the completion marker');
|
||||||
assert.equal(f.read('version.yaml'), 'version: 2.21.0');
|
assert.equal(f.read('version.yaml'), 'version: 2.21.0');
|
||||||
assert.equal(fs.existsSync(path.join(f.root, 'stops')), false);
|
assert.equal(fs.existsSync(path.join(f.root, 'stops')), false);
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -295,7 +296,7 @@ test('failed health checks and concurrent upgrades are rejected', async (t) => {
|
|||||||
await withUpgradeLock(tmp, async () => {
|
await withUpgradeLock(tmp, async () => {
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
withUpgradeLock(tmp, async () => {}),
|
withUpgradeLock(tmp, async () => {}),
|
||||||
/Another upgrade/,
|
/Another upgrade|已有更新或重载/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
assert.deepEqual(fs.readdirSync(tmp), []);
|
assert.deepEqual(fs.readdirSync(tmp), []);
|
||||||
@@ -307,6 +308,6 @@ test('failed health checks and concurrent upgrades are rejected', async (t) => {
|
|||||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
waitForHealth({ QlPort: String(server.address().port) }, 20),
|
waitForHealth({ QlPort: String(server.address().port) }, 20),
|
||||||
/health check/,
|
/health check|后端启动检查失败/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const load = require('../helpers/load-security-module.cjs');
|
||||||
|
const yaml = require('js-yaml');
|
||||||
|
function service(request) {
|
||||||
|
const Service = load('back/services/system.ts', {
|
||||||
|
undici: { request },
|
||||||
|
'../config': {
|
||||||
|
versionFile: 'version.yaml',
|
||||||
|
lastVersionFile: 'https://version.example/version.yaml',
|
||||||
|
},
|
||||||
|
'../config/const': {},
|
||||||
|
'../config/util': {
|
||||||
|
parseVersion: async () => ({ version: '2.23.0' }),
|
||||||
|
parseContentVersion: yaml.load,
|
||||||
|
},
|
||||||
|
'../data/dependence': {},
|
||||||
|
'../data/notify': {},
|
||||||
|
'../data/system': {},
|
||||||
|
'../shared/pLimit': {},
|
||||||
|
'../schedule/client': {},
|
||||||
|
'../shared/i18n': { t: (key) => key },
|
||||||
|
'./notify': class {},
|
||||||
|
'./schedule': class {},
|
||||||
|
'./sock': class {},
|
||||||
|
}).default;
|
||||||
|
return new Service({}, {}, {});
|
||||||
|
}
|
||||||
|
for (const [name, request] of [
|
||||||
|
[
|
||||||
|
'unreachable server',
|
||||||
|
async () => {
|
||||||
|
throw new Error('network down');
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'HTTP error with a version-like body',
|
||||||
|
async () => ({
|
||||||
|
statusCode: 503,
|
||||||
|
body: { text: async () => 'version: 2.23.0' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'HTML response',
|
||||||
|
async () => ({
|
||||||
|
statusCode: 200,
|
||||||
|
body: { text: async () => '<html>gateway error</html>' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'invalid version',
|
||||||
|
async () => ({
|
||||||
|
statusCode: 200,
|
||||||
|
body: { text: async () => 'version: latest' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'unreadable body',
|
||||||
|
async () => ({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
text: async () => {
|
||||||
|
throw new Error('reset');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
test(`update check reports failure for ${name}, without claiming the latest version`, async () => {
|
||||||
|
assert.deepEqual(await service(request).checkUpdate(), {
|
||||||
|
code: 400,
|
||||||
|
message: '检查更新失败,请检查网络后重试',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
for (const [version, newer] of [
|
||||||
|
['2.23.0', false],
|
||||||
|
['2.23.1', true],
|
||||||
|
['2.22.0', false],
|
||||||
|
['3.0.0', true],
|
||||||
|
])
|
||||||
|
test(`valid update response ${version}`, async () => {
|
||||||
|
const result = await service(async (url, options) => {
|
||||||
|
assert.ok(options.signal instanceof AbortSignal);
|
||||||
|
assert.equal(options.signal.aborted, false);
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
text: async () => `version: ${version}\nchangeLog: release notes`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}).checkUpdate();
|
||||||
|
assert.equal(result.code, 200);
|
||||||
|
assert.equal(result.data.hasNewVersion, newer);
|
||||||
|
assert.equal(result.data.lastVersion, version);
|
||||||
|
assert.equal(result.data.lastLog, 'release notes');
|
||||||
|
});
|
||||||
|
test('a stalled version request times out instead of hanging the update check', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const checking = service(async (url, { signal }) => {
|
||||||
|
return new Promise((resolve, reject) =>
|
||||||
|
signal.addEventListener('abort', () => reject(new Error('aborted')), {
|
||||||
|
once: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}).checkUpdate();
|
||||||
|
await Promise.resolve();
|
||||||
|
t.mock.timers.tick(15000);
|
||||||
|
assert.equal((await checking).code, 400);
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const fsp = require('node:fs/promises');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
|
const { stageUpgrade } = require('../../shell/upgrade.cjs');
|
||||||
|
const {
|
||||||
|
writeManifest,
|
||||||
|
sourceCommit,
|
||||||
|
} = require('../helpers/upgrade-fixture.cjs');
|
||||||
|
async function fixture(
|
||||||
|
t,
|
||||||
|
fail,
|
||||||
|
fallbackMirror = 'gitee',
|
||||||
|
corrupt = false,
|
||||||
|
signal,
|
||||||
|
) {
|
||||||
|
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-download-fallback-'));
|
||||||
|
t.after(() => fsp.rm(base, { recursive: true, force: true }));
|
||||||
|
const root = path.join(base, 'root'),
|
||||||
|
tmp = path.join(base, 'tmp'),
|
||||||
|
payload = path.join(base, 'payload'),
|
||||||
|
source = path.join(payload, 'qinglong-' + sourceCommit),
|
||||||
|
staticRoot = path.join(payload, 'qinglong-static-develop');
|
||||||
|
for (const dir of [
|
||||||
|
root,
|
||||||
|
tmp,
|
||||||
|
path.join(root, 'node_modules'),
|
||||||
|
path.join(source, 'sample'),
|
||||||
|
path.join(staticRoot, 'build'),
|
||||||
|
])
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
for (const dir of [root, source]) {
|
||||||
|
fs.writeFileSync(path.join(dir, 'package.json'), '{}');
|
||||||
|
fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), 'lock');
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(source, 'sample/config.sample.sh'), 'sample');
|
||||||
|
fs.writeFileSync(path.join(staticRoot, 'build/app.js'), 'backend');
|
||||||
|
writeManifest(source, staticRoot);
|
||||||
|
if (corrupt)
|
||||||
|
fs.writeFileSync(path.join(staticRoot, 'build/app.js'), 'tampered');
|
||||||
|
for (const repo of ['qinglong-static', 'qinglong'])
|
||||||
|
execFileSync(
|
||||||
|
'zip',
|
||||||
|
[
|
||||||
|
'-qr',
|
||||||
|
path.join(base, repo + '.zip'),
|
||||||
|
repo === 'qinglong-static'
|
||||||
|
? 'qinglong-static-develop'
|
||||||
|
: 'qinglong-' + sourceCommit,
|
||||||
|
],
|
||||||
|
{ cwd: payload },
|
||||||
|
);
|
||||||
|
const urls = [];
|
||||||
|
const operation = stageUpgrade({
|
||||||
|
root,
|
||||||
|
tmp,
|
||||||
|
env: { QL_BRANCH: 'develop' },
|
||||||
|
mirror: 'github',
|
||||||
|
fallbackMirror,
|
||||||
|
signal,
|
||||||
|
install: async () => assert.fail('unchanged dependencies'),
|
||||||
|
run: async (program, args) => {
|
||||||
|
if (program === 'curl') {
|
||||||
|
const url = args.at(-1);
|
||||||
|
urls.push(url);
|
||||||
|
assert.ok(args.includes('--speed-time'));
|
||||||
|
if (fail(url)) throw new Error('download failed');
|
||||||
|
const repo = url.includes('/qinglong-static/')
|
||||||
|
? 'qinglong-static'
|
||||||
|
: 'qinglong';
|
||||||
|
fs.copyFileSync(
|
||||||
|
path.join(base, repo + '.zip'),
|
||||||
|
args[args.indexOf('--output') + 1],
|
||||||
|
);
|
||||||
|
return { stdout: '' };
|
||||||
|
}
|
||||||
|
return { stdout: execFileSync(program, args, { encoding: 'utf8' }) };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { operation, urls, tmp };
|
||||||
|
}
|
||||||
|
test('automatic source selection falls back after GitHub network failure and pins the matching source commit', async (t) => {
|
||||||
|
const f = await fixture(t, (url) => url.startsWith('https://codeload.'));
|
||||||
|
await f.operation;
|
||||||
|
assert.equal(f.urls.length, 4);
|
||||||
|
assert.match(f.urls[1], /gitee.com.*qinglong-static.*develop/);
|
||||||
|
assert.ok(f.urls[3].includes(sourceCommit));
|
||||||
|
assert.ok(fs.existsSync(path.join(f.tmp, 'upgrade-ready-develop.json')));
|
||||||
|
});
|
||||||
|
test('source-only network failure keeps the manifest commit when changing mirrors', async (t) => {
|
||||||
|
const f = await fixture(
|
||||||
|
t,
|
||||||
|
(url) => url.startsWith('https://codeload.') && url.includes('/qinglong/'),
|
||||||
|
);
|
||||||
|
await f.operation;
|
||||||
|
assert.equal(f.urls.length, 3);
|
||||||
|
assert.ok(f.urls[2].endsWith(sourceCommit + '.zip'));
|
||||||
|
});
|
||||||
|
test('explicit CLI GitHub selection does not switch providers', async (t) => {
|
||||||
|
const f = await fixture(t, () => true, null);
|
||||||
|
await assert.rejects(f.operation, /download failed/);
|
||||||
|
assert.equal(f.urls.length, 1);
|
||||||
|
assert.deepEqual(fs.readdirSync(f.tmp), []);
|
||||||
|
});
|
||||||
|
test('checksum validation failure never retries from another mirror', async (t) => {
|
||||||
|
const f = await fixture(t, () => false, 'gitee', true);
|
||||||
|
await assert.rejects(f.operation, /checksum mismatch|构建文件缺失、损坏或混入旧文件/);
|
||||||
|
assert.equal(f.urls.length, 2);
|
||||||
|
assert.deepEqual(fs.readdirSync(f.tmp), []);
|
||||||
|
});
|
||||||
|
test('cancellation does not trigger mirror failover', async (t) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const f = await fixture(
|
||||||
|
t,
|
||||||
|
() => {
|
||||||
|
controller.abort(new Error('cancelled'));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
'gitee',
|
||||||
|
false,
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
await assert.rejects(f.operation, /cancelled/);
|
||||||
|
assert.equal(f.urls.length, 1);
|
||||||
|
});
|
||||||
@@ -88,7 +88,7 @@ for (const legacy of [true, false])
|
|||||||
try {
|
try {
|
||||||
await entered;
|
await entered;
|
||||||
const contender = await Promise.race(attempts);
|
const contender = await Promise.race(attempts);
|
||||||
assert.match(contender.error?.message || '', /Another upgrade/);
|
assert.match(contender.error?.message || '', /Another upgrade|已有更新或重载/);
|
||||||
assert.equal(maximum, 1);
|
assert.equal(maximum, 1);
|
||||||
assert.equal((await original.readdir(lock)).length, 1);
|
assert.equal((await original.readdir(lock)).length, 1);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -106,7 +106,7 @@ test('an empty abandoned lock is acquired atomically and a failed operation rele
|
|||||||
withUpgradeLock(tmp, async () => {
|
withUpgradeLock(tmp, async () => {
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
withUpgradeLock(tmp, async () => assert.fail('concurrent operation')),
|
withUpgradeLock(tmp, async () => assert.fail('concurrent operation')),
|
||||||
/Another upgrade/,
|
/Another upgrade|已有更新或重载/,
|
||||||
);
|
);
|
||||||
throw new Error('operation failed');
|
throw new Error('operation failed');
|
||||||
}),
|
}),
|
||||||
@@ -169,7 +169,7 @@ withUpgradeLock(process.argv[2], async () => {
|
|||||||
await withUpgradeLock(tmp, async () => {
|
await withUpgradeLock(tmp, async () => {
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
withUpgradeLock(tmp, async () => {}),
|
withUpgradeLock(tmp, async () => {}),
|
||||||
/Another upgrade/,
|
/Another upgrade|已有更新或重载/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
assert.deepEqual(await fs.readdir(tmp), ['worker.cjs']);
|
assert.deepEqual(await fs.readdir(tmp), ['worker.cjs']);
|
||||||
@@ -235,7 +235,7 @@ test(
|
|||||||
res.once('close', () => clearInterval(timer));
|
res.once('close', () => clearInterval(timer));
|
||||||
});
|
});
|
||||||
const began = Date.now();
|
const began = Date.now();
|
||||||
await assert.rejects(waitForHealth({ QlPort: port }, 80), /health check/);
|
await assert.rejects(waitForHealth({ QlPort: port }, 80), /health check|后端启动检查失败/);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
Date.now() - began < 1000,
|
Date.now() - began < 1000,
|
||||||
'deadline must not depend on socket inactivity',
|
'deadline must not depend on socket inactivity',
|
||||||
@@ -280,6 +280,6 @@ for (const streaming of [false, true])
|
|||||||
test('unhealthy, malformed and oversized health responses fail within the deadline', async (t) => {
|
test('unhealthy, malformed and oversized health responses fail within the deadline', async (t) => {
|
||||||
for (const body of ['{}', '{', ' '.repeat(65537)]) {
|
for (const body of ['{}', '{', ' '.repeat(65537)]) {
|
||||||
const port = await healthServer(t, (req, res) => res.end(body));
|
const port = await healthServer(t, (req, res) => res.end(body));
|
||||||
await assert.rejects(waitForHealth({ QlPort: port }, 20), /health check/);
|
await assert.rejects(waitForHealth({ QlPort: port }, 20), /health check|后端启动检查失败/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const load = require('../helpers/load-security-module.cjs');
|
||||||
|
for (const [locale, expected] of [
|
||||||
|
['en', 'i-default'],
|
||||||
|
['zh', 'zh-cn'],
|
||||||
|
])
|
||||||
|
test(`Monaco uses the matching bundled locale for ${locale}`, () => {
|
||||||
|
let options;
|
||||||
|
const { init } = load('src/utils/init.ts', {
|
||||||
|
'@monaco-editor/react': {
|
||||||
|
loader: {
|
||||||
|
config: (value) => {
|
||||||
|
options = value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'./config': { baseUrl: '/panel/' },
|
||||||
|
'react-intl-universal': {
|
||||||
|
getInitOptions: () => ({ currentLocale: locale }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
init('2.23.0');
|
||||||
|
assert.equal(options.paths.vs, '/panel/monaco-editor/min/vs');
|
||||||
|
assert.equal(options['vs/nls'].availableLanguages['*'], expected);
|
||||||
|
const fs = require('node:fs');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(
|
||||||
|
'node_modules/monaco-editor/min/vs/editor/editor.main.nls' +
|
||||||
|
(expected === 'i-default' ? '' : '.' + expected) +
|
||||||
|
'.js',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const ts = require('typescript');
|
||||||
|
// Drive the real component's handlers and effects without a browser or network.
|
||||||
|
function panel() {
|
||||||
|
let cursor = 0,
|
||||||
|
tree,
|
||||||
|
subscriber;
|
||||||
|
const hooks = [],
|
||||||
|
effects = [],
|
||||||
|
cleanups = [],
|
||||||
|
confirms = [],
|
||||||
|
infos = [],
|
||||||
|
timers = [],
|
||||||
|
responses = [];
|
||||||
|
const react = {
|
||||||
|
useState(initial) {
|
||||||
|
const i = cursor++;
|
||||||
|
if (!(i in hooks)) hooks[i] = initial;
|
||||||
|
return [
|
||||||
|
hooks[i],
|
||||||
|
(v) => {
|
||||||
|
hooks[i] = typeof v === 'function' ? v(hooks[i]) : v;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
useRef(initial) {
|
||||||
|
const i = cursor++;
|
||||||
|
return (hooks[i] ||= { current: initial });
|
||||||
|
},
|
||||||
|
useCallback(fn) {
|
||||||
|
cursor++;
|
||||||
|
return fn;
|
||||||
|
},
|
||||||
|
useEffect(fn, deps) {
|
||||||
|
const i = cursor++;
|
||||||
|
if (!hooks[i] || deps.some((v, j) => !Object.is(v, hooks[i][j]))) {
|
||||||
|
hooks[i] = deps;
|
||||||
|
effects.push(fn);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
function modal(options, list) {
|
||||||
|
const result = {
|
||||||
|
options,
|
||||||
|
update(patch) {
|
||||||
|
Object.assign(options, patch);
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
options.afterClose?.();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
list.push(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const mocks = {
|
||||||
|
'@/utils': { disableBody() {} },
|
||||||
|
'@/utils/config': { apiPrefix: '/api/' },
|
||||||
|
'@/utils/http': {
|
||||||
|
request: {
|
||||||
|
put: async (url) =>
|
||||||
|
responses.length
|
||||||
|
? responses.shift()(url)
|
||||||
|
: {
|
||||||
|
code: 200,
|
||||||
|
data: { hasNewVersion: false, lastVersion: '2.23.0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'@/utils/websocket': {
|
||||||
|
getInstance: () => ({
|
||||||
|
subscribe(topic, fn) {
|
||||||
|
subscriber = fn;
|
||||||
|
},
|
||||||
|
unsubscribe() {
|
||||||
|
subscriber = undefined;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'ansi-to-react': () => null,
|
||||||
|
antd: {
|
||||||
|
Button: 'button',
|
||||||
|
Statistic: { Countdown: 'span' },
|
||||||
|
message: { loading() {}, destroy() {}, error() {}, success() {} },
|
||||||
|
Modal: {
|
||||||
|
confirm: (o) => modal(o, confirms),
|
||||||
|
info: (o) => modal(o, infos),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
react,
|
||||||
|
'react-intl-universal': { get: (key) => key },
|
||||||
|
};
|
||||||
|
const module = { exports: {} };
|
||||||
|
const compiled = ts.transpileModule(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../src/pages/setting/checkUpdate.tsx'),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
{
|
||||||
|
compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS,
|
||||||
|
target: ts.ScriptTarget.ES2020,
|
||||||
|
jsx: ts.JsxEmit.ReactJSX,
|
||||||
|
esModuleInterop: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).outputText;
|
||||||
|
new Function(
|
||||||
|
'require',
|
||||||
|
'module',
|
||||||
|
'exports',
|
||||||
|
'setTimeout',
|
||||||
|
'document',
|
||||||
|
compiled,
|
||||||
|
)(
|
||||||
|
(name) => mocks[name] || require(name),
|
||||||
|
module,
|
||||||
|
module.exports,
|
||||||
|
(fn) => {
|
||||||
|
timers.push(fn);
|
||||||
|
},
|
||||||
|
{ querySelector: () => null },
|
||||||
|
);
|
||||||
|
function render() {
|
||||||
|
cursor = 0;
|
||||||
|
tree = module.exports.default({ systemInfo: { version: '2.23.0' } });
|
||||||
|
while (effects.length) {
|
||||||
|
const cleanup = effects.shift()();
|
||||||
|
if (cleanup) cleanups.push(cleanup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function settle() {
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
return {
|
||||||
|
confirms,
|
||||||
|
infos,
|
||||||
|
responses,
|
||||||
|
timers,
|
||||||
|
settle,
|
||||||
|
async start() {
|
||||||
|
tree.props.children[0].props.onClick();
|
||||||
|
await settle();
|
||||||
|
confirms.at(-1).options.onOk();
|
||||||
|
await settle();
|
||||||
|
return infos.at(-1);
|
||||||
|
},
|
||||||
|
async send(payload) {
|
||||||
|
subscriber?.(payload);
|
||||||
|
await settle();
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
infos.at(-1).destroy();
|
||||||
|
},
|
||||||
|
unmount() {
|
||||||
|
cleanups.forEach((fn) => fn());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
for (const logged of [false, true])
|
||||||
|
test(`failed final update event unlocks the modal without an additional log; logged=${logged}`, async () => {
|
||||||
|
const p = panel(),
|
||||||
|
m = await p.start();
|
||||||
|
if (logged) await p.send({ message: 'download failed\n' });
|
||||||
|
assert.equal(m.options.okButtonProps.disabled, true);
|
||||||
|
await p.send({ message: '', status: 'failed' });
|
||||||
|
assert.equal(m.options.okButtonProps.disabled, false);
|
||||||
|
assert.equal(m.options.closable, true);
|
||||||
|
});
|
||||||
|
for (const rejected of [false, true])
|
||||||
|
test(`update start request failure unlocks modal; rejected=${rejected}`, async () => {
|
||||||
|
const p = panel();
|
||||||
|
p.responses.push(
|
||||||
|
async () => ({ code: 200, data: { hasNewVersion: false } }),
|
||||||
|
async () => {
|
||||||
|
if (rejected) throw new Error('offline');
|
||||||
|
return { code: 400, message: 'busy' };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const m = await p.start();
|
||||||
|
assert.equal(m.options.okButtonProps.disabled, false);
|
||||||
|
assert.equal(m.options.closable, true);
|
||||||
|
});
|
||||||
|
test('retry clears the previous failure and ignores notifications without an active modal', async () => {
|
||||||
|
const p = panel();
|
||||||
|
await p.send({ status: 'success' });
|
||||||
|
assert.equal(p.timers.length, 0);
|
||||||
|
await p.start();
|
||||||
|
await p.send({ status: 'failed', message: '' });
|
||||||
|
p.close();
|
||||||
|
const retry = await p.start();
|
||||||
|
assert.equal(retry.options.okButtonProps.disabled, true);
|
||||||
|
assert.equal(retry.options.closable, false);
|
||||||
|
});
|
||||||
|
test('successful completion prompts restart once and cancel leaves the log dismissible', async () => {
|
||||||
|
const p = panel(),
|
||||||
|
m = await p.start();
|
||||||
|
await p.send({ status: 'success', message: '' });
|
||||||
|
await p.send({ status: 'success', message: '' });
|
||||||
|
p.timers.splice(0).forEach((fn) => fn());
|
||||||
|
assert.equal(p.confirms.length, 2);
|
||||||
|
p.confirms.at(-1).options.onCancel();
|
||||||
|
assert.equal(m.options.okButtonProps.disabled, false);
|
||||||
|
assert.equal(m.options.closable, true);
|
||||||
|
});
|
||||||
|
test('unmount removes the modal and suppresses a pending restart prompt', async () => {
|
||||||
|
const p = panel();
|
||||||
|
await p.start();
|
||||||
|
await p.send({ status: 'success' });
|
||||||
|
p.unmount();
|
||||||
|
p.timers.splice(0).forEach((fn) => fn());
|
||||||
|
assert.equal(p.confirms.length, 1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user