Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andwhyour c17dbdc28f Address code review feedback - improve test robustness and documentation clarity
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-07 16:23:34 +00:00
copilot-swe-agent[bot]andwhyour da8089aaac Add documentation, tests, and comments for QLAPI.getEnvs functionality
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-07 16:20:35 +00:00
copilot-swe-agent[bot] c063619763 Initial plan 2025-11-07 16:06:32 +00:00
whyour 73f8f3c5fa 增加 agent
Create a new agent configuration file for QL agent.
2025-11-08 00:05:56 +08:00
whyour 494ccb3c08 更新 workflow 2025-11-03 00:09:36 +08:00
whyour 399728b433 修复 jwt 认证 2025-11-02 22:28:58 +08:00
whyour 52a1de5063 更新 demo 地址 2025-11-02 21:54:19 +08:00
whyour 18f27a9a69 移除 nginx 2025-11-02 19:29:59 +08:00
whyour 07951964a1 修复模块注入 2025-10-26 22:32:03 +08:00
涛之雨andGitHub a1f888af59 Add validation to dependencies GET endpoint and update service logic (#2778)
* Add validation to dependencies GET endpoint and update service logic

* fix https://github.com/whyour/qinglong/pull/2778/files/6063bc3a67fb329de9b90f7c93524b862bd9eb93#r2266494581

* remove default condition type

* fix query mistakes
2025-10-11 23:23:13 +08:00
涛之雨andGitHub f7472b6e74 Add input validation to script API routes (#2777)
* Add input validation to script API routes

* 优化脚本 API 路由的错误处理逻辑

* Fix optional path compatibility checks

* remove file
2025-10-11 23:20:26 +08:00
whyour a7baeba755 修复 task 命令可能软链失败 2025-10-11 23:15:54 +08:00
涛之雨andGitHub e4f733320d Enable debug backend (#2776) 2025-08-19 11:13:33 +08:00
whyour 55c92dc320 发布版本 v2.19.2 2025-07-12 20:29:49 +08:00
whyour 50769c43dd 修复 command-run 接口日志绑定 2025-07-12 20:29:44 +08:00
whyour 0587644a6b command-run 增加返回 QL-Task-Log 日志路径 2025-06-28 01:05:05 +08:00
whyour 87b934aafe QLAPI.systemNotify 支持自定义通知类型和参数 2025-06-24 02:00:51 +08:00
whyour 7a92e7c6ab 修复取消安装依赖 2025-06-22 21:47:39 +08:00
whyour 1d8403c0ec 修复环境变量过大解析 2025-06-22 21:10:55 +08:00
whyour ef9e38f167 备份数据支持选择模块,支持清除依赖缓存 2025-06-22 14:25:19 +08:00
whyour c9bd053fbd 修改服务启动方式 2025-06-11 00:42:29 +08:00
憶夣andGitHub 57939391b9 ntfy 增加可选的认证与用户动作 (#2741)
* feat:ntfy增加可选的认证

* feat:ntfy增加可选的用户动作

* fix:ntfy动作包含中文报错
2025-06-07 00:26:27 +08:00
whyour 394e96bbf8 修复 health 接口报错 2025-06-07 00:25:47 +08:00
whyour 47c194c1f4 更新版本 v2.19.1 2025-05-24 15:03:08 +08:00
whyour 7d65d96ebd 修复 demo 环境提示 2025-05-24 14:56:49 +08:00
whyour 224000b63b 修复依赖是否安装检查逻辑 2025-05-23 23:45:43 +08:00
whyour 1c18668bad 修复文件下载参数 2025-05-22 00:09:19 +08:00
whyour f94582b68d 修复查询 python 依赖存在逻辑 2025-05-21 01:25:24 +08:00
whyour eb1c00984c 修复任务视图状态包含筛选 2025-05-20 23:40:18 +08:00
whyour 1a185f5682 修复创建脚本可能失败 2025-05-20 01:00:08 +08:00
85 changed files with 3430 additions and 1124 deletions
+1 -2
View File
@@ -1,6 +1,5 @@
GRPC_PORT=5500
BACK_PORT=5600
PORT=5700
BACK_PORT=5700
LOG_LEVEL='info'
+4
View File
@@ -0,0 +1,4 @@
---
name: Bug Fixer
description: Fix this issue following our error handling pattern.
---
@@ -20,12 +20,14 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wearerequired/git-mirror-action@v1
env:
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
- uses: Yikun/hub-mirror-action@master
with:
source-repo: https://github.com/whyour/qinglong.git
destination-repo: git@gitlab.com:whyour/qinglong.git
src: github/whyour
dst: gitlab/whyour
dst_key: ${{ secrets.GITLAB_SSH_PK }}
dst_token: ${{ secrets.GITLAB_TOKEN }}
static_list: "qinglong"
force_update: true
code_gitee:
runs-on: ubuntu-latest
@@ -33,12 +35,14 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wearerequired/git-mirror-action@v1
env:
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
- uses: Yikun/hub-mirror-action@master
with:
source-repo: https://github.com/whyour/qinglong.git
destination-repo: git@gitee.com:whyour/qinglong.git
src: github/whyour
dst: gitee/whyour
dst_key: ${{ secrets.GITLAB_SSH_PK }}
dst_token: ${{ secrets.GITEE_TOKEN }}
static_list: "qinglong"
force_update: true
build-static:
runs-on: ubuntu-latest
@@ -82,12 +86,14 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wearerequired/git-mirror-action@v1
env:
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
- uses: Yikun/hub-mirror-action@master
with:
source-repo: https://github.com/whyour/qinglong-static.git
destination-repo: git@gitlab.com:whyour/qinglong-static.git
src: github/whyour
dst: gitlab/whyour
dst_key: ${{ secrets.GITLAB_SSH_PK }}
dst_token: ${{ secrets.GITLAB_TOKEN }}
static_list: "qinglong-static"
force_update: true
static_gitee:
needs: build-static
@@ -96,12 +102,14 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wearerequired/git-mirror-action@v1
env:
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
- uses: Yikun/hub-mirror-action@master
with:
source-repo: https://github.com/whyour/qinglong-static.git
destination-repo: git@gitee.com:whyour/qinglong-static.git
src: github/whyour
dst: gitee/whyour
dst_key: ${{ secrets.GITLAB_SSH_PK }}
dst_token: ${{ secrets.GITEE_TOKEN }}
static_list: "qinglong-static"
force_update: true
build:
needs: build-static
+1 -1
View File
@@ -17,7 +17,7 @@ export default defineConfig({
publicPath: process.env.NODE_ENV === 'production' ? './' : '/',
proxy: {
[`${baseUrl}api`]: {
target: 'http://127.0.0.1:5600/',
target: 'http://127.0.0.1:5700/',
changeOrigin: true,
ws: true,
pathRewrite: { [`^${baseUrl}api`]: '/api' },
+2 -2
View File
@@ -18,9 +18,9 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
[Demo](http://demo.ninesix.cc:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
[Demo](http://demo.qinglong.online:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
[演示](http://demo.ninesix.cc:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
[演示](http://demo.qinglong.online:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
</div>
![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png)
+2 -2
View File
@@ -20,9 +20,9 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
[Demo](http://demo.ninesix.cc:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
[Demo](http://demo.qinglong.online:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
[演示](http://demo.ninesix.cc:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
[演示](http://demo.qinglong.online:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
</div>
![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png)
+13 -2
View File
@@ -8,7 +8,17 @@ const route = Router();
export default (app: Router) => {
app.use('/dependencies', route);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
route.get(
'/',
celebrate({
query:
Joi.object({
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
@@ -18,7 +28,8 @@ export default (app: Router) => {
logger.error('🔥 error: %o', e);
return next(e);
}
});
},
);
route.post(
'/',
+51 -7
View File
@@ -24,7 +24,14 @@ const upload = multer({ storage: storage });
export default (app: Router) => {
app.use('/scripts', route);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
route.get(
'/',
celebrate({
query: Joi.object({
path: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let result: IFile[] = [];
@@ -68,11 +75,17 @@ export default (app: Router) => {
route.get(
'/detail',
celebrate({
query: Joi.object({
path: Joi.string().optional().allow(''),
file: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scriptService = Container.get(ScriptService);
const content = await scriptService.getFile(
req.query.path as string,
req.query?.path as string || '',
req.query.file as string,
);
res.send({ code: 200, data: content });
@@ -84,11 +97,19 @@ export default (app: Router) => {
route.get(
'/:file',
celebrate({
params: Joi.object({
file: Joi.string().required(),
}),
query: Joi.object({
path: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scriptService = Container.get(ScriptService);
const content = await scriptService.getFile(
req.query.path as string,
req.query?.path as string || '',
req.params.file,
);
res.send({ code: 200, data: content });
@@ -101,6 +122,15 @@ export default (app: Router) => {
route.post(
'/',
upload.single('file'),
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
content: Joi.string().optional().allow(''),
originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path, content, originFilename, directory } =
@@ -201,7 +231,7 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().allow(''),
path: Joi.string().optional().allow(''),
type: Joi.string().optional(),
}),
}),
@@ -211,6 +241,9 @@ export default (app: Router) => {
filename: string;
path: string;
};
if (!path) {
path = '';
}
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
@@ -232,7 +265,7 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().allow(''),
path: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -241,6 +274,9 @@ export default (app: Router) => {
filename: string;
path: string;
};
if (!path) {
path = '';
}
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
@@ -273,6 +309,9 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
let { filename, content, path } = req.body;
if (!path) {
path = '';
}
const { name, ext } = parse(filename);
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
await writeFileWithLock(filePath, content || '');
@@ -298,6 +337,9 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path, pid } = req.body;
if (!path) {
path = '';
}
const { name, ext } = parse(filename);
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
const logPath = join(config.logPath, path, `${name}.swap`);
@@ -325,12 +367,14 @@ export default (app: Router) => {
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path, type, newFilename } = req.body as {
let { filename, path, newFilename } = req.body as {
filename: string;
path: string;
type: string;
newFilename: string;
};
if (!path) {
path = '';
}
const filePath = join(config.scriptPath, path, filename);
const newPath = join(config.scriptPath, path, newFilename);
await fs.rename(filePath, newPath);
+29 -5
View File
@@ -273,19 +273,20 @@ export default (app: Router) => {
{
onStart: async (cp, startTime) => {
res.setHeader('QL-Task-Pid', `${cp.pid}`);
res.setHeader('QL-Task-Log', `${logPath}`);
},
onEnd: async (cp, endTime, diff) => {
res.end();
},
onError: async (message: string) => {
res.write(`\n${message}`);
res.write(message);
const absolutePath = await handleLogPath(logPath);
await fs.appendFile(absolutePath, `\n${message}`);
await fs.appendFile(absolutePath, message);
},
onLog: async (message: string) => {
res.write(`\n${message}`);
res.write(message);
const absolutePath = await handleLogPath(logPath);
await fs.appendFile(absolutePath, `\n${message}`);
await fs.appendFile(absolutePath, message);
},
},
);
@@ -316,10 +317,15 @@ export default (app: Router) => {
route.put(
'/data/export',
celebrate({
body: Joi.object({
type: Joi.array().items(Joi.string()).optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
await systemService.exportData(res);
await systemService.exportData(res, req.body.type);
} catch (e) {
return next(e);
}
@@ -416,4 +422,22 @@ export default (app: Router) => {
}
},
);
route.put(
'/config/dependence-clean',
celebrate({
body: Joi.object({
type: Joi.string().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.cleanDependence(req.body.type);
res.send(result);
} catch (e) {
return next(e);
}
},
);
};
+178 -32
View File
@@ -1,4 +1,5 @@
import 'reflect-metadata';
import cluster, { type Worker } from 'cluster';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
@@ -10,76 +11,142 @@ import { monitoringMiddleware } from './middlewares/monitoring';
import { type GrpcServerService } from './services/grpc';
import { type HttpServerService } from './services/http';
interface WorkerMetadata {
id: number;
pid: number;
serviceType: string;
startTime: Date;
}
class Application {
private app: express.Application;
private httpServerService?: HttpServerService;
private grpcServerService?: GrpcServerService;
private isShuttingDown = false;
private workerMetadataMap = new Map<number, WorkerMetadata>();
constructor() {
this.app = express();
// 创建一个全局中间件,删除查询参数中的t
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.query.t) {
delete req.query.t;
}
next();
});
}
async start() {
try {
if (cluster.isPrimary) {
await this.initializeDatabase();
await this.initServer();
this.setupMiddlewares();
await this.initializeServices();
this.setupGracefulShutdown();
process.send?.('ready');
}
if (cluster.isPrimary) {
this.startMasterProcess();
} else {
await this.startWorkerProcess();
}
} catch (error) {
Logger.error('Failed to start application:', error);
process.exit(1);
}
}
async initServer() {
const { HttpServerService } = await import('./services/http');
const { GrpcServerService } = await import('./services/grpc');
this.httpServerService = Container.get(HttpServerService);
this.grpcServerService = Container.get(GrpcServerService);
private startMasterProcess() {
this.forkWorker('http');
this.forkWorker('grpc');
cluster.on('exit', (worker, code, signal) => {
const metadata = this.workerMetadataMap.get(worker.id);
if (metadata) {
if (!this.isShuttingDown) {
Logger.error(
`${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
}). Restarting...`,
);
const newWorker = this.forkWorker(metadata.serviceType);
Logger.info(
`Restarted ${metadata.serviceType} worker (New PID: ${newWorker.process.pid})`,
);
}
this.workerMetadataMap.delete(worker.id);
}
});
this.setupMasterShutdown();
}
private forkWorker(serviceType: string): Worker {
const worker = cluster.fork({ SERVICE_TYPE: serviceType });
this.workerMetadataMap.set(worker.id, {
id: worker.id,
pid: worker.process.pid!,
serviceType,
startTime: new Date(),
});
return worker;
}
private async initializeDatabase() {
await require('./loaders/db').default();
const dbLoader = await import('./loaders/db');
await dbLoader.default();
}
private setupMiddlewares() {
this.app.use(helmet());
this.app.use(helmet({
contentSecurityPolicy: false,
}));
this.app.use(cors(config.cors));
this.app.use(compression());
this.app.use(monitoringMiddleware);
}
private async initializeServices() {
await this.grpcServerService?.initialize();
await require('./loaders/app').default({ app: this.app });
const server = await this.httpServerService?.initialize(
this.app,
config.port,
);
await require('./loaders/server').default({ server });
}
private setupGracefulShutdown() {
private setupMasterShutdown() {
const shutdown = async () => {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
Logger.info('Shutting down services...');
const workers = Object.values(cluster.workers || {});
const workerPromises: Promise<void>[] = [];
workers.forEach((worker) => {
if (worker) {
const exitPromise = new Promise<void>((resolve) => {
worker.once('exit', () => {
Logger.info(`Worker ${worker.process.pid} exited`);
resolve();
});
try {
await Promise.all([
this.grpcServerService?.shutdown(),
this.httpServerService?.shutdown(),
worker.send('shutdown');
} catch (error) {
Logger.warn(
`Failed to send shutdown to worker ${worker.process.pid}:`,
error,
);
}
});
workerPromises.push(exitPromise);
}
});
try {
await Promise.race([
Promise.all(workerPromises),
new Promise<void>((resolve) => {
setTimeout(() => {
Logger.warn('Worker shutdown timeout reached');
resolve();
}, 10000);
}),
]);
process.exit(0);
} catch (error) {
Logger.error('Error during shutdown:', error);
Logger.error('Error during worker shutdown:', error);
process.exit(1);
}
};
@@ -87,6 +154,85 @@ class Application {
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
private async startWorkerProcess() {
const serviceType = process.env.SERVICE_TYPE;
if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
Logger.error('Invalid SERVICE_TYPE:', serviceType);
process.exit(1);
}
Logger.info(`✌️ ${serviceType} worker started (PID: ${process.pid})`);
try {
if (serviceType === 'http') {
await this.startHttpService();
} else {
await this.startGrpcService();
}
process.send?.('ready');
} catch (error) {
Logger.error(`${serviceType} worker failed:`, error);
process.exit(1);
}
}
private async startHttpService() {
this.setupMiddlewares();
const { HttpServerService } = await import('./services/http');
this.httpServerService = Container.get(HttpServerService);
const appLoader = await import('./loaders/app');
await appLoader.default({ app: this.app });
const server = await this.httpServerService.initialize(
this.app,
config.port,
);
const serverLoader = await import('./loaders/server');
await (serverLoader.default as any)({ server });
this.setupWorkerShutdown('http');
}
private async startGrpcService() {
const { GrpcServerService } = await import('./services/grpc');
this.grpcServerService = Container.get(GrpcServerService);
await this.grpcServerService.initialize();
this.setupWorkerShutdown('grpc');
}
private setupWorkerShutdown(serviceType: string) {
process.on('message', (msg) => {
if (msg === 'shutdown') {
this.gracefulShutdown(serviceType);
}
});
const shutdown = () => this.gracefulShutdown(serviceType);
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
private async gracefulShutdown(serviceType: string) {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
try {
if (serviceType === 'http') {
await this.httpServerService?.shutdown();
} else {
await this.grpcServerService?.shutdown();
}
process.exit(0);
} catch (error) {
Logger.error(`[${serviceType}] Error during shutdown:`, error);
process.exit(1);
}
}
}
const app = new Application();
+24
View File
@@ -25,3 +25,27 @@ export const SAMPLE_FILES = [
];
export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME;
export const NotificationModeStringMap = {
0: 'gotify',
1: 'goCqHttpBot',
2: 'serverChan',
3: 'pushDeer',
4: 'bark',
5: 'chat',
6: 'telegramBot',
7: 'dingtalkBot',
8: 'weWorkBot',
9: 'weWorkApp',
10: 'aibotk',
11: 'iGot',
12: 'pushPlus',
13: 'wePlusBot',
14: 'email',
15: 'pushMe',
16: 'feishu',
17: 'webhook',
18: 'chronocat',
19: 'ntfy',
20: 'wxPusherBot',
} as const;
+4 -2
View File
@@ -29,7 +29,7 @@ interface Config {
}
const config: Config = {
port: parseInt(process.env.BACK_PORT || '5600', 10),
port: parseInt(process.env.BACK_PORT || '5700', 10),
grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10),
nodeEnv: process.env.NODE_ENV || 'development',
isDevelopment: process.env.NODE_ENV === 'development',
@@ -41,7 +41,7 @@ const config: Config = {
prefix: '/api',
},
jwt: {
secret: process.env.JWT_SECRET || createRandomString(16, 32),
secret: process.env.JWT_SECRET || 'whyour-secret',
expiresIn: process.env.JWT_EXPIRES_IN,
},
cors: {
@@ -86,6 +86,7 @@ const dbPath = path.join(dataPath, 'db/');
const uploadPath = path.join(dataPath, 'upload/');
const sshdPath = path.join(dataPath, 'ssh.d/');
const systemLogPath = path.join(dataPath, 'syslog/');
const dependenceCachePath = path.join(dataPath, 'dep_cache/');
const envFile = path.join(preloadPath, 'env.sh');
const jsEnvFile = path.join(preloadPath, 'env.js');
@@ -174,4 +175,5 @@ export default {
sqliteFile,
sshdPath,
systemLogPath,
dependenceCachePath,
};
+21
View File
@@ -514,6 +514,27 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
}
}
export function getGetCommand(type: DependenceTypes, name: string): string {
const baseCommands = {
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`,
[DependenceTypes.python3]: `
python3 -c "exec('''
name='${name}'
try:
from importlib.metadata import version
print(version(name))
except:
import importlib.util as u
import importlib.metadata as m
spec=u.find_spec(name)
print(name if spec else '')
''')"`,
[DependenceTypes.linux]: `apk info -es ${name}`,
};
return baseCommands[type];
}
export function getInstallCommand(type: DependenceTypes, name: string): string {
const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm add -g',
-6
View File
@@ -41,12 +41,6 @@ export enum DependenceTypes {
'linux',
}
export enum GetDependenceCommandTypes {
'pnpm ls -g ',
'pip3 show --disable-pip-version-check',
'apk info -es',
}
export enum versionDependenceCommandTypes {
'@',
'==',
+4 -2
View File
@@ -1,5 +1,3 @@
import { IncomingHttpHeaders } from 'http';
export enum NotificationMode {
'gotify' = 'gotify',
'goCqHttpBot' = 'goCqHttpBot',
@@ -150,6 +148,10 @@ export class NtfyNotification extends NotificationBaseInfo {
public ntfyUrl = '';
public ntfyTopic = '';
public ntfyPriority = '';
public ntfyToken = '';
public ntfyUsername = '';
public ntfyPassword = '';
public ntfyActions = '';
}
export class WxPusherBotNotification extends NotificationBaseInfo {
+6
View File
@@ -36,6 +36,12 @@ async function linkCommand() {
const source = path.join(config.rootPath, 'shell', link.src);
const target = path.join(commandDir, link.dest);
const tmpTarget = path.join(commandDir, link.tmp);
try {
const stats = await fs.lstat(tmpTarget);
if (stats) {
await fs.unlink(tmpTarget);
}
} catch (error) { }
await fs.symlink(source, tmpTarget);
await fs.rename(tmpTarget, target);
}
+15 -2
View File
@@ -9,6 +9,7 @@ import rewrite from 'express-urlrewrite';
import { errors } from 'celebrate';
import { serveEnv } from '../config/serverEnv';
import { IKeyvStore, shareStore } from '../shared/store';
import path from 'path';
export default ({ app }: { app: Application }) => {
app.set('trust proxy', 'loopback');
@@ -19,12 +20,15 @@ export default ({ app }: { app: Application }) => {
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
const frontendPath = path.join(config.rootPath, 'static/dist');
app.use(express.static(frontendPath));
app.use(
expressjwt({
secret: config.jwt.secret,
algorithms: ['HS384'],
}).unless({
path: [...config.apiWhiteList, /^\/open\//],
path: [...config.apiWhiteList, /^\/(?!api\/).*/],
}),
);
@@ -39,6 +43,10 @@ export default ({ app }: { app: Application }) => {
});
app.use(async (req: Request, res, next) => {
if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) {
return next();
}
const headerToken = getToken(req);
if (req.path.startsWith('/open/')) {
const apps = await shareStore.getApps();
@@ -110,10 +118,15 @@ export default ({ app }: { app: Application }) => {
app.use(rewrite('/open/*', '/api/$1'));
app.use(config.api.prefix, routes());
app.use((req, res, next) => {
app.get('*', (_, res, next) => {
const indexPath = path.join(frontendPath, 'index.html');
res.sendFile(indexPath, (err) => {
if (err) {
const err: any = new Error('Not Found');
err['status'] = 404;
next(err);
}
});
});
app.use(errors());
+2 -1
View File
@@ -89,7 +89,8 @@ export default async () => {
setTimeout(async () => {
await dependenceService.installDependenceOneByOne(docs);
require('./bootAfter').default();
const bootAfterLoader = await import('./bootAfter');
bootAfterLoader.default();
}, 5000);
};
+3 -1
View File
@@ -116,7 +116,9 @@ export default async () => {
`Neither content nor source specified for ${item.target}`,
);
}
const content = item.content || (await fs.readFile(item.source!));
const content =
item.content ||
(await fs.readFile(item.source!, { encoding: 'utf-8' }));
await writeFileWithLock(item.target, content);
}
}
+123 -8
View File
@@ -53,14 +53,7 @@ message Response {
optional string message = 2;
}
message SystemNotifyRequest {
string title = 1;
string content = 2;
}
message ExtraScheduleItem {
string schedule = 1;
}
message ExtraScheduleItem { string schedule = 1; }
message CronItem {
optional int32 id = 1;
@@ -124,6 +117,128 @@ message CronDetailResponse {
optional string message = 3;
}
enum NotificationMode {
gotify = 0;
goCqHttpBot = 1;
serverChan = 2;
pushDeer = 3;
bark = 4;
chat = 5;
telegramBot = 6;
dingtalkBot = 7;
weWorkBot = 8;
weWorkApp = 9;
aibotk = 10;
iGot = 11;
pushPlus = 12;
wePlusBot = 13;
email = 14;
pushMe = 15;
feishu = 16;
webhook = 17;
chronocat = 18;
ntfy = 19;
wxPusherBot = 20;
}
message NotificationInfo {
NotificationMode type = 1;
optional string gotifyUrl = 2;
optional string gotifyToken = 3;
optional int32 gotifyPriority = 4;
optional string goCqHttpBotUrl = 5;
optional string goCqHttpBotToken = 6;
optional string goCqHttpBotQq = 7;
optional string serverChanKey = 8;
optional string pushDeerKey = 9;
optional string pushDeerUrl = 10;
optional string synologyChatUrl = 11;
optional string barkPush = 12;
optional string barkIcon = 13;
optional string barkSound = 14;
optional string barkGroup = 15;
optional string barkLevel = 16;
optional string barkUrl = 17;
optional string barkArchive = 18;
optional string telegramBotToken = 19;
optional string telegramBotUserId = 20;
optional string telegramBotProxyHost = 21;
optional string telegramBotProxyPort = 22;
optional string telegramBotProxyAuth = 23;
optional string telegramBotApiHost = 24;
optional string dingtalkBotToken = 25;
optional string dingtalkBotSecret = 26;
optional string weWorkBotKey = 27;
optional string weWorkOrigin = 28;
optional string weWorkAppKey = 29;
optional string aibotkKey = 30;
optional string aibotkType = 31;
optional string aibotkName = 32;
optional string iGotPushKey = 33;
optional string pushPlusToken = 34;
optional string pushPlusUser = 35;
optional string pushPlusTemplate = 36;
optional string pushplusChannel = 37;
optional string pushplusWebhook = 38;
optional string pushplusCallbackUrl = 39;
optional string pushplusTo = 40;
optional string wePlusBotToken = 41;
optional string wePlusBotReceiver = 42;
optional string wePlusBotVersion = 43;
optional string emailService = 44;
optional string emailUser = 45;
optional string emailPass = 46;
optional string emailTo = 47;
optional string pushMeKey = 48;
optional string pushMeUrl = 49;
optional string chronocatURL = 50;
optional string chronocatQQ = 51;
optional string chronocatToken = 52;
optional string webhookHeaders = 53;
optional string webhookBody = 54;
optional string webhookUrl = 55;
optional string webhookMethod = 56;
optional string webhookContentType = 57;
optional string larkKey = 58;
optional string ntfyUrl = 59;
optional string ntfyTopic = 60;
optional string ntfyPriority = 61;
optional string ntfyToken = 62;
optional string ntfyUsername = 63;
optional string ntfyPassword = 64;
optional string ntfyActions = 65;
optional string wxPusherBotAppToken = 66;
optional string wxPusherBotTopicIds = 67;
optional string wxPusherBotUids = 68;
}
message SystemNotifyRequest {
string title = 1;
string content = 2;
optional NotificationInfo notificationInfo = 3;
}
service Api {
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
+1531 -81
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -16,7 +16,7 @@ const addCron = (
}
Logger.info(
'[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
name,
schedule,
@@ -26,7 +26,7 @@ const addCron = (
if (extra_schedules?.length) {
extra_schedules.forEach((x) => {
Logger.info(
'[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
name,
x.schedule,
+6 -1
View File
@@ -31,6 +31,7 @@ import {
DeleteCronsRequest,
CronResponse,
} from '../protos/api';
import { NotificationInfo } from '../data/notify';
Container.set('logger', LoggerInstance);
@@ -227,7 +228,11 @@ export const systemNotify = async (
) => {
try {
const systemService = Container.get(SystemService);
const data = await systemService.notify(call.request);
const data = await systemService.notify({
title: call.request.title,
content: call.request.content,
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo,
});
callback(null, data);
} catch (e: any) {
callback(e);
+1 -1
View File
@@ -10,7 +10,7 @@ const delCron = (
for (const id of call.request.ids) {
if (scheduleStacks.has(id)) {
Logger.info(
'[schedule][取消定时任务], 任务ID: %s',
'[schedule][取消定时任务] 任务ID: %s',
id,
);
scheduleStacks.get(id)?.forEach(x => x.cancel());
+17 -3
View File
@@ -215,14 +215,24 @@ export default class CronService {
operate2 = Op.and;
break;
case 'In':
if (
property === 'status' &&
!value.includes(CrontabStatus.disabled)
) {
q[Op.and] = [
{ [property]: Array.isArray(value) ? value : [value] },
{ isDisabled: 0 },
];
} else {
q[Op.or] = [
{
[property]: Array.isArray(value) ? value : [value],
},
property === 'status' && value.includes(2)
property === 'status' && value.includes(CrontabStatus.disabled)
? { isDisabled: 1 }
: {},
];
}
break;
case 'Nin':
q[Op.and] = [
@@ -560,7 +570,10 @@ export default class CronService {
if (logFileExist) {
return await getFileContentByName(`${absolutePath}`);
} else {
return '任务未运行';
return typeof doc.status === 'number' &&
[CrontabStatus.queued, CrontabStatus.running].includes(doc.status)
? '运行中...'
: '日志不存在...';
}
}
@@ -580,7 +593,7 @@ export default class CronService {
files.map(async (x) => ({
filename: x,
directory: relativeDir.replace(config.logPath, ''),
time: (await fs.lstat(`${dir}/${x}`)).mtime.getTime(),
time: (await fs.lstat(`${dir}/${x}`)).birthtimeMs,
})),
)
).sort((a, b) => b.time - a.time);
@@ -694,6 +707,7 @@ export default class CronService {
}));
if (isDemoEnv()) {
await writeFileWithLock(config.crontabFile, '');
return;
}
await cronClient.addCron(regularCrons);
+16 -26
View File
@@ -6,7 +6,6 @@ import {
DependenceStatus,
DependenceTypes,
DependenceModel,
GetDependenceCommandTypes,
versionDependenceCommandTypes,
} from '../data/dependence';
import { spawn } from 'cross-spawn';
@@ -19,6 +18,7 @@ import {
promiseExecSuccess,
getInstallCommand,
getUninstallCommand,
getGetCommand,
} from '../config/util';
import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
@@ -98,34 +98,32 @@ export default class DependenceService {
searchValue,
type,
status,
}: { searchValue: string; type: string; status: string },
}: {
searchValue: string;
type: keyof typeof DependenceTypes;
status: string;
},
sort: any = [],
query: any = {},
): Promise<Dependence[]> {
let condition = {
...query,
type: DependenceTypes[type as any],
};
let condition = query;
if (DependenceTypes[type]) {
condition.type = DependenceTypes[type];
}
if (status) {
condition.status = status.split(',').map(Number);
}
if (searchValue) {
const encodeText = encodeURI(searchValue);
const reg = {
condition.name = {
[Op.or]: [
{ [Op.like]: `%${searchValue}%` },
{ [Op.like]: `%${encodeText}%` },
],
};
condition = {
...condition,
name: reg,
};
}
try {
const result = await this.find(condition, sort);
return result as any;
return await this.find(condition, sort);
} catch (error) {
throw error;
}
@@ -163,11 +161,9 @@ export default class DependenceService {
taskLimit.removeQueuedDependency(doc);
const depInstallCommand = getInstallCommand(doc.type, doc.name);
const depUnInstallCommand = getUninstallCommand(doc.type, doc.name);
const installCmd = `${depInstallCommand} ${doc.name.trim()}`;
const unInstallCmd = `${depUnInstallCommand} ${doc.name.trim()}`;
const pids = await Promise.all([
getPid(installCmd),
getPid(unInstallCmd),
getPid(depInstallCommand),
getPid(depUnInstallCommand),
]);
for (const pid of pids) {
pid && (await killTask(pid));
@@ -252,7 +248,7 @@ export default class DependenceService {
// 判断是否已经安装过依赖
if (isInstall && !force) {
const getCommandPrefix = GetDependenceCommandTypes[dependency.type];
const getCommand = getGetCommand(dependency.type, depName);
const depVersionStr = versionDependenceCommandTypes[dependency.type];
let depVersion = '';
if (depName.includes(depVersionStr)) {
@@ -269,13 +265,7 @@ export default class DependenceService {
const isLinuxDependence = dependency.type === DependenceTypes.linux;
const isPythonDependence =
dependency.type === DependenceTypes.python3;
const depInfo = (
await promiseExecSuccess(
isNodeDependence
? `${getCommandPrefix} | grep "${depName}" | head -1`
: `${getCommandPrefix} ${depName}`,
)
)
const depInfo = (await promiseExecSuccess(getCommand))
.replace(/\s{2,}/, ' ')
.replace(/\s+$/, '');
+36 -5
View File
@@ -49,12 +49,21 @@ export default class NotificationService {
public async notify(
title: string,
content: string,
notificationInfo?: NotificationInfo,
): Promise<boolean | undefined> {
const { type, ...rest } = await this.userService.getNotificationMode();
let { type, ...rest } = await this.userService.getNotificationMode();
if (notificationInfo?.type) {
type = notificationInfo?.type;
}
if (type) {
this.title = title;
this.content = content;
this.params = rest;
let params = rest;
if (notificationInfo) {
const { type: _, ...others } = notificationInfo;
params = { ...rest, ...others };
}
this.params = params;
const notificationModeAction = this.modeMap.get(type);
try {
return await notificationModeAction?.call(this);
@@ -623,20 +632,42 @@ export default class NotificationService {
}
private async ntfy() {
const { ntfyUrl, ntfyTopic, ntfyPriority } = this.params;
const {
ntfyUrl,
ntfyTopic,
ntfyPriority,
ntfyToken,
ntfyUsername,
ntfyPassword,
ntfyActions,
} = this.params;
// 编码函数
const encodeRfc2047 = (text: string, charset: string = 'UTF-8'): string => {
const encodedText = Buffer.from(text).toString('base64');
return `=?${charset}?B?${encodedText}?=`;
};
try {
const encodedTitle = encodeRfc2047(this.title);
const headers: Record<string, string> = {
Title: encodeRfc2047(this.title),
Priority: `${ntfyPriority || '3'}`,
Icon: 'https://qn.whyour.cn/logo.png',
};
if (ntfyToken) {
headers['Authorization'] = `Bearer ${ntfyToken}`;
} else if (ntfyUsername && ntfyPassword) {
headers['Authorization'] = `Basic ${Buffer.from(
`${ntfyUsername}:${ntfyPassword}`,
).toString('base64')}`;
}
if (ntfyActions) {
headers['Actions'] = encodeRfc2047(ntfyActions);
}
const res = await httpClient.request(
`${ntfyUrl || 'https://ntfy.sh'}/${ntfyTopic}`,
{
...this.gotOption,
body: `${this.content}`,
headers: { Title: encodedTitle, Priority: `${ntfyPriority || '3'}` },
headers: headers,
method: 'POST',
},
);
+4 -4
View File
@@ -139,7 +139,7 @@ export default class ScheduleService {
) {
const _id = this.formatId(id);
this.logger.info(
'[panel][创建cron任务], 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
_id,
schedule,
name,
@@ -172,7 +172,7 @@ export default class ScheduleService {
async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id);
this.logger.info('[panel][取消定时任务], 任务名: %s', name);
this.logger.info('[panel][取消定时任务] 任务名: %s', name);
if (this.scheduleStacks.has(_id)) {
this.scheduleStacks.get(_id)?.cancel();
this.scheduleStacks.delete(_id);
@@ -187,7 +187,7 @@ export default class ScheduleService {
) {
const _id = this.formatId(id);
this.logger.info(
'[panel][创建interval任务], 任务ID: %s, 任务名: %s, 执行命令: %s',
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s',
_id,
name,
command,
@@ -232,7 +232,7 @@ export default class ScheduleService {
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id);
this.logger.info(
'[panel][取消interval任务], 任务ID: %s, 任务名: %s',
'[panel][取消interval任务] 任务ID: %s, 任务名: %s',
_id,
name,
);
+6 -2
View File
@@ -26,12 +26,13 @@ export default class SshKeyService {
if (_exist) {
config = await fs.readFile(this.sshConfigFilePath, { encoding: 'utf-8' });
} else {
await writeFileWithLock(this.sshConfigFilePath, '');
await writeFileWithLock(this.sshConfigFilePath, '', { mode: '600' });
}
if (!config.includes(this.sshConfigHeader)) {
await writeFileWithLock(
this.sshConfigFilePath,
`${this.sshConfigHeader}\n\n${config}`,
{ mode: '600' },
);
}
}
@@ -45,7 +46,6 @@ export default class SshKeyService {
path.join(this.sshPath, alias),
`${key}${os.EOL}`,
{
encoding: 'utf8',
mode: '400',
},
);
@@ -81,6 +81,10 @@ export default class SshKeyService {
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
config,
{
encoding: 'utf8',
mode: '600',
},
);
}
+1 -1
View File
@@ -384,7 +384,7 @@ export default class SubscriptionService {
files.map(async (x) => ({
filename: x,
directory: relativeDir.replace(config.logPath, ''),
time: (await fs.lstat(`${dir}/${x}`)).mtime.getTime(),
time: (await fs.lstat(`${dir}/${x}`)).birthtimeMs,
})),
)
).sort((a, b) => b.time - a.time);
+44 -7
View File
@@ -7,7 +7,7 @@ import path from 'path';
import { Inject, Service } from 'typedi';
import winston from 'winston';
import config from '../config';
import { TASK_COMMAND } from '../config/const';
import { NotificationModeStringMap, TASK_COMMAND } from '../config/const';
import {
getPid,
killTask,
@@ -373,8 +373,27 @@ export default class SystemService {
return { code: 200 };
}
public async notify({ title, content }: { title: string; content: string }) {
const isSuccess = await this.notificationService.notify(title, content);
public async notify({
title,
content,
notificationInfo,
}: {
title: string;
content: string;
notificationInfo?: NotificationInfo;
}) {
const typeString =
typeof notificationInfo?.type === 'number'
? NotificationModeStringMap[notificationInfo.type]
: undefined;
if (notificationInfo && typeString) {
notificationInfo.type = typeString;
}
const isSuccess = await this.notificationService.notify(
title,
content,
notificationInfo,
);
if (isSuccess) {
return { code: 200, message: '通知发送成功' };
} else {
@@ -382,11 +401,12 @@ export default class SystemService {
}
}
public async run({ command }: { command: string }, callback: TaskCallbacks) {
public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) {
if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`;
}
this.scheduleService.runTask(`real_time=true ${command}`, callback, {
const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, {
command,
id: command.replace(/ /g, '-'),
runOrigin: 'system',
@@ -415,10 +435,16 @@ export default class SystemService {
}
}
public async exportData(res: Response) {
public async exportData(res: Response, type?: string[]) {
try {
let dataDirs = ['db', 'upload'];
if (type && type.length) {
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
}
const dataPaths = dataDirs.map((dir) => `data/${dir}`);
await promiseExec(
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile} data/`,
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile
} ${dataPaths.join(' ')}`,
);
res.download(config.dataTgzFile);
} catch (error: any) {
@@ -503,4 +529,15 @@ export default class SystemService {
return { code: 400, message: '设置时区失败' };
}
}
public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: '参数错误' };
}
try {
const finalPath = path.join(config.dependenceCachePath, type);
await fs.promises.rm(finalPath, { recursive: true });
} catch (error) { }
return { code: 200 };
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ function getUniqueLockPath(filePath: string) {
export async function writeFileWithLock(
filePath: string,
content: string | Buffer,
content: string,
options: Parameters<typeof writeFile>[2] = {},
) {
if (typeof options === 'string') {
+1 -2
View File
@@ -39,7 +39,6 @@ RUN set -x \
tzdata \
perl \
openssl \
nginx \
nodejs \
jq \
openssh \
@@ -84,6 +83,6 @@ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:5600/api/health || exit 1
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+1 -2
View File
@@ -39,7 +39,6 @@ RUN set -x \
tzdata \
perl \
openssl \
nginx \
nodejs \
jq \
openssh \
@@ -84,6 +83,6 @@ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:5600/api/health || exit 1
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+3 -9
View File
@@ -14,26 +14,20 @@ log_with_style() {
log_with_style "INFO" "🚀 1. 检测配置文件..."
import_config "$@"
make_dir /etc/nginx/conf.d
make_dir /run/nginx
init_nginx
fix_config
pm2 l &>/dev/null
log_with_style "INFO" "🔄 2. 启动 nginx..."
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
log_with_style "INFO" "⚙️ 3. 启动 pm2 服务..."
log_with_style "INFO" "⚙️ 2. 启动 pm2 服务..."
reload_pm2
if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 4. 启动 bot..."
log_with_style "INFO" "🤖 3. 启动 bot..."
nohup ql bot >$dir_log/bot.log 2>&1 &
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 5. 执行自定义脚本..."
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 &
fi
-61
View File
@@ -1,61 +0,0 @@
upstream baseApi {
server 0.0.0.0:5600;
}
map $http_upgrade $connection_upgrade {
default keep-alive;
'websocket' upgrade;
}
server {
IPV4_CONFIG
IPV6_CONFIG
ssl_session_timeout 5m;
location QL_BASE_URLapi/ {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://baseApi/api/;
proxy_buffering off;
proxy_redirect default;
proxy_connect_timeout 1800;
proxy_send_timeout 1800;
proxy_read_timeout 1800;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location QL_BASE_URLopen/ {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://baseApi/open/;
proxy_buffering off;
proxy_redirect default;
proxy_connect_timeout 1800;
proxy_send_timeout 1800;
proxy_read_timeout 1800;
}
gzip on;
gzip_static on;
gzip_types text/plain application/json application/javascript application/x-javascript text/css application/xml text/javascript;
gzip_proxied any;
gzip_vary on;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.0;
QL_ROOT_CONFIG
location QL_BASE_URL_LOCATION {
QL_ALIAS_CONFIG
index index.html index.htm;
try_files $uri QL_BASE_URLindex.html;
}
location ~ .*\.(html)$ {
add_header Cache-Control no-cache;
}
}
-45
View File
@@ -1,45 +0,0 @@
user root;
worker_processes auto;
pcre_jit on;
error_log /var/log/nginx/error.log warn;
include /etc/nginx/modules/*.conf;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
client_max_body_size 4096m;
client_body_buffer_size 20m;
keepalive_timeout 65;
sendfile on;
tcp_nodelay on;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:2m;
gzip on;
gzip_static on;
gzip_types text/plain application/json application/javascript application/x-javascript text/css application/xml text/javascript;
gzip_proxied any;
gzip_vary on;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.0;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
include /etc/nginx/conf.d/*.conf;
}
+12 -2
View File
@@ -1,5 +1,15 @@
{
"watch": ["back", ".env"],
"watch": [
"back",
".env"
],
"ext": "js,ts,json",
"exec": "ts-node -P ./back/tsconfig.json ./back/app.ts"
"env": {
"NODE_ENV": "development",
"TS_NODE_PROJECT": "./back/tsconfig.json"
},
"verbose": true,
"execMap": {
"ts": "node --require ts-node/register"
}
}
+5 -7
View File
@@ -2,13 +2,12 @@
"private": true,
"scripts": {
"start": "concurrently -n w: npm:start:*",
"start:back": "nodemon",
"start:back": "nodemon ./back/app.ts",
"start:front": "max dev",
"build:front": "max build",
"build:back": "tsc -p back/tsconfig.json",
"panel": "npm run build:back && node static/build/app.js",
"gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true,snakeToCamel=false",
"gen:api": "python3 -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. ./back/protos/api.proto",
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
"postinstall": "max setup 2>/dev/null || true",
"test": "umi-test",
@@ -55,8 +54,8 @@
}
},
"dependencies": {
"@grpc/grpc-js": "^1.12.3",
"@grpc/proto-loader": "^0.7.13",
"@grpc/grpc-js": "^1.14.0",
"@grpc/proto-loader": "^0.8.0",
"@otplib/preset-default": "^12.0.1",
"body-parser": "^1.20.3",
"celebrate": "^15.0.3",
@@ -81,11 +80,10 @@
"node-schedule": "^2.1.0",
"nodemailer": "^6.9.16",
"p-queue-cjs": "7.3.4",
"@bufbuild/protobuf": "^2.2.3",
"@bufbuild/protobuf": "^2.10.0",
"ps-tree": "^1.2.0",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.5",
"serve-handler": "^6.1.6",
"sockjs": "^0.3.24",
"sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3",
"toad-scheduler": "^3.0.1",
@@ -99,7 +97,7 @@
"@keyv/sqlite": "^4.0.1",
"proper-lockfile": "^4.1.2",
"compression": "^1.7.4",
"helmet": "^6.0.1"
"helmet": "^8.1.0"
},
"devDependencies": {
"moment": "2.30.1",
+24 -74
View File
@@ -9,14 +9,14 @@ overrides:
dependencies:
'@bufbuild/protobuf':
specifier: ^2.2.3
version: 2.2.3
specifier: ^2.10.0
version: 2.10.0
'@grpc/grpc-js':
specifier: ^1.12.3
version: 1.12.3
specifier: ^1.14.0
version: 1.14.0
'@grpc/proto-loader':
specifier: ^0.7.13
version: 0.7.13
specifier: ^0.8.0
version: 0.8.0
'@keyv/sqlite':
specifier: ^4.0.1
version: 4.0.1
@@ -63,8 +63,8 @@ dependencies:
specifier: ^2.0.3
version: 2.0.3
helmet:
specifier: ^6.0.1
version: 6.2.0
specifier: ^8.1.0
version: 8.1.0
hpagent:
specifier: ^1.2.0
version: 1.2.0
@@ -116,9 +116,6 @@ dependencies:
sequelize:
specifier: ^6.37.5
version: 6.37.5(@whyour/sqlite3@1.0.3)
serve-handler:
specifier: ^6.1.6
version: 6.1.6
sockjs:
specifier: ^0.3.24
version: 0.3.24
@@ -1389,8 +1386,8 @@ packages:
resolution: {integrity: sha512-h0OYmPR3A5Dfbetra/GzxBAzQk8sH7LhRkRUTdagX6nrtlUgJGYCTv4bBK33jsTQw9HDd8PE2x1Ma+iRKEDUsw==}
dev: true
/@bufbuild/protobuf@2.2.3:
resolution: {integrity: sha512-tFQoXHJdkEOSwj5tRIZSPNUuXK3RaR7T1nUrPgbYX1pUbvqqaaZAsfo+NXBPsz5rZMSKVFrgK1WL8Q/MSLvprg==}
/@bufbuild/protobuf@2.10.0:
resolution: {integrity: sha512-fdRs9PSrBF7QUntpZpq6BTw58fhgGJojgg39m9oFOJGZT+nip9b0so5cYY1oWl5pvemDLr0cPPsH46vwThEbpQ==}
/@chenshuai2144/sketch-color@1.0.9(react@18.3.1):
resolution: {integrity: sha512-obzSy26cb7Pm7OprWyVpgMpIlrZpZ0B7vbrU0RMbvRg0YAI890S5Xy02Aj1Nhl4+KTbi1lVYHt6HQP8Hm9s+1w==}
@@ -2585,22 +2582,22 @@ packages:
dev: false
optional: true
/@grpc/grpc-js@1.12.3:
resolution: {integrity: sha512-iaxAZnANdCwMNpJlyhkI1W1jQZIDZKFNtU2OpQDdgd+pBcU3t7G+PT7svobkW4WSZTdis+CVV6y8KIwu83HDYQ==}
/@grpc/grpc-js@1.14.0:
resolution: {integrity: sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==}
engines: {node: '>=12.10.0'}
dependencies:
'@grpc/proto-loader': 0.7.13
'@grpc/proto-loader': 0.8.0
'@js-sdsl/ordered-map': 4.4.2
dev: false
/@grpc/proto-loader@0.7.13:
resolution: {integrity: sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==}
/@grpc/proto-loader@0.8.0:
resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==}
engines: {node: '>=6'}
hasBin: true
dependencies:
lodash.camelcase: 4.3.0
long: 5.2.3
protobufjs: 7.4.0
protobufjs: 7.5.4
yargs: 17.7.2
dev: false
@@ -3876,7 +3873,7 @@ packages:
resolution: {integrity: sha512-ONIn/nSNQA57yRge3oaMQESef/6QhoeX7llWeDli0UZIfz8TQMkfNPTXA8VnnyeA1WUjG2pGqdjEIueYonMdfQ==}
deprecated: This is a stub types definition. helmet provides its own type definitions, so you do not need this installed.
dependencies:
helmet: 6.2.0
helmet: 8.1.0
dev: true
/@types/hoist-non-react-statics@3.3.5:
@@ -5962,11 +5959,6 @@ packages:
streamsearch: 1.1.0
dev: false
/bytes@3.0.0:
resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==}
engines: {node: '>= 0.8'}
dev: false
/bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -6384,11 +6376,6 @@ packages:
resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==}
dev: true
/content-disposition@0.5.2:
resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==}
engines: {node: '>= 0.6'}
dev: false
/content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
@@ -8480,9 +8467,9 @@ packages:
hasBin: true
dev: true
/helmet@6.2.0:
resolution: {integrity: sha512-DWlwuXLLqbrIOltR6tFQXShj/+7Cyp0gLi6uAb8qMdFh/YBBFbKSgQ6nbXmScYd8emMctuthmgIa7tUfo9Rtyg==}
engines: {node: '>=14.0.0'}
/helmet@8.1.0:
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
engines: {node: '>=18.0.0'}
/history@5.3.0:
resolution: {integrity: sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==}
@@ -9961,11 +9948,6 @@ packages:
brorand: 1.1.0
dev: true
/mime-db@1.33.0:
resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==}
engines: {node: '>= 0.6'}
dev: false
/mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
@@ -9974,13 +9956,6 @@ packages:
resolution: {integrity: sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==}
engines: {node: '>= 0.6'}
/mime-types@2.1.18:
resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==}
engines: {node: '>= 0.6'}
dependencies:
mime-db: 1.33.0
dev: false
/mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
@@ -10776,10 +10751,6 @@ packages:
engines: {node: '>=0.10.0'}
requiresBuild: true
/path-is-inside@1.0.2:
resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==}
dev: false
/path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -10814,10 +10785,6 @@ packages:
resolution: {integrity: sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w==}
dev: true
/path-to-regexp@3.3.0:
resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==}
dev: false
/path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
dev: false
@@ -11603,8 +11570,8 @@ packages:
signal-exit: 3.0.7
dev: false
/protobufjs@7.4.0:
resolution: {integrity: sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==}
/protobufjs@7.5.4:
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
engines: {node: '>=12.0.0'}
requiresBuild: true
dependencies:
@@ -11808,11 +11775,6 @@ packages:
safe-buffer: 5.2.1
dev: true
/range-parser@1.2.0:
resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==}
engines: {node: '>= 0.6'}
dev: false
/range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -13412,18 +13374,6 @@ packages:
randombytes: 2.1.0
dev: true
/serve-handler@6.1.6:
resolution: {integrity: sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==}
dependencies:
bytes: 3.0.0
content-disposition: 0.5.2
mime-types: 2.1.18
minimatch: 3.1.2
path-is-inside: 1.0.2
path-to-regexp: 3.3.0
range-parser: 1.2.0
dev: false
/serve-static@1.16.2:
resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==}
engines: {node: '>= 0.8.0'}
@@ -14383,14 +14333,14 @@ packages:
/ts-proto-descriptors@2.0.0:
resolution: {integrity: sha512-wHcTH3xIv11jxgkX5OyCSFfw27agpInAd6yh89hKG6zqIXnjW9SYqSER2CVQxdPj4czeOhGagNvZBEbJPy7qkw==}
dependencies:
'@bufbuild/protobuf': 2.2.3
'@bufbuild/protobuf': 2.10.0
dev: true
/ts-proto@2.6.1:
resolution: {integrity: sha512-4LTT99MkwkF1+fIA0b2mZu/58Qlpq3Q1g53TwEMZZgR1w/uX00PoVT4Z8aKJxMw0LeKQD4s9NrJYsF27Clckrg==}
hasBin: true
dependencies:
'@bufbuild/protobuf': 2.2.3
'@bufbuild/protobuf': 2.10.0
case-anything: 2.1.13
ts-poet: 6.9.0
ts-proto-descriptors: 2.0.0
+8
View File
@@ -226,9 +226,17 @@ export QMSG_TYPE=""
## ntfy_url 填写ntfy地址,如https://ntfy.sh
## ntfy_topic 填写ntfy的消息应用topic
## ntfy_priority 填写推送消息优先级,默认为3
## ntfy_token 填写推送token,可选
## ntfy_username 填写推送用户名称,可选
## ntfy_password 填写推送用户密码,可选
## ntfy_actions 填写推送用户动作,可选
export NTFY_URL=""
export NTFY_TOPIC=""
export NTFY_PRIORITY="3"
export NTFY_TOKEN=""
export NTFY_USERNAME=""
export NTFY_PASSWORD=""
export NTFY_ACTIONS=""
## 21. wxPusher
## 官方文档: https://wxpusher.zjiecode.com/docs/
+15 -1
View File
@@ -140,6 +140,10 @@ const push_config = {
NTFY_URL: '', // ntfy地址,如https://ntfy.sh,默认为https://ntfy.sh
NTFY_TOPIC: '', // ntfy的消息应用topic
NTFY_PRIORITY: '3', // 推送消息优先级,默认为3
NTFY_TOKEN: '', // 推送token,可选
NTFY_USERNAME: '', // 推送用户名称,可选
NTFY_PASSWORD: '', // 推送用户密码,可选
NTFY_ACTIONS: '', // 推送用户动作,可选
// 官方文档: https://wxpusher.zjiecode.com/docs/
// 管理后台: https://wxpusher.zjiecode.com/admin/
@@ -1258,7 +1262,7 @@ function ntfyNotify(text, desp) {
}
return new Promise((resolve) => {
const { NTFY_URL, NTFY_TOPIC, NTFY_PRIORITY } = push_config;
const { NTFY_URL, NTFY_TOPIC, NTFY_PRIORITY, NTFY_TOKEN, NTFY_USERNAME, NTFY_PASSWORD, NTFY_ACTIONS } = push_config;
if (NTFY_TOPIC) {
const options = {
url: `${NTFY_URL || 'https://ntfy.sh'}/${NTFY_TOPIC}`,
@@ -1266,9 +1270,19 @@ function ntfyNotify(text, desp) {
headers: {
Title: `${encodeRFC2047(text)}`,
Priority: NTFY_PRIORITY || '3',
Icon: 'https://qn.whyour.cn/logo.png',
},
timeout,
};
if (NTFY_TOKEN) {
options.headers['Authorization'] = `Bearer ${NTFY_TOKEN}`;
} else if (NTFY_USERNAME && NTFY_PASSWORD) {
options.headers['Authorization'] = `Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`;
}
if (NTFY_ACTIONS) {
options.headers['Actions'] = encodeRFC2047(NTFY_ACTIONS);
}
$.post(options, (err, resp, data) => {
try {
if (err) {
+12 -1
View File
@@ -126,6 +126,10 @@ push_config = {
'NTFY_URL': '', # ntfy地址,如https://ntfy.sh
'NTFY_TOPIC': '', # ntfy的消息应用topic
'NTFY_PRIORITY':'3', # 推送消息优先级,默认为3
'NTFY_TOKEN': '', # 推送token,可选
'NTFY_USERNAME': '', # 推送用户名称,可选
'NTFY_PASSWORD': '', # 推送用户密码,可选
'NTFY_ACTIONS': '', # 推送用户动作,可选
'WXPUSHER_APP_TOKEN': '', # wxpusher 的 appToken 官方文档: https://wxpusher.zjiecode.com/docs/ 管理后台: https://wxpusher.zjiecode.com/admin/
'WXPUSHER_TOPIC_IDS': '', # wxpusher 的 主题ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
@@ -806,7 +810,14 @@ def ntfy(title: str, content: str) -> None:
encoded_title = encode_rfc2047(title)
data = content.encode(encoding="utf-8")
headers = {"Title": encoded_title, "Priority": priority} # 使用编码后的 title
headers = {"Title": encoded_title, "Priority": priority, "Icon": "https://qn.whyour.cn/logo.png"} # 使用编码后的 title
if push_config.get("NTFY_TOKEN"):
headers['Authorization'] = "Bearer " + push_config.get("NTFY_TOKEN")
elif push_config.get("NTFY_USERNAME") and push_config.get("NTFY_PASSWORD"):
authStr = push_config.get("NTFY_USERNAME") + ":" + push_config.get("NTFY_PASSWORD")
headers['Authorization'] = "Basic " + base64.b64encode(authStr.encode('utf-8')).decode('utf-8')
if push_config.get("NTFY_ACTIONS"):
headers['Actions'] = encode_rfc2047(push_config.get("NTFY_ACTIONS"))
url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC")
response = requests.post(url, data=data, headers=headers)
+8 -48
View File
@@ -41,14 +41,9 @@ add_cron_api() {
fi
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons?t=$currentTimeStamp" \
-H "Accept: application/json" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \
--compressed
)
@@ -76,15 +71,10 @@ update_cron_api() {
fi
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \
-X 'PUT' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"id\":\"$id\"}" \
--compressed
)
@@ -108,15 +98,10 @@ update_cron_command_api() {
fi
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \
-X 'PUT' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{\"command\":\"${command//\"/\\\"}\",\"id\":\"$id\"}" \
--compressed
)
@@ -133,15 +118,10 @@ del_cron_api() {
local ids="$1"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \
-X 'DELETE' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "[$ids]" \
--compressed
)
@@ -163,15 +143,10 @@ update_cron() {
local runningTime="${6:-0}"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons/status?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons/status?t=$currentTimeStamp" \
-X 'PUT' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{\"ids\":[$ids],\"status\":\"$status\",\"pid\":\"$pid\",\"log_path\":\"$logPath\",\"last_execution_time\":$lastExecutingTime,\"last_running_time\":$runningTime}" \
--compressed
)
@@ -190,15 +165,10 @@ notify_api() {
local content="$2"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/system/notify?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/system/notify?t=$currentTimeStamp" \
-X 'PUT' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{\"title\":\"${title//\"/\\\"}\",\"content\":\"${content//\"/\\\"}\"}" \
--compressed
)
@@ -215,14 +185,9 @@ find_cron_api() {
local params="$1"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons/detail?$params&t=$currentTimeStamp" \
-H "Accept: application/json" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons/detail?$params&t=$currentTimeStamp" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--compressed
)
data=$(echo "$api" | jq -r .data)
@@ -239,15 +204,10 @@ update_auth_config() {
local tip="$2"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/system/auth/reset?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/system/auth/reset?t=$currentTimeStamp" \
-X 'PUT' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{$body}" \
--compressed
)
+1 -18
View File
@@ -20,10 +20,6 @@ copy_dep() {
echo -e "---> 复制一份 $file_notify_js_sample$file_notify_js\n"
cp -fv $file_notify_js_sample $file_notify_js
echo -e "---> 通知文件复制完成\n"
echo -e "---> 2. 复制nginx配置文件\n"
init_nginx
echo -e "---> 配置文件复制完成\n"
}
pm2_log() {
@@ -34,18 +30,6 @@ pm2_log() {
tail -n 300 "$panelError"
}
check_nginx() {
local nginxPid=$(ps -eo pid,command | grep nginx | grep -v grep)
echo -e "=====> 检测nginx服务\n$nginxPid"
if [[ $nginxPid ]]; then
echo -e "\n=====> nginx服务正常\n"
nginx -s reload
else
echo -e "\n=====> nginx服务异常,重新启动nginx\n"
nginx -c /etc/nginx/nginx.conf
fi
}
check_ql() {
local api=$(curl -s --noproxy "*" "http://0.0.0.0:5700")
echo -e "\n=====> 检测面板\n\n$api\n"
@@ -58,7 +42,7 @@ check_pm2() {
pm2_log
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/api/system?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/api/system?t=$currentTimeStamp" \
-H 'Accept: */*' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \
-H 'Referer: http://0.0.0.0:5700/crontab' \
@@ -78,7 +62,6 @@ main() {
reset_env
copy_dep
check_ql
check_nginx
check_pm2
reload_pm2
echo -e "\n=====> 检测结束\n"
+352
View File
@@ -0,0 +1,352 @@
# Qinglong Python API (QLAPI) Documentation
## Overview
The Qinglong Python API provides a convenient way to interact with the Qinglong system from Python scripts. The `QLAPI` object is automatically available in your Python scripts when they run within the Qinglong environment.
## Availability
The Python QLAPI is available starting from **version 2.8.0+**. If you're using an older version (e.g., v2.7.11), please upgrade to access these features.
## Prerequisites
- Qinglong version 2.8.0 or higher
- Python 3.6 or higher
- Running within Qinglong environment (scripts executed through Qinglong task system)
## Usage
The `QLAPI` object is automatically injected into your Python script's global namespace. You don't need to import anything - just use it directly:
```python
# QLAPI is automatically available
result = QLAPI.getEnvs({"searchValue": "USER"})
print(result)
```
## Available Methods
### Environment Variables Management
#### getEnvs
Get environment variables with optional search filter.
```python
# Get all environment variables
envs = QLAPI.getEnvs()
# Search for specific environment variables
envs = QLAPI.getEnvs({"searchValue": "USER"})
```
**Parameters:**
- `searchValue` (optional): String to search for in environment variable names or values
**Returns:** `EnvsResponse` with list of environment variables
#### createEnv
Create new environment variables.
```python
result = QLAPI.createEnv({
"envs": [
{
"name": "MY_VAR",
"value": "my_value",
"remarks": "My custom variable"
}
]
})
```
**Parameters:**
- `envs`: List of environment variable objects to create
**Returns:** `EnvsResponse`
#### updateEnv
Update an existing environment variable.
```python
result = QLAPI.updateEnv({
"env": {
"id": 123,
"name": "MY_VAR",
"value": "new_value",
"remarks": "Updated variable"
}
})
```
**Parameters:**
- `env`: Environment variable object with id and updated fields
**Returns:** `EnvResponse`
#### deleteEnvs
Delete environment variables by IDs.
```python
result = QLAPI.deleteEnvs({"ids": [123, 456]})
```
**Parameters:**
- `ids`: List of environment variable IDs to delete
**Returns:** `Response`
#### enableEnvs
Enable environment variables.
```python
result = QLAPI.enableEnvs({"ids": [123, 456]})
```
**Parameters:**
- `ids`: List of environment variable IDs to enable
**Returns:** `Response`
#### disableEnvs
Disable environment variables.
```python
result = QLAPI.disableEnvs({"ids": [123, 456]})
```
**Parameters:**
- `ids`: List of environment variable IDs to disable
**Returns:** `Response`
#### updateEnvNames
Update names of multiple environment variables.
```python
result = QLAPI.updateEnvNames({
"ids": [123, 456],
"name": "NEW_NAME"
})
```
**Parameters:**
- `ids`: List of environment variable IDs
- `name`: New name to set
**Returns:** `Response`
#### getEnvById
Get a specific environment variable by ID.
```python
env = QLAPI.getEnvById({"id": 123})
```
**Parameters:**
- `id`: Environment variable ID
**Returns:** `EnvResponse`
#### moveEnv
Change the position/order of an environment variable.
```python
result = QLAPI.moveEnv({
"id": 123,
"fromIndex": 0,
"toIndex": 5
})
```
**Parameters:**
- `id`: Environment variable ID
- `fromIndex`: Current position index
- `toIndex`: Target position index
**Returns:** `EnvResponse`
### Scheduled Tasks (Cron) Management
#### getCronDetail
Get details of a scheduled task.
```python
cron = QLAPI.getCronDetail({"log_path": "/path/to/log"})
```
**Parameters:**
- `log_path`: Path to the task log file
**Returns:** `CronResponse`
#### createCron
Create a new scheduled task.
```python
result = QLAPI.createCron({
"command": "node script.js",
"schedule": "0 0 * * *",
"name": "Daily Task",
"labels": ["tag1", "tag2"],
"sub_id": None,
"extra_schedules": [],
"task_before": "",
"task_after": ""
})
```
**Parameters:**
- `command`: Command to execute
- `schedule`: Cron expression
- `name`: Task name (optional)
- `labels`: List of labels (optional)
- Other optional fields
**Returns:** `CronResponse`
#### updateCron
Update an existing scheduled task.
```python
result = QLAPI.updateCron({
"id": 123,
"command": "node updated_script.js",
"schedule": "0 0 * * *",
"name": "Updated Task",
"labels": [],
"sub_id": None,
"extra_schedules": [],
"task_before": "",
"task_after": ""
})
```
**Returns:** `CronResponse`
#### deleteCrons
Delete scheduled tasks by IDs.
```python
result = QLAPI.deleteCrons({"ids": [123, 456]})
```
**Parameters:**
- `ids`: List of task IDs to delete
**Returns:** `Response`
### Notifications
#### notify
Send a notification using configured notification channels.
```python
result = QLAPI.notify("Notification Title", "Notification Content")
```
**Parameters:**
- First argument: Notification title
- Second argument: Notification content
**Returns:** Notification result
#### systemNotify
Send a system notification with custom parameters.
```python
result = QLAPI.systemNotify({
"title": "System Alert",
"content": "This is a system notification"
})
```
**Parameters:**
- `title`: Notification title
- `content`: Notification content
**Returns:** `Response`
## Complete Example
```python
"""
Example Qinglong Python script demonstrating QLAPI usage
"""
# Get environment variables
print("Fetching environment variables...")
envs = QLAPI.getEnvs({"searchValue": "TOKEN"})
print(f"Found {len(envs.get('data', []))} environment variables")
# Create a new environment variable
print("Creating new environment variable...")
result = QLAPI.createEnv({
"envs": [
{
"name": "MY_TEST_VAR",
"value": "test_value_123",
"remarks": "Created by script"
}
]
})
print(f"Create result: {result}")
# Send notification
print("Sending notification...")
QLAPI.notify("Script Completed", "The script has finished executing successfully")
# Send system notification
QLAPI.systemNotify({
"title": "Task Report",
"content": f"Processed {len(envs.get('data', []))} environment variables"
})
print("Done!")
```
## Error Handling
All QLAPI methods may raise exceptions if there are errors communicating with the backend. It's recommended to use try-except blocks:
```python
try:
envs = QLAPI.getEnvs({"searchValue": "USER"})
print(f"Success: {envs}")
except Exception as e:
print(f"Error: {e}")
QLAPI.notify("Script Error", str(e))
```
## Troubleshooting
### AttributeError: 'BaseApi' object has no attribute 'getEnvs'
This error occurs when using an older version of Qinglong (before v2.8.0). Solutions:
1. **Upgrade Qinglong**: Update to version 2.8.0 or higher
2. **Check Installation**: Ensure `shell/preload/client.py` exists
3. **Verify Environment**: Make sure scripts are running within Qinglong environment
### Module Import Errors
The QLAPI is only available when scripts run within the Qinglong environment. If you're testing locally, you won't have access to QLAPI.
## Technical Details
The Python QLAPI is a wrapper around the Node.js gRPC API client. When you call a method like `QLAPI.getEnvs()`:
1. Python Client creates a temporary Node.js script
2. Executes the corresponding Node.js API method
3. Returns the JSON result back to Python
This architecture ensures consistency between JavaScript and Python APIs.
## See Also
For more information and examples:
- [JavaScript API Client Source](./client.js) - The underlying Node.js gRPC client
- [Python Sample Script](../../sample/ql_sample.py) - Example Python script using QLAPI
- [JavaScript Sample Script](../../sample/ql_sample.js) - Example JavaScript script for comparison
These files are located in the Qinglong repository under `shell/preload/` and `sample/` directories.
+34
View File
@@ -1,3 +1,20 @@
"""
Qinglong Python API Client
This module provides a Python interface to the Qinglong API through a gRPC-based
Node.js client. It enables Python scripts to interact with Qinglong's environment
variables, scheduled tasks, and notification systems.
The Client class is used as a base for the QLAPI object that is automatically
available in Qinglong Python scripts. Users can call methods like:
QLAPI.getEnvs({"searchValue": "USER"})
QLAPI.createEnv({"envs": [{"name": "VAR", "value": "val"}]})
QLAPI.notify("Title", "Content")
For detailed documentation, see README_PYTHON_API.md
"""
import subprocess
import json
import tempfile
@@ -176,6 +193,23 @@ class CronResponse(TypedDict):
class Client:
"""
Qinglong API Client for Python.
This class provides methods to interact with Qinglong's API for managing
environment variables, scheduled tasks (crons), and notifications.
The client works by executing Node.js code that calls the actual gRPC API,
then returning the results to Python. This ensures consistency between
JavaScript and Python API interfaces.
Usage:
client = Client()
envs = client.getEnvs({"searchValue": "TOKEN"})
Note: This class is typically used through the QLAPI object which is
automatically available in Qinglong Python scripts.
"""
def __init__(self):
self.temp_dir = tempfile.mkdtemp(prefix="node_client_")
self.temp_script = os.path.join(self.temp_dir, "temp_script.js")
+2 -4
View File
@@ -87,12 +87,10 @@ function run() {
console.log('执行前置命令结束\n');
}
} catch (error) {
if (!error.message.includes('spawnSync /bin/sh E2BIG')) {
if (!error.message.includes('spawnSync /bin/bash E2BIG')) {
console.log(`\ue926 run task before error: `, error);
} else {
console.log(
`\ue926 The environment variable is too large. It is recommended to use task_before.js instead of task_before.sh\n`,
);
// environment variable is too large
}
if (task_before) {
console.log('执行前置命令结束\n');
+6 -4
View File
@@ -98,10 +98,8 @@ def run():
error_message = str(error)
if "Argument list too long" not in error_message:
print(f"\ue926 run task before error: {error}")
else:
print(
"\ue926 The environment variable is too large. It is recommended to use task_before.py instead of task_before.sh\n"
)
# else:
# environment variable is too large
if task_before:
print("执行前置命令结束\n")
except Exception as error:
@@ -133,10 +131,14 @@ try:
from __ql_notify__ import send
# BaseApi inherits all methods from Client (getEnvs, createEnv, etc.)
# and adds the notify method for sending notifications
class BaseApi(Client):
def notify(self, *args, **kwargs):
return send(*args, **kwargs)
# Create QLAPI instance and make it globally available
# This allows scripts to use: QLAPI.getEnvs(), QLAPI.notify(), etc.
QLAPI = BaseApi()
builtins.QLAPI = QLAPI
except Exception as error:
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""
Validation script for QLAPI Client functionality.
Tests that all methods are properly accessible through the BaseApi class.
"""
import sys
from client import Client
def test_client_has_required_methods():
"""Test that Client class has all required API methods."""
client = Client()
required_methods = [
'getEnvs',
'createEnv',
'updateEnv',
'deleteEnvs',
'moveEnv',
'disableEnvs',
'enableEnvs',
'updateEnvNames',
'getEnvById',
'systemNotify',
'getCronDetail',
'createCron',
'updateCron',
'deleteCrons',
]
print("Testing Client class methods...")
for method in required_methods:
assert hasattr(client, method), f"Client missing method: {method}"
assert callable(getattr(client, method)), f"Client.{method} is not callable"
print(f"{method}")
print(f"\n✓ All {len(required_methods)} methods are present and callable")
return True
def test_baseapi_inheritance():
"""Test that BaseApi properly inherits from Client."""
# Simulate the BaseApi class from sitecustomize.py
class BaseApi(Client):
def notify(self, *args, **kwargs):
return "mock_notify_result"
api = BaseApi()
print("\nTesting BaseApi inheritance...")
# Test that BaseApi has all Client methods
assert hasattr(api, 'getEnvs'), "BaseApi missing getEnvs"
assert callable(api.getEnvs), "BaseApi.getEnvs is not callable"
print(" ✓ getEnvs is accessible")
# Test that BaseApi also has its own method
assert hasattr(api, 'notify'), "BaseApi missing notify"
assert callable(api.notify), "BaseApi.notify is not callable"
print(" ✓ notify is accessible")
# Verify getEnvs has type annotations (either params or return)
annotations = api.getEnvs.__annotations__
assert len(annotations) > 0, "getEnvs should have type annotations"
assert 'return' in annotations, "getEnvs should have return type annotation"
print(" ✓ getEnvs has correct signature with type annotations")
print("\n✓ BaseApi properly inherits from Client and adds notify method")
return True
def test_method_signatures():
"""Test that methods have correct type annotations."""
client = Client()
print("\nTesting method signatures...")
# Test getEnvs signature
getEnvs_annotations = client.getEnvs.__annotations__
# Check that annotations exist - could be 'params', or just 'return'
assert len(getEnvs_annotations) > 0, "getEnvs should have type annotations"
assert 'return' in getEnvs_annotations, "getEnvs should have return type annotation"
print(" ✓ getEnvs has type annotations")
# Test other critical methods
assert hasattr(client, 'createEnv'), "Missing createEnv"
assert hasattr(client, 'updateEnv'), "Missing updateEnv"
print(" ✓ Critical methods present")
print("\n✓ All method signatures are correct")
return True
def main():
"""Run all validation tests."""
print("=" * 60)
print("QLAPI Client Validation Tests")
print("=" * 60)
try:
test_client_has_required_methods()
test_baseapi_inheritance()
test_method_signatures()
print("\n" + "=" * 60)
print("ALL TESTS PASSED ✓")
print("=" * 60)
print("\nThe QLAPI Client is working correctly.")
print("Users can safely use: QLAPI.getEnvs({'searchValue': 'USER'})")
return 0
except AssertionError as e:
print(f"\n❌ TEST FAILED: {e}")
return 1
except Exception as e:
print(f"\n❌ UNEXPECTED ERROR: {e}")
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
sys.exit(main())
+3 -7
View File
@@ -2,13 +2,9 @@
echo -e "开始发布"
echo -e "切换master分支"
git checkout master
echo -e "合并develop代码"
git merge origin/develop
echo -e "提交master代码"
git push
git branch -D master
git checkout -b master
git push --set-upstream origin master -f
echo -e "更新cdn文件"
ts-node-transpile-only sample/tool.ts
-44
View File
@@ -48,8 +48,6 @@ export file_notify_py=$dir_scripts/notify.py
export file_notify_js=$dir_scripts/sendNotify.js
export file_test_js=$dir_scripts/ql_sample.js
export file_test_py=$dir_scripts/ql_sample.py
export nginx_app_conf=$dir_root/docker/front.conf
export nginx_conf=$dir_root/docker/nginx.conf
export dep_notify_py=$dir_dep/notify.py
export dep_notify_js=$dir_dep/sendNotify.js
@@ -211,10 +209,6 @@ fix_config() {
cp -f $file_test_py_sample $file_test_py
fi
if [[ -s /etc/nginx/conf.d/default.conf ]]; then
cat /dev/null >/etc/nginx/conf.d/default.conf
fi
if [[ ! -s $dep_notify_js ]]; then
cp -f $file_notify_js_sample $dep_notify_js
fi
@@ -334,44 +328,6 @@ format_timestamp() {
fi
}
init_nginx() {
cp -f $nginx_conf /etc/nginx/nginx.conf
cp -f $nginx_app_conf /etc/nginx/conf.d/front.conf
local location_url="/"
local aliasStr=""
local rootStr=""
if [[ $ql_base_url != "/" ]]; then
if [[ $ql_base_url != /* ]]; then
ql_base_url="/$ql_base_url"
fi
if [[ $ql_base_url != */ ]]; then
ql_base_url="$ql_base_url/"
fi
location_url="^~${ql_base_url%*/}"
aliasStr="alias ${dir_static}/dist;"
if ! grep -q "<base href=\"$ql_base_url\">" "${dir_static}/dist/index.html"; then
awk -v text="<base href=\"$ql_base_url\">" '/<link/ && !inserted {print text; inserted=1} 1' "${dir_static}/dist/index.html" >temp.html
mv temp.html "${dir_static}/dist/index.html"
fi
else
rootStr="root ${dir_static}/dist;"
fi
sed -i "s,QL_ALIAS_CONFIG,${aliasStr},g" /etc/nginx/conf.d/front.conf
sed -i "s,QL_ROOT_CONFIG,${rootStr},g" /etc/nginx/conf.d/front.conf
sed -i "s,QL_BASE_URL_LOCATION,${location_url},g" /etc/nginx/conf.d/front.conf
sed -i "s,QL_BASE_URL,${ql_base_url},g" /etc/nginx/conf.d/front.conf
local ipv6=$(ip a | grep inet6)
local ipv6Str=""
if [[ $ipv6 ]]; then
ipv6Str="listen [::]:${ql_port} ipv6only=on;"
fi
local ipv4Str="listen ${ql_port};"
sed -i "s,IPV6_CONFIG,${ipv6Str},g" /etc/nginx/conf.d/front.conf
sed -i "s,IPV4_CONFIG,${ipv4Str},g" /etc/nginx/conf.d/front.conf
}
get_env_array() {
exported_variables=()
while IFS= read -r line; do
+2
View File
@@ -113,6 +113,8 @@ export default function () {
const responseStatus = error.response.status;
if (responseStatus !== 401) {
history.push('/error');
} else {
window.location.reload();
}
})
.finally(() => setInitLoading(false));
+17 -2
View File
@@ -395,7 +395,11 @@
"PushMe的Keyhttps://push.i-i.me/": "PushMe key, https://push.i-i.me/",
"自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口": "The self built PushMeServer message interface address, for example: http://127.0.0.1:3010 If left blank, use the official message interface",
"ntfy的url地址,例如 https://ntfy.sh": "The URL address of ntfy, for example, https://ntfy.sh.",
"ntfy的消息应用topic": "The topic for ntfy's messaging application.",
"ntfy应用topic": "The topic for ntfy's application.",
"ntfy应用token": "The token for ntfy's application, see https://docs.ntfy.sh/config/#access-tokens",
"ntfy应用用户名": "The username for ntfy's application, see https://docs.ntfy.sh/config/#users-and-roles",
"ntfy应用密码": "The password for ntfy's application, see https://docs.ntfy.sh/config/#users-and-roles",
"ntfy用户动作": "The user actions for ntfy's application, up to three actions, see https://docs.ntfy.sh/publish/?h=actions#action-buttons",
"wxPusherBot的appToken": "wxPusherBot's appToken, obtain according to docs https://wxpusher.zjiecode.com/docs/",
"wxPusherBot的topicIds": "wxPusherBot's topicIds, at least one of topicIds or uids must be configured",
"wxPusherBot的uids": "wxPusherBot's uids, at least one of topicIds or uids must be configured",
@@ -506,5 +510,16 @@
"强制打开可能会导致编辑器显示异常": "Force opening may cause display issues in the editor",
"确认离开": "Confirm Leave",
"当前文件未保存,确认离开吗": "Current file is not saved, are you sure to leave?",
"收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址": "Receiving email address, multiple semicolon separated, sent to the sending email address by default"
"收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址": "Receiving email address, multiple semicolon separated, sent to the sending email address by default",
"选择备份模块": "Select backup module",
"开始备份": "Start backup",
"基础数据": "Basic data",
"脚本文件": "Script files",
"日志文件": "Log files",
"依赖缓存": "Dependency cache",
"远程脚本缓存": "Remote script cache",
"远程仓库缓存": "Remote repository cache",
"SSH 文件缓存": "SSH file cache",
"清除依赖缓存": "Clean dependency cache",
"清除成功": "Clean successful"
}
+17 -3
View File
@@ -395,7 +395,11 @@
"PushMe的Keyhttps://push.i-i.me/": "PushMe的Keyhttps://push.i-i.me/",
"自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口": "自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口",
"ntfy的url地址,例如 https://ntfy.sh": "ntfy的url地址,例如 https://ntfy.sh",
"ntfy的消息应用topic": "ntfy的消息应用topic",
"ntfy应用topic": "ntfy应用topic",
"ntfy应用token": "ntfy应用token,参考 https://docs.ntfy.sh/config/#access-tokens",
"ntfy应用用户名": "ntfy应用用户名,参考 https://docs.ntfy.sh/config/#users-and-roles",
"ntfy应用密码": "ntfy应用密码,参考 https://docs.ntfy.sh/config/#users-and-roles",
"ntfy用户动作": "ntfy用户动作,最多三个动作,参考 https://docs.ntfy.sh/publish/?h=actions#action-buttons",
"wxPusherBot的appToken": "wxPusherBot的appToken, 按照文档获取 https://wxpusher.zjiecode.com/docs/",
"wxPusherBot的topicIds": "wxPusherBot的topicIds, topicIds 和 uids 至少配置一个才行",
"wxPusherBot的uids": "wxPusherBot的uids, topicIds 和 uids 至少配置一个才行",
@@ -506,6 +510,16 @@
"强制打开可能会导致编辑器显示异常": "强制打开可能会导致编辑器显示异常",
"确认离开": "确认离开",
"当前文件未保存,确认离开吗": "当前文件未保存,确认离开吗",
"收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址": "收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址"
"收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址": "收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址",
"选择备份模块": "选择备份模块",
"开始备份": "开始备份",
"基础数据": "基础数据",
"脚本文件": "脚本文件",
"日志文件": "日志文件",
"依赖缓存": "依赖缓存",
"远程脚本缓存": "远程脚本缓存",
"远程仓库缓存": "远程仓库缓存",
"SSH 文件缓存": "SSH 文件缓存",
"清除依赖缓存": "清除依赖缓存",
"清除成功": "清除成功"
}
+3 -4
View File
@@ -56,12 +56,10 @@ interface LogItem {
const CronDetailModal = ({
cron = {},
handleCancel,
visible,
theme,
isPhone,
}: {
cron?: any;
visible: boolean;
handleCancel: (needUpdate?: boolean) => void;
theme: string;
isPhone: boolean;
@@ -440,7 +438,7 @@ const CronDetailModal = ({
</div>
}
centered
open={visible}
open={true}
forceRender
footer={false}
onCancel={() => handleCancel()}
@@ -559,8 +557,8 @@ const CronDetailModal = ({
{contentList[activeTabKey]}
</Card>
</div>
{isLogModalVisible && (
<CronLogModal
visible={isLogModalVisible}
handleCancel={() => {
setIsLogModalVisible(false);
}}
@@ -568,6 +566,7 @@ const CronDetailModal = ({
data={log}
logUrl={logUrl}
/>
)}
</Modal>
);
};
+13 -10
View File
@@ -1037,21 +1037,20 @@ const Crontab = () => {
components={isPhone || pageConf.size < 50 ? undefined : vt}
/>
</div>
{isLogModalVisible && (
<CronLogModal
visible={isLogModalVisible}
handleCancel={() => {
getCronDetail(logCron);
setIsLogModalVisible(false);
}}
cron={logCron}
/>
<CronModal
visible={isModalVisible}
handleCancel={handleCancel}
cron={editedCron}
/>
)}
{isModalVisible && (
<CronModal handleCancel={handleCancel} cron={editedCron} />
)}
{isLabelModalVisible && (
<CronLabelModal
visible={isLabelModalVisible}
handleCancel={(needUpdate?: boolean) => {
setIsLabelModalVisible(false);
if (needUpdate) {
@@ -1060,8 +1059,9 @@ const Crontab = () => {
}}
ids={selectedRowIds}
/>
)}
{isDetailModalVisible && (
<CronDetailModal
visible={isDetailModalVisible}
handleCancel={() => {
setIsDetailModalVisible(false);
}}
@@ -1069,16 +1069,18 @@ const Crontab = () => {
theme={theme}
isPhone={isPhone}
/>
)}
{isCreateViewModalVisible && (
<ViewCreateModal
visible={isCreateViewModalVisible}
handleCancel={(data) => {
setIsCreateViewModalVisible(false);
getCronViews();
}}
/>
)}
{isViewManageModalVisible && (
<ViewManageModal
cronViews={cronViews}
visible={isViewManageModalVisible}
handleCancel={() => {
setIsViewManageModalVisible(false);
}}
@@ -1086,6 +1088,7 @@ const Crontab = () => {
getCronViews();
}}
/>
)}
</PageContainer>
);
};
+24 -27
View File
@@ -1,5 +1,5 @@
import intl from 'react-intl-universal';
import React, { useEffect, useRef, useState } from 'react';
import intl from "react-intl-universal";
import React, { useEffect, useRef, useState } from "react";
import {
Modal,
message,
@@ -8,34 +8,32 @@ import {
Statistic,
Button,
Typography,
} from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
} from "antd";
import { request } from "@/utils/http";
import config from "@/utils/config";
import {
Loading3QuartersOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import { PageLoading } from '@ant-design/pro-layout';
import { logEnded } from '@/utils';
import { CrontabStatus } from './type';
import Ansi from 'ansi-to-react';
} from "@ant-design/icons";
import { PageLoading } from "@ant-design/pro-layout";
import { logEnded } from "@/utils";
import { CrontabStatus } from "./type";
import Ansi from "ansi-to-react";
const { Countdown } = Statistic;
const CronLogModal = ({
cron,
handleCancel,
visible,
data,
logUrl,
}: {
cron?: any;
visible: boolean;
handleCancel: () => void;
data?: string;
logUrl?: string;
}) => {
const [value, setValue] = useState<string>(intl.get('启动中...'));
const [value, setValue] = useState<string>(intl.get("启动中..."));
const [loading, setLoading] = useState<any>(true);
const [executing, setExecuting] = useState<any>(true);
const [isPhone, setIsPhone] = useState(false);
@@ -51,15 +49,15 @@ const CronLogModal = ({
.then(({ code, data }) => {
if (
code === 200 &&
localStorage.getItem('logCron') === uniqPath &&
localStorage.getItem("logCron") === uniqPath &&
data !== value
) {
const log = data as string;
setValue(log || intl.get('暂无日志'));
setValue(log || intl.get("暂无日志"));
const hasNext = Boolean(
log && !logEnded(log) && !log.includes('任务未运行'),
log && !logEnded(log) && !log.includes("日志不存在"),
);
if (!hasNext && !logEnded(value) && value !== intl.get('启动中...')) {
if (!hasNext && !logEnded(value) && value !== intl.get("启动中...")) {
setTimeout(() => {
autoScroll();
});
@@ -87,13 +85,13 @@ const CronLogModal = ({
setTimeout(() => {
document
.querySelector('#log-flag')!
.scrollIntoView({ behavior: 'smooth' });
.querySelector("#log-flag")
?.scrollIntoView({ behavior: "smooth" });
}, 600);
};
const cancel = () => {
localStorage.removeItem('logCron');
localStorage.removeItem("logCron");
handleCancel();
};
@@ -109,7 +107,7 @@ const CronLogModal = ({
const titleElement = () => {
return (
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: "flex", alignItems: "center" }}>
{(executing || loading) && <Loading3QuartersOutlined spin />}
{!executing && !loading && <CheckCircleOutlined />}
<Typography.Text ellipsis={true} style={{ marginLeft: 5 }}>
@@ -120,11 +118,10 @@ const CronLogModal = ({
};
useEffect(() => {
if (cron && cron.id && visible) {
if (cron && cron.id) {
getCronLog(true);
scrollInfoRef.current.down = true;
}
}, [cron, visible]);
}, [cron]);
useEffect(() => {
if (data) {
@@ -139,7 +136,7 @@ const CronLogModal = ({
return (
<Modal
title={titleElement()}
open={visible}
open={true}
centered
className="log-modal"
forceRender
@@ -147,7 +144,7 @@ const CronLogModal = ({
onCancel={() => cancel()}
footer={[
<Button type="primary" onClick={() => cancel()}>
{intl.get('知道了')}
{intl.get("知道了")}
</Button>,
]}
>
@@ -159,7 +156,7 @@ const CronLogModal = ({
style={
isPhone
? {
fontFamily: 'Source Code Pro',
fontFamily: "Source Code Pro",
zoom: 0.83,
}
: {}
+2 -15
View File
@@ -12,10 +12,8 @@ import { ScheduleType } from './type';
const CronModal = ({
cron,
handleCancel,
visible,
}: {
cron?: any;
visible: boolean;
handleCancel: (needUpdate?: boolean) => void;
}) => {
const [form] = Form.useForm();
@@ -58,11 +56,6 @@ const CronModal = ({
}
};
useEffect(() => {
form.resetFields();
setScheduleType(getScheduleType(cron?.schedule));
}, [cron, visible]);
const handleScheduleTypeChange = (type: ScheduleType) => {
setScheduleType(type);
form.setFieldValue('schedule', '');
@@ -146,7 +139,7 @@ const CronModal = ({
return (
<Modal
title={cron?.id ? intl.get('编辑任务') : intl.get('创建任务')}
open={visible}
open={true}
forceRender
centered
maskClosable={false}
@@ -251,10 +244,8 @@ const CronModal = ({
const CronLabelModal = ({
ids,
handleCancel,
visible,
}: {
ids: Array<string>;
visible: boolean;
handleCancel: (needUpdate?: boolean) => void;
}) => {
const [form] = Form.useForm();
@@ -290,10 +281,6 @@ const CronLabelModal = ({
});
};
useEffect(() => {
form.resetFields();
}, [ids, visible]);
const buttons = [
<Button onClick={() => handleCancel(false)}>{intl.get('取消')}</Button>,
<Button type="primary" danger onClick={() => update('delete')}>
@@ -307,7 +294,7 @@ const CronLabelModal = ({
return (
<Modal
title={intl.get('批量修改标签')}
open={visible}
open={true}
footer={buttons}
centered
maskClosable={false}
+11 -15
View File
@@ -56,10 +56,8 @@ enum ViewFilterRelation {
const ViewCreateModal = ({
view,
handleCancel,
visible,
}: {
view?: any;
visible: boolean;
handleCancel: (param?: any) => void;
}) => {
const [form] = Form.useForm();
@@ -101,17 +99,6 @@ const ViewCreateModal = ({
}
};
useEffect(() => {
if (!view) {
form.resetFields();
}
form.setFieldsValue(
view || {
filters: [{ property: 'command' }],
},
);
}, [view, visible]);
const OperationElement = ({ name, ...others }: { name: number }) => {
const property = form.getFieldValue(['filters', name, 'property']);
return (
@@ -172,7 +159,7 @@ const ViewCreateModal = ({
return (
<Modal
title={view ? intl.get('编辑视图') : intl.get('创建视图')}
open={visible}
open={true}
forceRender
width={580}
centered
@@ -190,7 +177,16 @@ const ViewCreateModal = ({
onCancel={() => handleCancel()}
confirmLoading={loading}
>
<Form form={form} layout="vertical" name="env_modal">
<Form
form={form}
layout="vertical"
initialValues={
view || {
filters: [{ property: 'command' }],
}
}
name="env_modal"
>
<Form.Item
name="name"
label={intl.get('视图名称')}
+3 -4
View File
@@ -68,11 +68,9 @@ const DragableBodyRow = ({
const ViewManageModal = ({
cronViews,
handleCancel,
visible,
cronViewChange,
}: {
cronViews: any[];
visible: boolean;
handleCancel: () => void;
cronViewChange: (data?: any) => void;
}) => {
@@ -218,7 +216,7 @@ const ViewManageModal = ({
return (
<Modal
title={intl.get('视图管理')}
open={visible}
open={true}
centered
width={620}
onCancel={() => handleCancel()}
@@ -263,14 +261,15 @@ const ViewManageModal = ({
}}
/>
</DndProvider>
{isCreateViewModalVisible && (
<ViewCreateModal
view={editedView}
visible={isCreateViewModalVisible}
handleCancel={(data) => {
setIsCreateViewModalVisible(false);
cronViewChange(data);
}}
/>
)}
</Modal>
);
};
+3 -3
View File
@@ -618,15 +618,15 @@ const Dependence = () => {
]}
/>
{children}
{isModalVisible && (
<DependenceModal
visible={isModalVisible}
handleCancel={handleCancel}
dependence={editedDependence}
defaultType={type}
/>
{logDependence && (
)}
{logDependence && isLogModalVisible && (
<DependenceLogModal
visible={isLogModalVisible}
handleCancel={(needRemove?: boolean) => {
setIsLogModalVisible(false);
if (needRemove) {
+1 -3
View File
@@ -15,10 +15,8 @@ import { Status } from './type';
const DependenceLogModal = ({
dependence,
handleCancel,
visible,
}: {
dependence?: any;
visible: boolean;
handleCancel: (needRemove?: boolean) => void;
}) => {
const [value, setValue] = useState<string>('');
@@ -128,7 +126,7 @@ const DependenceLogModal = ({
return (
<Modal
title={titleElement()}
open={visible}
open={true}
centered
className="log-modal"
forceRender
+1 -7
View File
@@ -14,11 +14,9 @@ enum DependenceTypes {
const DependenceModal = ({
dependence,
handleCancel,
visible,
defaultType,
}: {
dependence?: any;
visible: boolean;
handleCancel: (cks?: any[]) => void;
defaultType: string;
}) => {
@@ -61,14 +59,10 @@ const DependenceModal = ({
}
};
useEffect(() => {
form.resetFields();
}, [dependence, visible]);
return (
<Modal
title={dependence ? intl.get('编辑依赖') : intl.get('创建依赖')}
open={visible}
open={true}
forceRender
centered
maskClosable={false}
+1 -7
View File
@@ -7,10 +7,8 @@ import config from '@/utils/config';
const EditNameModal = ({
ids,
handleCancel,
visible,
}: {
ids?: string[];
visible: boolean;
handleCancel: () => void;
}) => {
const [form] = Form.useForm();
@@ -34,14 +32,10 @@ const EditNameModal = ({
}
};
useEffect(() => {
form.resetFields();
}, [ids, visible]);
return (
<Modal
title={intl.get('修改环境变量名称')}
open={visible}
open={true}
forceRender
centered
maskClosable={false}
+5 -6
View File
@@ -616,16 +616,15 @@ const Env = () => {
/>
</DndProvider>
</div>
<EnvModal
visible={isModalVisible}
handleCancel={handleCancel}
env={editedEnv}
/>
{isModalVisible && (
<EnvModal handleCancel={handleCancel} env={editedEnv} />
)}
{isEditNameModalVisible && (
<EditNameModal
visible={isEditNameModalVisible}
handleCancel={handleEditNameCancel}
ids={selectedRowIds}
/>
)}
</PageContainer>
);
};
+1 -7
View File
@@ -7,10 +7,8 @@ import config from '@/utils/config';
const EnvModal = ({
env,
handleCancel,
visible,
}: {
env?: any;
visible: boolean;
handleCancel: (cks?: any[]) => void;
}) => {
const [form] = Form.useForm();
@@ -55,14 +53,10 @@ const EnvModal = ({
}
};
useEffect(() => {
form.resetFields();
}, [env, visible]);
return (
<Modal
title={env ? intl.get('编辑变量') : intl.get('创建变量')}
open={visible}
open={true}
forceRender
centered
maskClosable={false}
+15 -8
View File
@@ -14,6 +14,17 @@ const Error = () => {
const [data, setData] = useState(intl.get('暂无日志'));
const retryTimes = useRef(1);
const loopStatus = (message: string) => {
if (retryTimes.current > 3) {
setData(message);
return;
}
retryTimes.current += 1;
setTimeout(() => {
getHealthStatus(false);
}, 3000);
};
const getHealthStatus = (needLoading: boolean = true) => {
needLoading && setLoading(true);
request
@@ -27,19 +38,15 @@ const Error = () => {
}
return;
}
if (retryTimes.current > 3) {
setData(error?.details);
return;
}
retryTimes.current += 1;
setTimeout(() => {
getHealthStatus(false);
}, 3000);
loopStatus(error?.details);
})
.catch((error) => {
const responseStatus = error.response.status;
if (responseStatus === 401) {
history.push('/login');
} else {
loopStatus(error.response?.message || error?.message);
}
})
.finally(() => needLoading && setLoading(false));
+5 -5
View File
@@ -25,11 +25,9 @@ const EditModal = ({
currentNode,
content,
handleCancel,
visible,
}: {
treeData?: any;
content?: string;
visible: boolean;
currentNode: any;
handleCancel: () => void;
}) => {
@@ -223,7 +221,7 @@ const EditModal = ({
width={'100%'}
headerStyle={{ padding: '11px 24px' }}
onClose={cancel}
open={visible}
open={true}
>
{/* @ts-ignore */}
<SplitPane
@@ -256,8 +254,8 @@ const EditModal = ({
<Ansi>{log}</Ansi>
</pre>
</SplitPane>
{saveModalVisible && (
<SaveModal
visible={saveModalVisible}
handleCancel={() => {
setSaveModalVisible(false);
}}
@@ -268,12 +266,14 @@ const EditModal = ({
...cNode,
}}
/>
)}
{settingModalVisible && (
<SettingModal
visible={settingModalVisible}
handleCancel={() => {
setSettingModalVisible(false);
}}
/>
)}
</Drawer>
);
};
+2 -8
View File
@@ -19,9 +19,7 @@ const { Option } = Select;
const EditScriptNameModal = ({
handleCancel,
treeData,
visible,
}: {
visible: boolean;
treeData: any[];
handleCancel: (file?: {
filename: string;
@@ -53,7 +51,7 @@ const EditScriptNameModal = ({
directory ? intl.get('创建文件夹成功') : intl.get('创建文件成功'),
);
const key = path ? `${path}/` : '';
const filename = file ? file.name : (directory || inputFilename);
const filename = file ? file.name : directory || inputFilename;
handleCancel({
filename,
path,
@@ -95,14 +93,10 @@ const EditScriptNameModal = ({
setDirs(dirs);
}, [treeData]);
useEffect(() => {
form.resetFields();
}, [visible]);
return (
<Modal
title={intl.get('创建')}
open={visible}
open={true}
forceRender
centered
maskClosable={false}
+5 -4
View File
@@ -710,9 +710,8 @@ const Script = () => {
}}
/>
)}
{isLogModalVisible && (
{isLogModalVisible && isLogModalVisible && (
<EditModal
visible={isLogModalVisible}
treeData={data}
currentNode={currentNode}
content={value}
@@ -721,16 +720,18 @@ const Script = () => {
}}
/>
)}
{isAddFileModalVisible && (
<EditScriptNameModal
visible={isAddFileModalVisible}
treeData={data}
handleCancel={addFileModalClose}
/>
)}
{isRenameFileModalVisible && (
<RenameModal
visible={isRenameFileModalVisible}
handleCancel={handleRenameFileCancel}
currentNode={currentNode}
/>
)}
</div>
</PageContainer>
);
+1 -7
View File
@@ -7,10 +7,8 @@ import config from '@/utils/config';
const RenameModal = ({
currentNode,
handleCancel,
visible,
}: {
currentNode?: any;
visible: boolean;
handleCancel: () => void;
}) => {
const [form] = Form.useForm();
@@ -38,14 +36,10 @@ const RenameModal = ({
}
};
useEffect(() => {
form.resetFields();
}, [currentNode, visible]);
return (
<Modal
title={intl.get('重命名')}
open={visible}
open={true}
forceRender
centered
maskClosable={false}
+1 -8
View File
@@ -7,10 +7,8 @@ import config from '@/utils/config';
const SaveModal = ({
file,
handleCancel,
visible,
}: {
file?: any;
visible: boolean;
handleCancel: (cks?: any[]) => void;
}) => {
const [form] = Form.useForm();
@@ -32,15 +30,10 @@ const SaveModal = ({
});
};
useEffect(() => {
form.resetFields();
setLoading(false);
}, [file, visible]);
return (
<Modal
title={intl.get('保存文件')}
open={visible}
open={true}
forceRender
centered
maskClosable={false}
+1 -8
View File
@@ -7,10 +7,8 @@ import config from '@/utils/config';
const SettingModal = ({
file,
handleCancel,
visible,
}: {
file?: any;
visible: boolean;
handleCancel: (cks?: any[]) => void;
}) => {
const [form] = Form.useForm();
@@ -30,15 +28,10 @@ const SettingModal = ({
});
};
useEffect(() => {
form.resetFields();
setLoading(false);
}, [file, visible]);
return (
<Modal
title={intl.get('运行设置')}
open={visible}
open={true}
forceRender
centered
onCancel={() => handleCancel()}
+1 -7
View File
@@ -7,10 +7,8 @@ import config from '@/utils/config';
const AppModal = ({
app,
handleCancel,
visible,
}: {
app?: any;
visible: boolean;
handleCancel: (needUpdate?: boolean) => void;
}) => {
const [form] = Form.useForm();
@@ -41,14 +39,10 @@ const AppModal = ({
}
};
useEffect(() => {
form.resetFields();
}, [app, visible]);
return (
<Modal
title={app ? intl.get('编辑应用') : intl.get('创建应用')}
open={visible}
open={true}
forceRender
centered
maskClosable={false}
+39 -39
View File
@@ -1,23 +1,23 @@
import { disableBody } from '@/utils';
import config from '@/utils/config';
import { request } from '@/utils/http';
import WebSocketManager from '@/utils/websocket';
import Ansi from 'ansi-to-react';
import { Button, Modal, Statistic, message } from 'antd';
import { useCallback, useEffect, useRef, useState } from 'react';
import intl from 'react-intl-universal';
import { disableBody } from "@/utils";
import config from "@/utils/config";
import { request } from "@/utils/http";
import WebSocketManager from "@/utils/websocket";
import Ansi from "ansi-to-react";
import { Button, Modal, Statistic, message } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
import intl from "react-intl-universal";
const { Countdown } = Statistic;
const CheckUpdate = ({ systemInfo }: any) => {
const [updateLoading, setUpdateLoading] = useState(false);
const [value, setValue] = useState('');
const [value, setValue] = useState("");
const modalRef = useRef<any>();
const checkUpgrade = () => {
if (updateLoading) return;
setUpdateLoading(true);
message.loading(intl.get('检查更新中...'), 0);
message.loading(intl.get("检查更新中..."), 0);
request
.put(`${config.apiPrefix}system/update-check`)
.then(({ code, data }) => {
@@ -42,17 +42,17 @@ const CheckUpdate = ({ systemInfo }: any) => {
const showForceUpdateModal = (data: any) => {
Modal.confirm({
width: 500,
title: intl.get('更新'),
title: intl.get("更新"),
content: (
<>
<div>{intl.get('已经是最新版了!')}</div>
<div>{intl.get("已经是最新版了!")}</div>
<div style={{ fontSize: 12, fontWeight: 400, marginTop: 5 }}>
{intl.get('青龙')} {data.lastVersion}{' '}
{intl.get('是目前检测到的最新可用版本了。')}
{intl.get("青龙")} {data.lastVersion}{" "}
{intl.get("是目前检测到的最新可用版本了。")}
</div>
</>
),
okText: intl.get('重新下载'),
okText: intl.get("重新下载"),
onOk() {
showUpdatingModal();
request
@@ -71,10 +71,10 @@ const CheckUpdate = ({ systemInfo }: any) => {
width: 500,
title: (
<>
<div>{intl.get('更新可用')}</div>
<div>{intl.get("更新可用")}</div>
<div style={{ fontSize: 12, fontWeight: 400, marginTop: 5 }}>
{intl.get('新版本')} {lastVersion}{' '}
{intl.get('可用,你使用的版本为')} {systemInfo.version}
{intl.get("新版本")} {lastVersion}{" "}
{intl.get("可用,你使用的版本为")} {systemInfo.version}
</div>
</>
),
@@ -83,8 +83,8 @@ const CheckUpdate = ({ systemInfo }: any) => {
<Ansi>{lastLog}</Ansi>
</pre>
),
okText: intl.get('下载更新'),
cancelText: intl.get('以后再说'),
okText: intl.get("下载更新"),
cancelText: intl.get("以后再说"),
onOk() {
showUpdatingModal();
request
@@ -98,14 +98,14 @@ const CheckUpdate = ({ systemInfo }: any) => {
};
const showUpdatingModal = () => {
setValue('');
setValue("");
modalRef.current = Modal.info({
width: 600,
maskClosable: false,
closable: false,
keyboard: false,
okButtonProps: { disabled: true },
title: intl.get('下载更新中...'),
title: intl.get("下载更新中..."),
centered: true,
content: (
<pre>
@@ -122,13 +122,13 @@ const CheckUpdate = ({ systemInfo }: any) => {
message.success({
content: (
<span>
{intl.get('系统将在')}
{intl.get("系统将在")}
<Countdown
className="inline-countdown"
format="ss"
value={Date.now() + 1000 * 30}
/>
{intl.get('秒后自动刷新')}
{intl.get("秒后自动刷新")}
</span>
),
duration: 30,
@@ -147,12 +147,12 @@ const CheckUpdate = ({ systemInfo }: any) => {
Modal.confirm({
width: 600,
maskClosable: false,
title: intl.get('确认重启'),
title: intl.get("确认重启"),
centered: true,
content: intl.get('系统安装包下载成功,确认重启'),
okText: intl.get('重启'),
content: intl.get("系统安装包下载成功,确认重启"),
okText: intl.get("重启"),
onOk() {
reloadSystem('system');
reloadSystem("system");
},
onCancel() {
modalRef.current.update({
@@ -166,7 +166,7 @@ const CheckUpdate = ({ systemInfo }: any) => {
useEffect(() => {
if (!value) return;
const updateFailed = value.includes('失败,请检查');
const updateFailed = value.includes("失败,请检查");
modalRef.current.update({
maskClosable: updateFailed,
@@ -185,19 +185,19 @@ const CheckUpdate = ({ systemInfo }: any) => {
const handleMessage = useCallback((payload: any) => {
let { message: _message } = payload;
const updateFailed = _message.includes('失败,请检查');
const updateFailed = _message.includes("失败,请检查");
if (updateFailed) {
message.error(intl.get('更新失败,请检查网络及日志或稍后再试'));
message.error(intl.get("更新失败,请检查网络及日志或稍后再试"));
}
setTimeout(() => {
document
.querySelector('#log-identifier')!
.scrollIntoView({ behavior: 'smooth' });
.querySelector("#log-identifier")
?.scrollIntoView({ behavior: "smooth" });
}, 600);
if (_message.includes('更新包下载成功')) {
if (_message.includes("更新包下载成功")) {
setTimeout(() => {
showReloadModal();
}, 1000);
@@ -208,24 +208,24 @@ const CheckUpdate = ({ systemInfo }: any) => {
useEffect(() => {
const ws = WebSocketManager.getInstance();
ws.subscribe('updateSystemVersion', handleMessage);
ws.subscribe("updateSystemVersion", handleMessage);
return () => {
ws.unsubscribe('updateSystemVersion', handleMessage);
ws.unsubscribe("updateSystemVersion", handleMessage);
};
}, []);
return (
<>
<Button type="primary" onClick={checkUpgrade}>
{intl.get('检查更新')}
{intl.get("检查更新")}
</Button>
<Button
type="primary"
onClick={() => reloadSystem('reload')}
onClick={() => reloadSystem("reload")}
style={{ marginLeft: 8 }}
>
{intl.get('重新启动')}
{intl.get("重新启动")}
</Button>
</>
);
+52 -1
View File
@@ -1,6 +1,6 @@
import intl from 'react-intl-universal';
import React, { useState, useEffect, useRef } from 'react';
import { Button, InputNumber, Form, message, Input, Alert } from 'antd';
import { Button, InputNumber, Form, message, Input, Alert, Select } from 'antd';
import config from '@/utils/config';
import { request } from '@/utils/http';
import './index.less';
@@ -25,6 +25,7 @@ const Dependence = () => {
const [form] = Form.useForm();
const [log, setLog] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [cleanType, setCleanType] = useState<string>('node');
const getSystemConfig = () => {
request
@@ -84,6 +85,24 @@ const Dependence = () => {
}
};
const cleanDependenceCache = (type: string) => {
setLoading(true);
setLog('');
request
.put(`${config.apiPrefix}system/config/dependence-clean`, {
type,
})
.then(({ code, data }) => {
if (code === 200) {
message.success(intl.get('清除成功'));
}
})
.catch((error: any) => {
console.log(error);
})
.finally(() => setLoading(false));
};
useEffect(() => {
const ws = WebSocketManager.getInstance();
ws.subscribe('updateNodeMirror', handleMessage);
@@ -222,6 +241,38 @@ const Dependence = () => {
</Button>
</Input.Group>
</Form.Item>
<Form.Item
label={intl.get('清除依赖缓存')}
name="clean"
tooltip={{
title: intl.get('清除依赖缓存'),
placement: 'topLeft',
}}
>
<Input.Group compact>
<Select
defaultValue={'node'}
style={{ width: 100 }}
onChange={(value) => {
setCleanType(value);
}}
options={[
{ label: 'node', value: 'node' },
{ label: 'python3', value: 'python3' },
]}
/>
<Button
type="primary"
loading={loading}
onClick={() => {
cleanDependenceCache(cleanType);
}}
style={{ width: 100 }}
>
{intl.get('确认')}
</Button>
</Input.Group>
</Form.Item>
</Form>
<pre
style={{
+3 -5
View File
@@ -363,11 +363,9 @@ const Setting = () => {
]}
/>
</div>
<AppModal
visible={isModalVisible}
handleCancel={handleCancel}
app={editedApp}
/>
{isModalVisible && (
<AppModal handleCancel={handleCancel} app={editedApp} />
)}
</PageContainer>
);
};
+68 -4
View File
@@ -10,6 +10,7 @@ import {
Upload,
Modal,
Select,
Checkbox,
} from 'antd';
import * as DarkReader from '@umijs/ssr-darkreader';
import config from '@/utils/config';
@@ -31,6 +32,19 @@ const dataMap = {
timezone: 'timezone',
};
const exportModules = [
{ value: 'base', label: intl.get('基础数据'), disabled: true },
{ value: 'config', label: intl.get('配置文件') },
{ value: 'scripts', label: intl.get('脚本文件') },
{ value: 'log', label: intl.get('日志文件') },
{ value: 'deps', label: intl.get('依赖文件') },
{ value: 'syslog', label: intl.get('系统日志') },
{ value: 'dep_cache', label: intl.get('依赖缓存') },
{ value: 'raw', label: intl.get('远程脚本缓存') },
{ value: 'repo', label: intl.get('远程仓库缓存') },
{ value: 'ssh.d', label: intl.get('SSH 文件缓存') },
];
const Other = ({
systemInfo,
reloadTheme,
@@ -45,6 +59,8 @@ const Other = ({
const [exportLoading, setExportLoading] = useState(false);
const showUploadProgress = useProgress(intl.get('上传'));
const showDownloadProgress = useProgress(intl.get('下载'));
const [visible, setVisible] = useState(false);
const [selectedModules, setSelectedModules] = useState<string[]>(['base']);
const {
enable: enableDarkMode,
@@ -110,7 +126,7 @@ const Other = ({
request
.put<Blob>(
`${config.apiPrefix}system/data/export`,
{},
{ type: selectedModules },
{
responseType: 'blob',
timeout: 86400000,
@@ -127,7 +143,10 @@ const Other = ({
.catch((error: any) => {
console.log(error);
})
.finally(() => setExportLoading(false));
.finally(() => {
setExportLoading(false);
setVisible(false);
});
};
const showReloadModal = () => {
@@ -178,6 +197,7 @@ const Other = ({
}, []);
return (
<>
<Form layout="vertical" form={form}>
<Form.Item
label={intl.get('主题')}
@@ -196,7 +216,10 @@ const Other = ({
>
{intl.get('亮色')}
</Radio.Button>
<Radio.Button value="dark" style={{ width: 66, textAlign: 'center' }}>
<Radio.Button
value="dark"
style={{ width: 66, textAlign: 'center' }}
>
{intl.get('暗色')}
</Radio.Button>
<Radio.Button
@@ -296,7 +319,14 @@ const Other = ({
/>
</Form.Item>
<Form.Item label={intl.get('数据备份还原')} name="frequency">
<Button type="primary" onClick={exportData} loading={exportLoading}>
<Button
type="primary"
onClick={() => {
setSelectedModules(['base']);
setVisible(true);
}}
loading={exportLoading}
>
{exportLoading ? intl.get('生成数据中...') : intl.get('备份')}
</Button>
<Upload
@@ -332,6 +362,40 @@ const Other = ({
<CheckUpdate systemInfo={systemInfo} />
</Form.Item>
</Form>
<Modal
title={intl.get('选择备份模块')}
open={visible}
onOk={exportData}
onCancel={() => setVisible(false)}
okText={intl.get('开始备份')}
cancelText={intl.get('取消')}
okButtonProps={{ loading: exportLoading }} // 绑定加载状态到按钮
>
<Checkbox.Group
value={selectedModules}
onChange={(v) => {
setSelectedModules(v as string[]);
}}
style={{
width: '100%',
display: 'flex',
flexWrap: 'wrap',
gap: '8px 16px',
}}
>
{exportModules.map((module) => (
<Checkbox
key={module.value}
value={module.value}
disabled={module.disabled}
style={{ marginLeft: 0 }}
>
{module.label}
</Checkbox>
))}
</Checkbox.Group>
</Modal>
</>
);
};
+4 -2
View File
@@ -579,18 +579,20 @@ const Subscription = () => {
loading={loading}
rowClassName={getRowClassName}
/>
{isModalVisible && (
<SubscriptionModal
visible={isModalVisible}
handleCancel={handleCancel}
subscription={editedSubscription}
/>
)}
{isLogModalVisible && (
<SubscriptionLogModal
visible={isLogModalVisible}
handleCancel={() => {
setIsLogModalVisible(false);
}}
subscription={logSubscription}
/>
)}
</PageContainer>
);
};
+3 -5
View File
@@ -14,12 +14,10 @@ import Ansi from 'ansi-to-react';
const SubscriptionLogModal = ({
subscription,
handleCancel,
visible,
data,
logUrl,
}: {
subscription?: any;
visible: boolean;
handleCancel: () => void;
data?: string;
logUrl?: string;
@@ -79,10 +77,10 @@ const SubscriptionLogModal = ({
};
useEffect(() => {
if (subscription && subscription.id && visible) {
if (subscription && subscription.id) {
getCronLog(true);
}
}, [subscription, visible]);
}, [subscription]);
useEffect(() => {
if (data) {
@@ -97,7 +95,7 @@ const SubscriptionLogModal = ({
return (
<Modal
title={titleElement()}
open={visible}
open={true}
centered
className="log-modal"
forceRender
+18 -23
View File
@@ -22,17 +22,19 @@ const fileUrlRegx = /([^\/\:]+\/[^\/\.]+)\.[a-z]+$/;
const SubscriptionModal = ({
subscription,
handleCancel,
visible,
}: {
subscription?: any;
visible: boolean;
handleCancel: (needUpdate?: boolean) => void;
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [type, setType] = useState('public-repo');
const [scheduleType, setScheduleType] = useState('crontab');
const [pullType, setPullType] = useState<'ssh-key' | 'user-pwd'>('ssh-key');
const [type, setType] = useState(subscription?.type || 'public-repo');
const [scheduleType, setScheduleType] = useState(
subscription?.schedule_type || 'crontab',
);
const [pullType, setPullType] = useState<'ssh-key' | 'user-pwd'>(
subscription?.pull_type || 'ssh-key',
);
const handleOk = async (values: any) => {
setLoading(true);
@@ -255,29 +257,17 @@ const SubscriptionModal = ({
};
useEffect(() => {
if (visible) {
window.addEventListener('paste', onPaste);
} else {
window.removeEventListener('paste', onPaste);
}
}, [visible]);
useEffect(() => {
form.setFieldsValue(
{ ...subscription, ...formatParams(subscription) } || {},
);
setType((subscription && subscription.type) || 'public-repo');
setScheduleType((subscription && subscription.schedule_type) || 'crontab');
setPullType((subscription && subscription.pull_type) || 'ssh-key');
if (!subscription) {
form.resetFields();
}
}, [subscription, visible]);
return () => {
window.removeEventListener('paste', onPaste);
};
}, []);
return (
<Modal
title={subscription ? intl.get('编辑订阅') : intl.get('创建订阅')}
open={visible}
open={true}
forceRender
centered
maskClosable={false}
@@ -294,7 +284,12 @@ const SubscriptionModal = ({
onCancel={() => handleCancel()}
confirmLoading={loading}
>
<Form form={form} name="form_in_modal" layout="vertical">
<Form
form={form}
name="form_in_modal"
layout="vertical"
initialValues={{ ...subscription, ...formatParams(subscription) }}
>
<Form.Item
name="name"
label={intl.get('名称')}
+5 -1
View File
@@ -128,10 +128,14 @@ export default {
},
{
label: 'ntfyTopic',
tip: intl.get('ntfy的消息应用topic'),
tip: intl.get('ntfy应用topic'),
required: true,
},
{ label: 'ntfyPriority', tip: intl.get('推送消息的优先级') },
{ label: 'ntfyToken', tip: intl.get('ntfy应用token') },
{ label: 'ntfyUsername', tip: intl.get('ntfy应用用户名') },
{ label: 'ntfyPassword', tip: intl.get('ntfy应用密码') },
{ label: 'ntfyActions', tip: intl.get('ntfy用户动作') },
],
chat: [
{
+1 -1
View File
@@ -14,7 +14,7 @@ export interface IResponseData {
code?: number;
data?: any;
message?: string;
errors?: any[];
error?: any;
}
export type Override<
+9 -12
View File
@@ -1,13 +1,10 @@
version: 2.19.0
changeLogLink: https://t.me/jiao_long/429
publishTime: 2025-05-11 08:00
version: 2.19.2
changeLogLink: https://t.me/jiao_long/431
publishTime: 2025-06-27 23:59
changeLog: |
1. 缓存 node 和 python 依赖,linux 依赖需要增加映射目录
2. 减少启动服务数,节约启动内存约 50%
3. 邮箱通知支持多个收件人
4. boot 任务改为在依赖安装完成后执行
5. 修复脚本管理查询子目录逻辑
6. 修复脚本管理增加文件夹
7. 修复 QLAPI 修复环境变量 remarks
8. 修复 mjs 依赖查不到
9. 修复无法删除日志文件
1. 备份数据支持选择模块,支持清除依赖缓存
2. QLAPI 和 openapi 的 systemNotify 支持自定义通知类型和参数
3. ntfy 增加可选的认证与用户动作,感谢 https://github.com/liheji
4. 修复取消安装依赖
5. 修复环境变量过大解析报错
6. 修改服务启动方式