Compare commits

...
24 Commits
Author SHA1 Message Date
whyour 046239404f 更新版本 v2.16.4 2023-10-19 21:37:00 +08:00
whyour b90243f55c 修改 task 日志展示逻辑 2023-10-19 21:36:39 +08:00
whyour 43c0cd8132 修复 mac 任务无法正常退出 2023-10-16 23:44:39 +08:00
whyour 533e12a796 脚本推送增加自定义 webhook 方式 2023-10-15 20:42:05 +08:00
whyour bd004a0489 修复设置代理时,schedule 服务通信异常 2023-10-14 23:05:25 +08:00
whyour 41befcc0a8 更新 readme 2023-10-14 21:50:12 +08:00
whyour 9fb9b3d121 task 命令支持给脚本传参,使用 -- 分割,后面的参数都会传给脚本
例如 task x.js -n whyour,脚本就能接收到参数 -n whyour
2023-10-14 21:47:46 +08:00
5055045d22 增加chronocat无头模式的QQNT推送 用于无法使go-cqhttp的接替 (#2141)
* 增加chronocat无头模式的QQNT推送

* Chronocat发送时没有配置群号或个人消息号发送出错

* 增加系统通知、去除注释

* 增加系统通知

---------

Co-authored-by: child <wulincheng@javalc.com>
2023-10-14 20:56:32 +08:00
whyour 9f7beb934d 系统日志增加置顶置底按钮 2023-10-13 23:44:28 +08:00
whyour 26b06c17c5 修复清空文件夹报错 2023-10-13 15:35:50 +08:00
whyour b8a9b26ca3 修复定时任务任务前后命令校验 2023-10-12 00:55:22 +08:00
whyour b6376ed2e8 修复定时任务 task_after 命令无法停止 2023-10-12 00:45:49 +08:00
whyour 99f6073c8e 定时任务增加前后规则校验,不能包含 task 命令 2023-10-10 23:06:01 +08:00
whyour 5e73f0390f 修复定时任务详情页查看历史日志异常 2023-10-09 23:56:35 +08:00
whyour c35cfba8b0 修改任务队列重新设置并发 2023-10-08 22:18:54 +08:00
whyour aac109621a 修改任务队列日志打印 2023-10-08 22:11:46 +08:00
whyour a340964c82 修改获取登录 ip 判断 2023-10-08 00:10:46 +08:00
whyour 00818b694a 使用 spawn exit 方法监听进程退出 2023-10-07 22:51:44 +08:00
whyour ec5b885476 修改任务队列执行日志 2023-10-06 02:34:40 +08:00
whyour 9d55cb108c 定时任务详情增加额外定时展示 2023-09-29 23:16:07 +08:00
whyour 4c19054b30 修复 api 前缀 2023-09-29 16:26:03 +08:00
whyour 2a41f64d1b 修复 QlBaseUrl 末尾不加斜杠无法访问 2023-09-29 16:07:41 +08:00
whyour d3023d31e3 修复定时任务日志滚动 2023-09-27 23:04:16 +08:00
whyour eddc03e295 修复定时任务间隔较小,任务状态不准确 2023-09-26 22:51:33 +08:00
55 changed files with 1424 additions and 547 deletions
+64 -57
View File
@@ -36,6 +36,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
- Support cell phone operation
## Version
### docker
The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image.
@@ -53,6 +54,66 @@ The npm version supports `debian/ubuntu/centos/alpine` systems and requires `nod
npm i @whyour/qinglong
```
## Built-in commands
1. task
```bash
# Execute in sequence, if a random delay is set, it will be randomly delayed by a certain number of seconds
task <file_path>
# Execute in sequence, regardless of whether a random delay is set, all run immediately,
# and the foreground will output the day, while recorded in the log file
task <file_path> now
# Concurrent execution, regardless of whether a random delay is set, are run immediately,
# the foreground does not generate the day, directly recorded in the log file, and can be specified account execution
task <file_path> conc <env_name> <account_number>(Optional)
# Specify the account to execute and run immediately regardless of whether a random delay is set
task <file_path> desi <env_name> <account_number>
# Set task timeout
task -m <max_time> <file_path>
# Print task log in real time, no need to carry this parameter when creating timed tasks
task -l <file_path>
# Use -- to split, -- followed by a parameter that is passed to the script, as in the following example, the script receives the parameter -u whyour -p password
task <file_path> -- -u whyour -p password
```
1. ql
```bash
# Update and restart Green Dragon
ql update
# Run custom scripts extra.sh
ql extra
# Adding a single script file
ql raw <file_url>
# Add a specific script for a single repository
ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch>
# Delete old logs
ql rmlog <days>
# Start bot
ql bot
# Detecting the Green Dragon environment and repairing it
ql check
# Reset the number of login errors
ql resetlet
# Disable two-step login
ql resettfa
```
1. parameter description
- file_url: Script address
- repo_url: Repository address
- whitelist: The whitelist when pulling the repository, i.e., the string contained in the path of the script to be pulled
- blacklist: Blacklisting when pulling repositories, i.e. strings that are not included in the path of the script to be pulled
- dependence: Pulling the dependencies needed for the repository will be copied directly from the repository to the repository directory under scripts, regardless of the blacklist
- branch: Pull the branch of the repository
- days: Number of days of logs to be kept
- file_path: File path for task execution
- env_name: The name of the environment variable that needs to be concurrent or specified at the time of task execution
- account_number: Specify the account number of an environment variable to be executed when the task is executed
- max_time: Timeout, suffix "s" for seconds (default), "m" for minutes, "h" for hours, "d" for days
## Deployment
### Docker (Recommended)
@@ -63,7 +124,7 @@ docker run -dit \
-v $PWD/ql/data:/ql/data \
# The 5700 after the colon is the default port, if QlPort is set, it needs to be the same as QlPort.
-p 5700:5700 \
# Deployment paths are not required and begin and end with a slash, e.g. /test/.
# Deployment paths are not required, e.g. /test.
-e QlBaseUrl="/" \
# Deployment port is not required, when using host mode, you can set the port after service startup, default 5700
-e QlPort="5700" \
@@ -95,7 +156,7 @@ podman run -dit \
-v $PWD/ql/data:/ql/data \
# The 5700 after the colon is the default port, if QlPort is set, it needs to be the same as QlPort.
-p 5700:5700 \
# Deployment paths are not required and begin and end with a slash, e.g. /test/.
# Deployment paths are not required, e.g. /test.
-e QlBaseUrl="/" \
# Deployment port is not required, when using host mode, you can set the port after service startup, default 5700
-e QlPort="5700" \
@@ -118,60 +179,6 @@ export QL_DATA_DIR=""
qinglong
```
## Use
1. built-in commands
```bash
# Update and restart Green Dragon
ql update
# Run custom scripts extra.sh
ql extra
# Adding a single script file
ql raw <file_url>
# Add a specific script for a single repository
ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch>
# Delete old logs
ql rmlog <days>
# Start bot
ql bot
# Detecting the Green Dragon environment and repairing it
ql check
# Reset the number of login errors
ql resetlet
# Disable two-step login
ql resettfa
# Execute in sequence, if a random delay is set, it will be randomly delayed by a certain number of seconds
task <file_path>
# Execute in sequence, regardless of whether a random delay is set, all run immediately,
# and the foreground will output the day, while recorded in the log file
task <file_path> now
# Concurrent execution, regardless of whether a random delay is set, are run immediately,
# the foreground does not generate the day, directly recorded in the log file, and can be specified account execution
task <file_path> conc <env_name> <account_number>(Optional)
# Specify the account to execute and run immediately regardless of whether a random delay is set
task <file_path> desi <env_name> <account_number>
# Set task timeout
task -m <max_time> <file_path>
# Print task log in real time, no need to carry this parameter when creating timed tasks
task -l <file_path>
```
2. parameter description
* file_url: Script address
* repo_url: Repository address
* whitelist: The whitelist when pulling the repository, i.e., the string contained in the path of the script to be pulled
* blacklist: Blacklisting when pulling repositories, i.e. strings that are not included in the path of the script to be pulled
* dependence: Pulling the dependencies needed for the repository will be copied directly from the repository to the repository directory under scripts, regardless of the blacklist
* branch: Pull the branch of the repository
* days: Number of days of logs to be kept
* file_path: File path for task execution
* env_name: The name of the environment variable that needs to be concurrent or specified at the time of task execution
* account_number: Specify the account number of an environment variable to be executed when the task is executed
* max_time: Timeout, suffix "s" for seconds (default), "m" for minutes, "h" for hours, "d" for days
## Development
```bash
@@ -184,7 +191,7 @@ $ pnpm install
$ pnpm start
```
Open your browser and visit http://127.0.0.1:5700
Open your browser and visit <http://127.0.0.1:5700>
## Links
+62 -55
View File
@@ -38,6 +38,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
- 支持手机端操作
## 版本
### docker
`latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像
@@ -55,6 +56,65 @@ npm 版本支持 `debian/ubuntu/centos/alpine` 系统,需要自行安装 `node
npm i @whyour/qinglong
```
## 内置命令
1. task
```bash
# 依次执行,如果设置了随机延迟,将随机延迟一定秒数
task <file_path>
# 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日,同时记录在日志文件中
task <file_path> now
# 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日,直接记录在日志文件中,且可指定账号执行
task <file_path> conc <env_name> <account_number>(可选的)
# 指定账号执行,无论是否设置了随机延迟,均立即运行
task <file_path> desi <env_name> <account_number>
# 设置任务超时时间
task -m <max_time> <file_path>
# 实时打印任务日志,创建定时任务时,不用携带此参数
task -l <file_path>
# 使用 -- 分割,-- 后面的参数会传给脚本,下面的例子,脚本就可接收到参数 -u whyour -p password
task <file_path> -- -u whyour -p password
```
1. ql
```bash
# 更新并重启青龙
ql update
# 运行自定义脚本extra.sh
ql extra
# 添加单个脚本文件
ql raw <file_url>
# 添加单个仓库的指定脚本
ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch> <extensions>
# 删除旧日志
ql rmlog <days>
# 启动tg-bot
ql bot
# 检测青龙环境并修复
ql check
# 重置登录错误次数
ql resetlet
# 禁用两步登录
ql resettfa
```
1. 参数说明
- file_url: 脚本地址
- repo_url: 仓库地址
- whitelist: 拉取仓库时的白名单,即就是需要拉取的脚本的路径包含的字符串,多个竖线分割
- blacklist: 拉取仓库时的黑名单,即就是需要拉取的脚本的路径不包含的字符串,多个竖线分割
- dependence: 拉取仓库需要的依赖文件,会直接从仓库拷贝到scripts下的仓库目录,不受黑名单影响,多个竖线分割
- extensions: 拉取仓库的文件后缀,多个竖线分割
- branch: 拉取仓库的分支
- days: 需要保留的日志的天数
- file_path: 任务执行时的文件路径
- env_name: 任务执行时需要并发或者指定时的环境变量名称
- account_number: 任务执行时指定某个环境变量需要执行的账号序号
- max_time: 超时时间,后缀"s"代表秒(默认值), "m"代表分, "h"代表小时, "d"代表天
## 部署
### docker (推荐)
@@ -65,7 +125,7 @@ docker run -dit \
-v $PWD/ql/data:/ql/data \
# 冒号后面的 5700 为默认端口,如果设置了 QlPort, 需要跟 QlPort 保持一致
-p 5700:5700 \
# 部署路径非必须,以斜杠开头和结尾,比如 /test/
# 部署路径非必须,比如 /test
-e QlBaseUrl="/" \
# 部署端口非必须,当使用 host 模式时,可以设置服务启动后的端口,默认 5700
-e QlPort="5700" \
@@ -97,7 +157,7 @@ podman run -dit \
-v $PWD/ql/data:/ql/data \
# 冒号后面的 5700 为默认端口,如果设置了 QlPort, 需要跟 QlPort 保持一致
-p 5700:5700 \
# 部署路径非必须,以斜杠开头和结尾,比如 /test/
# 部署路径非必须,比如 /test
-e QlBaseUrl="/" \
# 部署端口非必须,当使用 host 模式时,可以设置服务启动后的端口,默认 5700
-e QlPort="5700" \
@@ -120,59 +180,6 @@ export QL_DATA_DIR=""
qinglong
```
## 使用
1. 内置命令
```bash
# 更新并重启青龙
ql update
# 运行自定义脚本extra.sh
ql extra
# 添加单个脚本文件
ql raw <file_url>
# 添加单个仓库的指定脚本
ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch> <extensions>
# 删除旧日志
ql rmlog <days>
# 启动tg-bot
ql bot
# 检测青龙环境并修复
ql check
# 重置登录错误次数
ql resetlet
# 禁用两步登录
ql resettfa
# 依次执行,如果设置了随机延迟,将随机延迟一定秒数
task <file_path>
# 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日,同时记录在日志文件中
task <file_path> now
# 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日,直接记录在日志文件中,且可指定账号执行
task <file_path> conc <env_name> <account_number>(可选的)
# 指定账号执行,无论是否设置了随机延迟,均立即运行
task <file_path> desi <env_name> <account_number>
# 设置任务超时时间
task -m <max_time> <file_path>
# 实时打印任务日志,创建定时任务时,不用携带此参数
task -l <file_path>
```
2. 参数说明
* file_url: 脚本地址
* repo_url: 仓库地址
* whitelist: 拉取仓库时的白名单,即就是需要拉取的脚本的路径包含的字符串,多个竖线分割
* blacklist: 拉取仓库时的黑名单,即就是需要拉取的脚本的路径不包含的字符串,多个竖线分割
* dependence: 拉取仓库需要的依赖文件,会直接从仓库拷贝到scripts下的仓库目录,不受黑名单影响,多个竖线分割
* extensions: 拉取仓库的文件后缀,多个竖线分割
* branch: 拉取仓库的分支
* days: 需要保留的日志的天数
* file_path: 任务执行时的文件路径
* env_name: 任务执行时需要并发或者指定时的环境变量名称
* account_number: 任务执行时指定某个环境变量需要执行的账号序号
* max_time: 超时时间,后缀"s"代表秒(默认值), "m"代表分, "h"代表小时, "d"代表天
## 开发
```bash
+2 -3
View File
@@ -458,13 +458,12 @@ export default (app: Router) => {
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.status({
...req.body,
status: parseInt(req.body.status),
pid: parseInt(req.body.pid) || '',
status: req.body.status ? parseInt(req.body.status) : undefined,
pid: req.body.pid ? parseInt(req.body.pid) : undefined,
});
return res.send({ code: 200, data });
} catch (e) {
+1 -1
View File
@@ -65,7 +65,7 @@ export default (app: Router) => {
};
const filePath = join(config.logPath, path, filename);
if (type === 'directory') {
emptyDir(filePath);
await emptyDir(filePath);
} else {
fs.unlinkSync(filePath);
}
+4 -3
View File
@@ -183,7 +183,7 @@ export default (app: Router) => {
};
const filePath = join(config.scriptPath, path, filename);
if (type === 'directory') {
emptyDir(filePath);
await emptyDir(filePath);
} else {
fs.unlinkSync(filePath);
}
@@ -260,7 +260,6 @@ export default (app: Router) => {
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let { filename, path, pid } = req.body;
const { name, ext } = parse(filename);
@@ -269,7 +268,9 @@ export default (app: Router) => {
const scriptService = Container.get(ScriptService);
const result = await scriptService.stopScript(filePath, pid);
emptyDir(logPath);
setTimeout(() => {
emptyDir(logPath);
}, 3000);
res.send(result);
} catch (e) {
return next(e);
-1
View File
@@ -2,7 +2,6 @@ import 'reflect-metadata'; // We need this in order to use @Decorators
import config from './config';
import express from 'express';
import Logger from './loaders/logger';
import path from 'path';
async function startServer() {
const app = express();
+8
View File
@@ -6,6 +6,14 @@ let pickedEnv: Record<string, string>;
function getPickedEnv() {
if (pickedEnv) return pickedEnv;
const picked = pick(process.env, ['QlBaseUrl', 'DeployEnv']);
if (picked.QlBaseUrl) {
if (!picked.QlBaseUrl.startsWith('/')) {
picked.QlBaseUrl = `/${picked.QlBaseUrl}`
}
if (!picked.QlBaseUrl.endsWith('/')) {
picked.QlBaseUrl = `${picked.QlBaseUrl}/`
}
}
pickedEnv = picked as Record<string, string>;
return picked;
}
+16 -7
View File
@@ -84,6 +84,11 @@ export async function getNetIp(req: any) {
if (ip.includes('127.0') || ip.includes('192.168') || ip.includes('10.7')) {
ip = '';
}
if (!ip) {
return { address: `获取失败`, ip };
}
try {
const baiduApi = got
.get(`https://www.cip.cc/${ip}`, { timeout: 10000, retry: 0 })
@@ -298,17 +303,21 @@ export function readDir(
return result;
}
export function emptyDir(path: string) {
export async function emptyDir(path: string) {
const pathExist = await fileExist(path);
if (!pathExist) {
return;
}
const files = fs.readdirSync(path);
files.forEach((file) => {
for (const file of files) {
const filePath = `${path}/${file}`;
const stats = fs.statSync(filePath);
if (stats.isDirectory()) {
emptyDir(filePath);
await emptyDir(filePath);
} else {
fs.unlinkSync(filePath);
}
});
}
fs.rmdirSync(path);
}
@@ -414,11 +423,11 @@ export function psTree(pid: number): Promise<number[]> {
export async function killTask(pid: number) {
const pids = await psTree(pid);
// SIGINT 2 程序终止(interrupt)信号,不会打印额外信息
if (pids.length) {
try {
[pid, ...pids].forEach((x) => {
process.kill(x, 2);
[pid, ...pids].reverse().forEach((x) => {
process.kill(x, 15);
});
} catch (error) { }
} else {
+9
View File
@@ -18,6 +18,7 @@ export enum NotificationMode {
'pushMe' = 'pushMe',
'feishu' = 'feishu',
'webhook' = 'webhook',
'chronocat' = 'Chronocat',
}
abstract class NotificationBaseInfo {
@@ -108,6 +109,12 @@ export class PushMeNotification extends NotificationBaseInfo {
public pushMeKey: string = '';
}
export class ChronocatNotification extends NotificationBaseInfo {
public chronocatURL: string = '';
public chronocatQQ: string = '';
public chronocatToekn: string = '';
}
export class WebhookNotification extends NotificationBaseInfo {
public webhookHeaders: string = '';
public webhookBody: string = '';
@@ -140,4 +147,6 @@ export interface NotificationInfo
EmailNotification,
PushMeNotification,
WebhookNotification,
ChronocatNotification,
LarkNotification {}
-4
View File
@@ -30,13 +30,9 @@ export default async ({ server }: { server: Server }) => {
process.on('uncaughtException', (error) => {
Logger.error('Uncaught exception:', error);
console.error('Uncaught exception:', error);
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
Logger.error('Unhandled rejection:', reason, promise);
console.error('Unhandled rejection:', reason, promise);
process.exit(1);
});
};
+1
View File
@@ -14,6 +14,7 @@ message ICron {
string schedule = 2;
string command = 3;
repeated ISchedule extra_schedules = 4;
string name = 5;
}
message AddCronRequest { repeated ICron crons = 1; }
+15 -1
View File
@@ -24,6 +24,7 @@ export interface ICron {
schedule: string;
command: string;
extraSchedules: ISchedule[];
name: string;
}
export interface AddCronRequest {
@@ -97,7 +98,7 @@ export const ISchedule = {
};
function createBaseICron(): ICron {
return { id: "", schedule: "", command: "", extraSchedules: [] };
return { id: "", schedule: "", command: "", extraSchedules: [], name: "" };
}
export const ICron = {
@@ -114,6 +115,9 @@ export const ICron = {
for (const v of message.extraSchedules) {
ISchedule.encode(v!, writer.uint32(34).fork()).ldelim();
}
if (message.name !== "") {
writer.uint32(42).string(message.name);
}
return writer;
},
@@ -152,6 +156,13 @@ export const ICron = {
message.extraSchedules.push(ISchedule.decode(reader, reader.uint32()));
continue;
case 5:
if (tag !== 42) {
break;
}
message.name = reader.string();
continue;
}
if ((tag & 7) === 4 || tag === 0) {
break;
@@ -169,6 +180,7 @@ export const ICron = {
extraSchedules: Array.isArray(object?.extraSchedules)
? object.extraSchedules.map((e: any) => ISchedule.fromJSON(e))
: [],
name: isSet(object.name) ? String(object.name) : "",
};
},
@@ -182,6 +194,7 @@ export const ICron = {
} else {
obj.extraSchedules = [];
}
message.name !== undefined && (obj.name = message.name);
return obj;
},
@@ -195,6 +208,7 @@ export const ICron = {
message.schedule = object.schedule ?? "";
message.command = object.command ?? "";
message.extraSchedules = object.extraSchedules?.map((e) => ISchedule.fromPartial(e)) || [];
message.name = object.name ?? "";
return message;
},
};
+7 -6
View File
@@ -3,7 +3,6 @@ import { AddCronRequest, AddCronResponse } from '../protos/cron';
import nodeSchedule from 'node-schedule';
import { scheduleStacks } from './data';
import { runCron } from '../shared/runCron';
import { QL_PREFIX, TASK_PREFIX } from '../config/const';
import Logger from '../loaders/logger';
const addCron = (
@@ -11,14 +10,15 @@ const addCron = (
callback: sendUnaryData<AddCronResponse>,
) => {
for (const item of call.request.crons) {
const { id, schedule, command, extraSchedules } = item;
const { id, schedule, command, extraSchedules, name } = item;
if (scheduleStacks.has(id)) {
scheduleStacks.get(id)?.forEach((x) => x.cancel());
}
Logger.info(
'[schedule][创建定时任务], 任务ID: %s, cron: %s, 执行命令: %s',
'[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
name,
schedule,
command,
);
@@ -26,8 +26,9 @@ const addCron = (
if (extraSchedules?.length) {
extraSchedules.forEach(x => {
Logger.info(
'[schedule][创建定时任务], 任务ID: %s, cron: %s, 执行命令: %s',
'[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
name,
x.schedule,
command,
);
@@ -37,13 +38,13 @@ const addCron = (
scheduleStacks.set(id, [
nodeSchedule.scheduleJob(id, schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command);
runCron(command, { name, schedule, extraSchedules });
}),
...(extraSchedules?.length
? extraSchedules.map((x) =>
nodeSchedule.scheduleJob(id, x.schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command);
runCron(command, { name, schedule, extraSchedules });
}),
)
: []),
+23 -18
View File
@@ -14,6 +14,8 @@ import cronClient from '../schedule/client';
import taskLimit from '../shared/pLimit';
import { spawn } from 'cross-spawn';
import dayjs from 'dayjs';
import pickBy from 'lodash/pickBy';
import omit from 'lodash/omit';
@Service()
export default class CronService {
@@ -33,7 +35,7 @@ export default class CronService {
const doc = await this.insert(tab);
if (this.isSixCron(doc) || doc.extra_schedules?.length) {
await cronClient.addCron([
{ id: String(doc.id), schedule: doc.schedule!, command: this.makeCommand(doc), extraSchedules: doc.extra_schedules || [] },
{ name: doc.name || '', id: String(doc.id), schedule: doc.schedule!, command: this.makeCommand(doc), extraSchedules: doc.extra_schedules || [] },
]);
}
await this.set_crontab();
@@ -58,6 +60,7 @@ export default class CronService {
if (this.isSixCron(newDoc) || newDoc.extra_schedules?.length) {
await cronClient.addCron([
{
name: doc.name || '',
id: String(newDoc.id),
schedule: newDoc.schedule!,
command: this.makeCommand(newDoc),
@@ -89,7 +92,7 @@ export default class CronService {
last_running_time: number;
last_execution_time: number;
}) {
const options: any = {
let options: any = {
status,
pid,
log_path,
@@ -99,7 +102,13 @@ export default class CronService {
options.last_running_time = last_running_time;
}
return await CrontabModel.update({ ...options }, { where: { id: ids } });
for (const id of ids) {
const cron = await this.getDb({ id });
if (status === CrontabStatus.idle && log_path !== cron.log_path) {
options = omit(options, ['status', 'log_path', 'pid']);
}
await CrontabModel.update({ ...pickBy(options, (v) => v === 0 || !!v) }, { where: { id } });
}
}
public async remove(ids: number[]) {
@@ -383,15 +392,18 @@ export default class CronService {
);
}
private async runSingle(cronId: number): Promise<number> {
return taskLimit.runWithCpuLimit(() => {
private async runSingle(cronId: number): Promise<number | void> {
return taskLimit.runWithCronLimit(() => {
return new Promise(async (resolve: any) => {
const cron = await this.getDb({ id: cronId });
const params = { name: cron.name, command: cron.command, schedule: cron.schedule, extraSchedules: cron.extra_schedules };
if (cron.status !== CrontabStatus.queued) {
resolve();
resolve(params);
return;
}
this.logger.info(`[panel][开始执行任务] 参数 ${JSON.stringify(params)}`);
let { id, command, log_path } = cron;
const uniqPath = await getUniqPath(command, `${id}`);
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
@@ -402,10 +414,6 @@ export default class CronService {
const logPath = `${uniqPath}/${logTime}.log`;
const absolutePath = path.resolve(config.logPath, `${logPath}`);
this.logger.silly('Running job');
this.logger.silly('ID: ' + id);
this.logger.silly('Original command: ' + command);
const cp = spawn(`real_log_path=${logPath} no_delay=true ${this.makeCommand(cron)}`, { shell: '/bin/bash' });
await CrontabModel.update(
@@ -419,17 +427,12 @@ export default class CronService {
fs.appendFileSync(`${absolutePath}`, `${JSON.stringify(err)}`);
});
cp.on('exit', async (code, signal) => {
this.logger.info(
`[panel][任务退出] 任务 ${command} 进程id: ${cp.pid}, 退出码 ${code}`,
);
});
cp.on('close', async (code) => {
cp.on('exit', async (code) => {
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id } },
);
resolve();
resolve({ ...params, pid: cp.pid, code });
});
});
});
@@ -447,6 +450,7 @@ export default class CronService {
const sixCron = docs
.filter((x) => this.isSixCron(x))
.map((doc) => ({
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
@@ -498,7 +502,7 @@ export default class CronService {
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
command = `${TASK_PREFIX}${tab.command}`;
}
let commandVariable = `ID=${tab.id} `
let commandVariable = `no_tee=true ID=${tab.id} `
if (tab.task_before) {
commandVariable += `task_before='${tab.task_before.replace(/'/g, "'\\''")
.trim()}' `;
@@ -578,6 +582,7 @@ export default class CronService {
const sixCron = tabs.data
.filter((x) => this.isSixCron(x) && x.isDisabled !== 1)
.map((doc) => ({
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
+1 -1
View File
@@ -273,7 +273,7 @@ export default class DependenceService {
this.updateLog(depIds, JSON.stringify(err));
});
cp.on('close', async (code) => {
cp.on('exit', async (code) => {
const endTime = dayjs();
const isSucceed = code === 0;
const resultText = isSucceed ? '成功' : '失败';
+66 -7
View File
@@ -1,12 +1,12 @@
import { NotificationInfo } from '../data/notify';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import UserService from './user';
import got from 'got';
import nodemailer from 'nodemailer';
import crypto from 'crypto';
import got from 'got';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
import nodemailer from 'nodemailer';
import { Inject, Service } from 'typedi';
import winston from 'winston';
import { parseBody, parseHeaders } from '../config/util';
import { NotificationInfo } from '../data/notify';
import UserService from './user';
@Service()
export default class NotificationService {
@@ -31,6 +31,7 @@ export default class NotificationService {
['pushMe', this.pushMe],
['webhook', this.webhook],
['lark', this.lark],
['chronocat', this.chronocat],
]);
private title = '';
@@ -195,7 +196,8 @@ export default class NotificationService {
}
private async bark() {
let { barkPush, barkIcon, barkSound, barkGroup, barkLevel, barkUrl } = this.params;
let { barkPush, barkIcon, barkSound, barkGroup, barkLevel, barkUrl } =
this.params;
if (!barkPush.startsWith('http')) {
barkPush = `https://api.day.app/${barkPush}`;
}
@@ -588,6 +590,63 @@ export default class NotificationService {
}
}
private async chronocat() {
const { chronocatURL, chronocatQQ, chronocatToekn } = this.params;
try {
const user_ids = chronocatQQ
.match(/user_id=(\d+)/g)
?.map((match: any) => match.split('=')[1]);
const group_ids = chronocatQQ
.match(/group_id=(\d+)/g)
?.map((match: any) => match.split('=')[1]);
const url = `${chronocatURL}/api/message/send`;
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${chronocatToekn}`,
};
for (const [chat_type, ids] of [
[1, user_ids],
[2, group_ids],
]) {
if (!ids) {
continue;
}
let _ids: any = ids;
for (const chat_id of _ids) {
const data = {
peer: {
chatType: chat_type,
peerUin: chat_id,
},
elements: [
{
elementType: 1,
textElement: {
content: `${this.title}\n\n${this.content}`,
},
},
],
};
const res: any = await got.post(url, {
...this.gotOption,
json: data,
headers,
});
if (res.body === 'success') {
return true;
} else {
throw new Error(res.body);
}
}
}
return false;
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async webhook() {
const {
webhookUrl,
+29 -14
View File
@@ -42,15 +42,22 @@ export default class ScheduleService {
private maxBuffer = 200 * 1024 * 1024;
constructor(@Inject('logger') private logger: winston.Logger) {}
constructor(@Inject('logger') private logger: winston.Logger) { }
async runTask(
command: string,
callbacks: TaskCallbacks = {},
params: {
schedule?: string;
name?: string;
command?: string;
},
completionTime: 'start' | 'end' = 'end',
) {
return taskLimit.runWithCpuLimit(() => {
return taskLimit.runWithCronLimit(() => {
return new Promise(async (resolve, reject) => {
this.logger.info(`[panel][开始执行任务] 参数 ${JSON.stringify({ ...params, command })}`);
try {
const startTime = dayjs();
await callbacks.onBefore?.(startTime);
@@ -82,20 +89,14 @@ export default class ScheduleService {
await callbacks.onError?.(JSON.stringify(err));
});
cp.on('exit', async (code, signal) => {
this.logger.info(
`[panel][任务退出] ${command} 进程id: ${cp.pid}, 退出码 ${code}`,
);
});
cp.on('close', async (code) => {
cp.on('exit', async (code) => {
const endTime = dayjs();
await callbacks.onEnd?.(
cp,
endTime,
endTime.diff(startTime, 'seconds'),
);
resolve(null);
resolve({ ...params, pid: cp.pid, code });
});
} catch (error) {
this.logger.error(
@@ -126,12 +127,20 @@ export default class ScheduleService {
this.scheduleStacks.set(
_id,
nodeSchedule.scheduleJob(_id, schedule, async () => {
this.runTask(command, callbacks);
this.runTask(command, callbacks, {
name,
schedule,
command,
});
}),
);
if (runImmediately) {
this.runTask(command, callbacks);
this.runTask(command, callbacks, {
name,
schedule,
command,
});
}
}
@@ -160,7 +169,10 @@ export default class ScheduleService {
const task = new Task(
name,
() => {
this.runTask(command, callbacks);
this.runTask(command, callbacks, {
name,
command,
});
},
(err) => {
this.logger.error(
@@ -180,7 +192,10 @@ export default class ScheduleService {
this.intervalSchedule.addIntervalJob(job);
if (runImmediately) {
this.runTask(command, callbacks);
this.runTask(command, callbacks, {
name,
command,
});
}
}
+4 -3
View File
@@ -16,14 +16,14 @@ export default class ScriptService {
private sockService: SockService,
private cronService: CronService,
private scheduleService: ScheduleService,
) {}
) { }
private taskCallbacks(filePath: string): TaskCallbacks {
return {
onEnd: async (cp, endTime, diff) => {
try {
fs.unlinkSync(filePath);
} catch (error) {}
} catch (error) { }
},
onError: async (message: string) => {
this.sockService.sendMessage({
@@ -46,6 +46,7 @@ export default class ScriptService {
const pid = await this.scheduleService.runTask(
command,
this.taskCallbacks(filePath),
{ command },
'start',
);
@@ -59,7 +60,7 @@ export default class ScriptService {
}
try {
await killTask(pid);
} catch (error) {}
} catch (error) { }
return { code: 200 };
}
+5 -1
View File
@@ -320,7 +320,11 @@ export default class SubscriptionService {
const command = formatCommand(subscription);
this.scheduleService.runTask(command, this.taskCallbacks(subscription));
this.scheduleService.runTask(command, this.taskCallbacks(subscription), {
name: subscription.name,
schedule: subscription.schedule,
command
});
}
public async disabled(ids: number[]) {
+5 -2
View File
@@ -39,7 +39,7 @@ export default class SystemService {
@Inject('logger') private logger: winston.Logger,
private scheduleService: ScheduleService,
private sockService: SockService,
) {}
) { }
public async getSystemConfig() {
const doc = await this.getDb({ type: AuthDataType.systemConfig });
@@ -114,7 +114,7 @@ export default class SystemService {
},
);
lastVersionContent = await parseContentVersion(result.body);
} catch (error) {}
} catch (error) { }
if (!lastVersionContent) {
lastVersionContent = currentVersionContent;
@@ -232,6 +232,9 @@ export default class SystemService {
this.scheduleService.runTask(
`real_log_path=${logPath} real_time=true ${command}`,
callback,
{
command,
}
);
}
+50 -20
View File
@@ -1,29 +1,62 @@
import pLimit from 'p-limit';
import PQueue, { QueueAddOptions } from 'p-queue-cjs';
import os from 'os';
import { AuthDataType, AuthModel } from '../data/auth';
import Logger from '../loaders/logger';
import dayjs from 'dayjs';
class TaskLimit {
private oneLimit = pLimit(1);
private updateLogLimit = pLimit(1);
private cpuLimit = pLimit(Math.max(os.cpus().length, 4));
private oneLimit = new PQueue({ concurrency: 1 });
private updateLogLimit = new PQueue({ concurrency: 1 });
private cronLimit = new PQueue({ concurrency: Math.max(os.cpus().length, 4) });
get cpuLimitActiveCount() {
return this.cpuLimit.activeCount;
get cronLimitActiveCount() {
return this.cronLimit.pending;
}
get cpuLimitPendingCount() {
return this.cpuLimit.pendingCount;
get cronLimitPendingCount() {
return this.cronLimit.size;
}
constructor() {
this.setCustomLimit();
this.handleEvents();
}
private handleEvents() {
this.cronLimit.on('add', () => {
Logger.info(
`[schedule][任务加入队列] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
})
this.cronLimit.on('active', () => {
Logger.info(
`[schedule][开始处理任务] 运行中任务数: ${this.cronLimitActiveCount + 1}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
})
this.cronLimit.on('completed', (param) => {
Logger.info(
`[schedule][任务处理成功] 参数 ${JSON.stringify(param)}`,
);
});
this.cronLimit.on('error', error => {
Logger.error(
`[schedule][任务处理错误] 参数 ${JSON.stringify(error)}`,
);
});
this.cronLimit.on('next', () => {
Logger.info(
`[schedule][任务处理结束] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
});
this.cronLimit.on('idle', () => {
Logger.info(
`[schedule][任务队列] 空闲中...`,
);
});
}
public async setCustomLimit(limit?: number) {
if (limit) {
this.cpuLimit = pLimit(limit);
this.cronLimit.concurrency = limit;
return;
}
await AuthModel.sync();
@@ -31,23 +64,20 @@ class TaskLimit {
where: { type: AuthDataType.systemConfig },
});
if (doc?.info?.cronConcurrency) {
this.cpuLimit = pLimit(doc?.info?.cronConcurrency);
this.cronLimit.concurrency = doc.info.cronConcurrency;
}
}
public runWithCpuLimit<T>(fn: () => Promise<T>): Promise<T> {
Logger.info(
`[schedule][任务加入队列] 运行中任务数: ${this.cpuLimitActiveCount}, 等待中任务数: ${this.cpuLimitPendingCount}`,
);
return this.cpuLimit(fn);
public async runWithCronLimit<T>(fn: () => Promise<T>, options?: Partial<QueueAddOptions>): Promise<T | void> {
return this.cronLimit.add(fn, options);
}
public runOneByOne<T>(fn: () => Promise<T>): Promise<T> {
return this.oneLimit(fn);
public runOneByOne<T>(fn: () => Promise<T>, options?: Partial<QueueAddOptions>): Promise<T | void> {
return this.oneLimit.add(fn, options);
}
public updateDepLog<T>(fn: () => Promise<T>): Promise<T> {
return this.updateLogLimit(fn);
public updateDepLog<T>(fn: () => Promise<T>, options?: Partial<QueueAddOptions>): Promise<T | void> {
return this.updateLogLimit.add(fn, options);
}
}
+5 -7
View File
@@ -2,11 +2,10 @@ import { spawn } from 'cross-spawn';
import taskLimit from './pLimit';
import Logger from '../loaders/logger';
export function runCron(cmd: string): Promise<number> {
return taskLimit.runWithCpuLimit(() => {
export function runCron(cmd: string, options?: { schedule: string; extraSchedules: Array<{ schedule: string }>; name: string }): Promise<number | void> {
return taskLimit.runWithCronLimit(() => {
return new Promise(async (resolve: any) => {
Logger.info(`[schedule][开始执行任务] 运行命令: ${cmd}`);
Logger.info(`[schedule][开始执行任务] 参数 ${JSON.stringify({ ...options, command: cmd })}`);
const cp = spawn(cmd, { shell: '/bin/bash' });
cp.stderr.on('data', (data) => {
@@ -24,9 +23,8 @@ export function runCron(cmd: string): Promise<number> {
);
});
cp.on('close', async (code) => {
Logger.info(`[schedule][任务退出] ${cmd} 进程id: ${cp.pid} 退出, 退出码 ${code}`);
resolve();
cp.on('exit', async (code) => {
resolve({ ...options, command: cmd, pid: cp.pid, code });
});
});
});
+1 -1
View File
@@ -83,7 +83,7 @@
"nedb": "^1.8.0",
"node-schedule": "^2.1.0",
"nodemailer": "^6.7.2",
"p-limit": "3.1.0",
"p-queue-cjs": "7.3.4",
"protobufjs": "^7.2.3",
"pstree.remy": "^1.1.8",
"reflect-metadata": "^0.1.13",
+228 -159
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -173,4 +173,13 @@ export SMTP_NAME=""
## PUSHME_KEY (必填)填写PushMe APP上获取的push_key
export PUSHME_KEY=""
## 13. CHRONOCAT
## CHRONOCAT_URL 推送 http://127.0.0.1:16530
## CHRONOCAT_TOKEN 填写在CHRONOCAT文件生成的访问密钥
## CHRONOCAT_QQ 个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx
## CHRONOCAT相关API https://chronocat.vercel.app/install/docker/official/
export CHRONOCAT_URL=""
export CHRONOCAT_QQ="" #
export CHRONOCAT_TOKEN=""
## 其他需要的变量,脚本中需要的变量使用 export 变量名= 声明即可
+260
View File
@@ -11,6 +11,7 @@
*/
const querystring = require('querystring');
const got = require('got');
const $ = new Env();
const timeout = 15000; //超时时间(单位毫秒)
// =======================================gotify通知设置区域==============================================
@@ -150,6 +151,23 @@ let SMTP_NAME = '';
//此处填你的PushMe KEY.
let PUSHME_KEY = '';
// =======================================CHRONOCAT通知设置区域===========================================
// CHRONOCAT_URL Red协议连接地址 例: http://127.0.0.1:16530
// CHRONOCAT_TOKEN 填写在CHRONOCAT文件生成的访问密钥
// CHRONOCAT_QQ 个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群
// CHRONOCAT相关API https://chronocat.vercel.app/install/docker/official/
let CHRONOCAT_URL = ''; // CHRONOCAT Red协议连接地址
let CHRONOCAT_TOKEN = ''; //CHRONOCAT 生成的访问密钥
let CHRONOCAT_QQ = ''; // 个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx
// =======================================自定义通知设置区域=======================================
// 自定义通知 接收回调的URL
let WEBHOOK_URL = '';
let WEBHOOK_BODY = '';
let WEBHOOK_HEADERS = '';
let WEBHOOK_METHOD = '';
let WEBHOOK_CONTENT_TYPE = '';
//==========================云端环境变量的判断与接收=========================
if (process.env.GOTIFY_URL) {
GOTIFY_URL = process.env.GOTIFY_URL;
@@ -306,6 +324,32 @@ if (process.env.SMTP_NAME) {
if (process.env.PUSHME_KEY) {
PUSHME_KEY = process.env.PUSHME_KEY;
}
if (process.env.CHRONOCAT_URL) {
CHRONOCAT_URL = process.env.CHRONOCAT_URL;
}
if (process.env.CHRONOCAT_QQ) {
CHRONOCAT_QQ = process.env.CHRONOCAT_QQ;
}
if (process.env.CHRONOCAT_TOKEN) {
CHRONOCAT_TOKEN = process.env.CHRONOCAT_TOKEN;
}
if (process.env.WEBHOOK_URL) {
WEBHOOK_URL = process.env.WEBHOOK_URL;
}
if (process.env.WEBHOOK_BODY) {
WEBHOOK_BODY = process.env.WEBHOOK_BODY;
}
if (process.env.WEBHOOK_HEADERS) {
WEBHOOK_HEADERS = process.env.WEBHOOK_HEADERS;
}
if (process.env.WEBHOOK_METHOD) {
WEBHOOK_METHOD = process.env.WEBHOOK_METHOD;
}
if (process.env.WEBHOOK_CONTENT_TYPE) {
WEBHOOK_CONTENT_TYPE = process.env.WEBHOOK_CONTENT_TYPE;
}
//==========================云端环境变量的判断与接收=========================
/**
@@ -355,6 +399,8 @@ async function sendNotify(
fsBotNotify(text, desp), //飞书机器人
smtpNotify(text, desp), //SMTP 邮件
PushMeNotify(text, desp, params), //PushMe
ChronocatNotify(text, desp), // Chronocat
webhookNotify(text, desp), //自定义通知
]);
}
@@ -1171,6 +1217,220 @@ function PushMeNotify(text, desp, params = {}) {
});
}
function ChronocatNotify(title, desp) {
return new Promise((resolve) => {
if (!CHRONOCAT_TOKEN || !CHRONOCAT_QQ || !CHRONOCAT_URL) {
console.log(
'CHRONOCAT 服务的 CHRONOCAT_URL 或 CHRONOCAT_QQ 未设置!!\n取消推送',
);
return;
}
console.log('CHRONOCAT 服务启动');
const user_ids = CHRONOCAT_QQ.match(/user_id=(\d+)/g)?.map(
(match) => match.split('=')[1],
);
const group_ids = CHRONOCAT_QQ.match(/group_id=(\d+)/g)?.map(
(match) => match.split('=')[1],
);
const url = `${CHRONOCAT_URL}/api/message/send`;
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${CHRONOCAT_TOKEN}`,
};
for (const [chat_type, ids] of [
[1, user_ids],
[2, group_ids],
]) {
if (!ids) {
continue;
}
for (const chat_id of ids) {
const data = {
peer: {
chatType: chat_type,
peerUin: chat_id,
},
elements: [
{
elementType: 1,
textElement: {
content: `${title}\n\n${desp}`,
},
},
],
};
const options = {
url: url,
json: data,
headers,
timeout,
};
$.post(options, (err, resp, data) => {
try {
if (err) {
console.log('Chronocat发送QQ通知消息失败!!\n');
console.log(err);
} else {
data = JSON.parse(data);
if (chat_type === 1) {
console.log(`QQ个人消息:${ids}推送成功!`);
} else {
console.log(`QQ群消息:${ids}推送成功!`);
}
}
} catch (e) {
$.logErr(e, resp);
} finally {
resolve(data);
}
});
}
}
});
}
function webhookNotify(text, desp) {
return new Promise((resolve) => {
const { formatBody, formatUrl } = formatNotifyContentFun(
WEBHOOK_URL,
WEBHOOK_BODY,
text,
desp,
);
if (!formatUrl && !formatBody) {
resolve();
return;
}
const headers = parseHeaders(WEBHOOK_HEADERS);
const body = parseBody(formatBody, WEBHOOK_CONTENT_TYPE);
const bodyParam = formatBodyFun(WEBHOOK_CONTENT_TYPE, body);
const options = {
method: WEBHOOK_METHOD,
headers,
allowGetBody: true,
...bodyParam,
timeout,
retry: 1,
};
if (WEBHOOK_METHOD) {
got(formatUrl, options).then((resp) => {
try {
if (resp.statusCode !== 200) {
console.log('自定义发送通知消息失败!!\n');
console.log(resp.body);
} else {
console.log('自定义发送通知消息成功🎉。\n');
console.log(resp.body);
}
} catch (e) {
$.logErr(e, resp);
} finally {
resolve(resp.body);
}
});
} else {
resolve();
}
});
}
function parseHeaders(headers) {
if (!headers) return {};
const parsed = {};
let key;
let val;
let i;
headers &&
headers.split('\n').forEach(function parser(line) {
i = line.indexOf(':');
key = line.substring(0, i).trim().toLowerCase();
val = line.substring(i + 1).trim();
if (!key) {
return;
}
parsed[key] = parsed[key] ? parsed[key] + ', ' + val : val;
});
return parsed;
}
function parseBody(body, contentType) {
if (!body) return '';
const parsed = {};
let key;
let val;
let i;
body &&
body.split('\n').forEach(function parser(line) {
i = line.indexOf(':');
key = line.substring(0, i).trim().toLowerCase();
val = line.substring(i + 1).trim();
if (!key || parsed[key]) {
return;
}
try {
const jsonValue = JSON.parse(val);
parsed[key] = jsonValue;
} catch (error) {
parsed[key] = val;
}
});
switch (contentType) {
case 'multipart/form-data':
return Object.keys(parsed).reduce((p, c) => {
p.append(c, parsed[c]);
return p;
}, new FormData());
case 'application/x-www-form-urlencoded':
return Object.keys(parsed).reduce((p, c) => {
return p ? `${p}&${c}=${parsed[c]}` : `${c}=${parsed[c]}`;
});
}
return parsed;
}
function formatBodyFun(contentType, body) {
if (!body) return {};
switch (contentType) {
case 'application/json':
return { json: body };
case 'multipart/form-data':
return { form: body };
case 'application/x-www-form-urlencoded':
return { body };
}
return {};
}
function formatNotifyContentFun(url, body, title, content) {
if (!url.includes('$title') && !body.includes('$title')) {
return {};
}
return {
formatUrl: url
.replaceAll('$title', encodeURIComponent(title))
.replaceAll('$content', encodeURIComponent(content)),
formatBody: body
.replaceAll('$title', title)
.replaceAll('$content', content),
};
}
module.exports = {
sendNotify,
BARK_PUSH,
+177 -3
View File
@@ -101,7 +101,17 @@ push_config = {
'SMTP_PASSWORD': '', # SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定
'SMTP_NAME': '', # SMTP 收发件人姓名,可随意填写
'PUSHME_KEY': '', # PushMe 酱的 PUSHME_KEY
'PUSHME_KEY': '', # PushMe 酱的 PUSHME_KEY
'CHRONOCAT_QQ': '', # qq号
'CHRONOCAT_TOKEN': '', # CHRONOCAT 的token
'CHRONOCAT_URL': '', # CHRONOCAT的url地址
'WEBHOOK_URL': '', # 自定义通知 请求地址
'WEBHOOK_BODY': '', # 自定义通知 请求体
'WEBHOOK_HEADERS': '', # 自定义通知 请求头
'WEBHOOK_METHOD': '', # 自定义通知 请求方法
'WEBHOOK_CONTENT_TYPE': '' # 自定义通知 content-type
}
notify_function = []
# fmt: on
@@ -446,7 +456,9 @@ class WeCom:
return data["access_token"]
def send_text(self, message, touser="@all"):
send_url = f"{self.ORIGIN}/cgi-bin/message/send?access_token={self.get_access_token()}"
send_url = (
f"{self.ORIGIN}/cgi-bin/message/send?access_token={self.get_access_token()}"
)
send_values = {
"touser": touser,
"msgtype": "text",
@@ -460,7 +472,9 @@ class WeCom:
return respone["errmsg"]
def send_mpnews(self, title, message, media_id, touser="@all"):
send_url = f"{self.ORIGIN}/cgi-bin/message/send?access_token={self.get_access_token()}"
send_url = (
f"{self.ORIGIN}/cgi-bin/message/send?access_token={self.get_access_token()}"
)
send_values = {
"touser": touser,
"msgtype": "mpnews",
@@ -643,6 +657,7 @@ def smtp(title: str, content: str) -> None:
except Exception as e:
print(f"SMTP 邮件 推送失败!{e}")
def pushme(title: str, content: str) -> None:
"""
使用 PushMe 推送消息。
@@ -665,6 +680,157 @@ def pushme(title: str, content: str) -> None:
print(f"PushMe 推送失败!{response.status_code} {response.text}")
def chronocat(title: str, content: str) -> None:
"""
使用 CHRONOCAT 推送消息。
"""
if (
not push_config.get("CHRONOCAT_URL")
or not push_config.get("CHRONOCAT_QQ")
or not push_config.get("CHRONOCAT_TOKEN")
):
print("CHRONOCAT 服务的 CHRONOCAT_URL 或 CHRONOCAT_QQ 未设置!!\n取消推送")
return
print("CHRONOCAT 服务启动")
user_ids = re.findall(r"user_id=(\d+)", push_config.get("CHRONOCAT_QQ"))
group_ids = re.findall(r"group_id=(\d+)", push_config.get("CHRONOCAT_QQ"))
url = f'{push_config.get("CHRONOCAT_URL")}/api/message/send'
headers = {
"Content-Type": "application/json",
"Authorization": f'Bearer {push_config.get("CHRONOCAT_TOKEN")}',
}
for chat_type, ids in [(1, user_ids), (2, group_ids)]:
if not ids:
continue
for chat_id in ids:
data = {
"peer": {"chatType": chat_type, "peerUin": chat_id},
"elements": [
{
"elementType": 1,
"textElement": {"content": f"{title}\n\n{content}"},
}
],
}
response = requests.post(url, headers=headers, data=json.dumps(data))
if response.status_code == 200:
if chat_type == 1:
print(f"QQ个人消息:{ids}推送成功!")
else:
print(f"QQ群消息:{ids}推送成功!")
else:
if chat_type == 1:
print(f"QQ个人消息:{ids}推送失败!")
else:
print(f"QQ群消息:{ids}推送失败!")
def parse_headers(headers):
if not headers:
return {}
parsed = {}
lines = headers.split("\n")
for line in lines:
i = line.find(":")
if i == -1:
continue
key = line[:i].strip().lower()
val = line[i + 1 :].strip()
parsed[key] = parsed.get(key, "") + ", " + val if key in parsed else val
return parsed
def parse_body(body, content_type):
if not body:
return ""
parsed = {}
lines = body.split("\n")
for line in lines:
i = line.find(":")
if i == -1:
continue
key = line[:i].strip().lower()
val = line[i + 1 :].strip()
if not key or key in parsed:
continue
try:
json_value = json.loads(val)
parsed[key] = json_value
except:
parsed[key] = val
if content_type == "application/x-www-form-urlencoded":
data = urlencode(parsed, doseq=True)
return data
if content_type == "application/json":
data = json.dumps(parsed)
return data
return parsed
def format_notify_content(url, body, title, content):
if "$title" not in url and "$title" not in body:
return {}
formatted_url = url.replace("$title", urllib.parse.quote_plus(title)).replace(
"$content", urllib.parse.quote_plus(content)
)
formatted_body = body.replace("$title", title).replace("$content", content)
return formatted_url, formatted_body
def custom_notify(title: str, content: str) -> None:
"""
通过 自定义通知 推送消息。
"""
if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"):
print("自定义通知的 WEBHOOK_URL 或 WEBHOOK_METHOD 未设置!!\n取消推送")
return
print("自定义通知服务启动")
WEBHOOK_URL = push_config.get("WEBHOOK_URL")
WEBHOOK_METHOD = push_config.get("WEBHOOK_METHOD")
WEBHOOK_CONTENT_TYPE = push_config.get("WEBHOOK_CONTENT_TYPE")
WEBHOOK_BODY = push_config.get("WEBHOOK_BODY")
WEBHOOK_HEADERS = push_config.get("WEBHOOK_HEADERS")
formatUrl, formatBody = format_notify_content(
WEBHOOK_URL, WEBHOOK_BODY, title, content
)
if not formatUrl and not formatBody:
print("请求头或者请求体中必须包含 $title 和 $content")
return
headers = parse_headers(WEBHOOK_HEADERS)
body = parse_body(formatBody, WEBHOOK_CONTENT_TYPE)
response = requests.request(
method=WEBHOOK_METHOD, url=formatUrl, headers=headers, timeout=15, data=body
)
if response.status_code == 200:
print("自定义通知推送成功!")
else:
print(f"自定义通知推送失败!{response.status_code} {response.text}")
def one() -> str:
"""
获取一条一言。
@@ -721,6 +887,14 @@ if (
notify_function.append(smtp)
if push_config.get("PUSHME_KEY"):
notify_function.append(pushme)
if (
push_config.get("CHRONOCAT_URL")
and push_config.get("CHRONOCAT_QQ")
and push_config.get("CHRONOCAT_TOKEN")
):
notify_function.append(chronocat)
if push_config.get("WEBHOOK_URL") and push_config.get("WEBHOOK_METHOD"):
notify_function.append(custom_notify)
def send(title: str, content: str) -> None:
+8 -6
View File
@@ -105,7 +105,7 @@ run_normal() {
file_param=${file_param/$relative_path\//}
fi
$timeoutCmd $which_program $file_param
$timeoutCmd $which_program $file_param "${script_params[@]}"
}
## 并发执行时,设定的 RandomDelay 不会生效,即所有任务立即执行
@@ -147,7 +147,7 @@ run_concurrent() {
for i in "${!array[@]}"; do
export "${env_param}=${array[i]}"
single_log_path="$dir_log/$log_dir/${single_log_time}_$((i + 1)).log"
eval $timeoutCmd $which_program $file_param &>$single_log_path &
eval $timeoutCmd $which_program $file_param "${script_params[@]}" &>$single_log_path &
done
wait
@@ -190,7 +190,7 @@ run_designated() {
cd ${relative_path}
file_param=${file_param/$relative_path\//}
fi
$timeoutCmd $which_program $file_param
$timeoutCmd $which_program $file_param "${script_params[@]}"
}
## 运行其他命令
@@ -241,6 +241,8 @@ main() {
fi
}
handle_task_before "$@"
main "$@"
handle_task_after "$@"
handle_task_start "${task_shell_params[@]}"
run_task_before "${task_shell_params[@]}"
main "${task_shell_params[@]}"
run_task_after "${task_shell_params[@]}"
handle_task_end "${task_shell_params[@]}"
+18 -11
View File
@@ -100,9 +100,6 @@ set_proxy() {
unset_proxy() {
unset http_proxy
unset https_proxy
unset ftp_proxy
unset all_proxy
unset no_proxy
}
make_dir() {
@@ -311,10 +308,8 @@ random_range() {
reload_pm2() {
cd $dir_root
# 代理会影响 grpc 服务
unset_proxy
pm2 flush &>/dev/null
pm2 startOrGracefulReload $file_ecosystem_js --update-env
env ALL_PROXY= HTTP_PROXY= HTTPS_PROXY= all_proxy= http_proxy= https_proxy= pm2 startOrGracefulReload $file_ecosystem_js --update-env
}
diff_time() {
@@ -407,8 +402,18 @@ init_nginx() {
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
@@ -428,11 +433,12 @@ init_nginx() {
sed -i "s,IPV4_CONFIG,${ipv4Str},g" /etc/nginx/conf.d/front.conf
}
handle_task_before() {
handle_task_start() {
[[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp"
echo -e "## 开始执行... $begin_time\n"
}
run_task_before() {
[[ $is_macos -eq 0 ]] && check_server
. $file_task_before "$@"
@@ -444,7 +450,7 @@ handle_task_before() {
fi
}
handle_task_after() {
run_task_after() {
. $file_task_after "$@"
if [[ $task_after ]]; then
@@ -452,7 +458,9 @@ handle_task_after() {
eval "$task_after"
echo -e "\n执行后置命令结束"
fi
}
handle_task_end() {
local etime=$(date "+$time_format")
local end_time=$(format_time "$time_format" "$etime")
local end_timestamp=$(format_timestamp "$time_format" "$etime")
@@ -460,8 +468,7 @@ handle_task_after() {
[[ "$diff_time" == 0 ]] && diff_time=1
echo -e "\n\n## 执行结束... $end_time 耗时 $diff_time 秒     "
echo -e "\n## 执行结束... $end_time 耗时 $diff_time 秒     "
[[ $ID ]] && update_cron "\"$ID\"" "1" "" "$log_path" "$begin_timestamp" "$diff_time"
}
+22 -9
View File
@@ -5,9 +5,9 @@ dir_shell=$QL_DIR/shell
. $dir_shell/share.sh
. $dir_shell/api.sh
trap "single_hanle" 2 3 20 15 14
trap "single_hanle" 2 3 20 15 14 19 1
single_hanle() {
eval handle_task_after "$@" "$cmd"
eval handle_task_end "$@" "$cmd"
exit 1
}
@@ -70,10 +70,10 @@ handle_log_path() {
log_path="$real_log_path"
fi
cmd=">> $dir_log/$log_path 2>&1"
cmd="2>&1 | tee -a $dir_log/$log_path"
make_dir "$dir_log/$log_dir"
if [[ "$show_log" == "true" ]]; then
cmd="2>&1 | tee -a $dir_log/$log_path"
if [[ "$no_tee" == "true" ]]; then
cmd=">> $dir_log/$log_path 2>&1"
fi
if [[ "$real_time" == "true" ]]; then
@@ -95,6 +95,21 @@ format_params() {
fi
fi
# params=$(echo "$@" | sed -E 's/([^ ])&([^ ])/\1\\\&\2/g')
# 分割 task 内置参数和脚本参数
task_shell_params=()
script_params=()
found_double_dash=false
for arg in "$@"; do
if $found_double_dash; then
script_params+=("$arg")
elif [ "$arg" == "--" ]; then
found_double_dash=true
else
task_shell_params+=("$arg")
fi
done
}
init_begin_time() {
@@ -119,11 +134,9 @@ if [[ $max_time ]]; then
fi
format_params "$@"
define_program "$@"
handle_log_path "$@"
define_program "${task_shell_params[@]}"
handle_log_path "${task_shell_params[@]}"
init_begin_time
eval . $dir_shell/otask.sh "$cmd"
[[ -f "$dir_log/$log_path" ]] && [[ ! $show_log ]] && [[ "$real_time" != "true" ]] && cat "$dir_log/$log_path"
exit 0
+7 -2
View File
@@ -24,9 +24,13 @@ body {
-webkit-overflow-scrolling: touch;
}
.ant-modal-header {
padding-right: 54px;
}
.ant-modal-body {
max-height: calc(90vh - 110px);
max-height: calc(90vh - var(--vh-offset, 110px));
max-height: calc(80vh - 110px);
max-height: calc(80vh - var(--vh-offset, 110px));
overflow-y: auto;
}
@@ -181,6 +185,7 @@ body {
.react-codemirror2,
.CodeMirror {
height: 100%;
overflow: auto;
}
}
}
+3 -3
View File
@@ -178,7 +178,7 @@ export default function () {
useEffect(() => {
if (!user || !user.username) return;
const ws = WebSocketManager.getInstance(
`${window.location.origin}/api/ws?token=${localStorage.getItem(
`${window.location.origin}${config.apiPrefix}ws?token=${localStorage.getItem(
config.authKey,
)}`,
);
@@ -320,7 +320,7 @@ export default function () {
shape="square"
size="small"
icon={<UserOutlined />}
src={user.avatar ? `/api/static/${user.avatar}` : ''}
src={user.avatar ? `${config.apiPrefix}static/${user.avatar}` : ''}
/>
<span style={{ marginLeft: 5 }}>{user.username}</span>
</span>
@@ -342,7 +342,7 @@ export default function () {
shape="square"
size="small"
icon={<UserOutlined />}
src={user.avatar ? `/api/static/${user.avatar}` : ''}
src={user.avatar ? `${config.apiPrefix}static/${user.avatar}` : ''}
/>
<span style={{ marginLeft: 5 }}>{user.username}</span>
</span>
+8 -2
View File
@@ -459,6 +459,12 @@
"新增定时规则": "Add Timing Rules",
"运行任务前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "Run commands before executing the task, e.g., cp/mv/python3 xxx.py/node xxx.js",
"运行任务后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "Run commands after executing the task, e.g., cp/mv/python3 xxx.py/node xxx.js",
"请输入运行任务前要执行的命令": "Please enter the command to run before executing the task",
"请输入运行任务后要执行的命令": "Please enter the command to run after executing the task"
"请输入运行任务前要执行的命令,不能包含 task 命令": "Please enter the command to run before executing the task, cannot contain task commands",
"请输入运行任务后要执行的命令,不能包含 task 命令": "Please enter the command to run after executing the task, cannot contain task commands",
"不能包含 task 命令": "Cannot contain task commands",
"Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/": "Connection address of the Chronocat Red service https://chronocat.vercel.app/install/docker/official/",
"个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx": "Individuals: user_id=individual QQ Groups fill in group_id=QQ Groups more than one with English; separated by the same time to support individuals and groups such as: user_id=xxx;group_id=xxxx;group_id=xxxxx",
"docker安装在持久化config目录下的chronocat.yml文件可找到": "The docker installation can be found in the persistence config directory in the chronocat.yml file",
"请选择": "Please select",
"请输入": "Please input"
}
+8 -2
View File
@@ -459,6 +459,12 @@
"新增定时规则": "新增定时规则",
"运行任务前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "运行任务前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js",
"运行任务后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "运行任务后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js",
"请输入运行任务前要执行的命令": "请输入运行任务前要执行的命令",
"请输入运行任务后要执行的命令": "请输入运行任务后要执行的命令"
"请输入运行任务前要执行的命令,不能包含 task 命令": "请输入运行任务前要执行的命令,不能包含 task 命令",
"请输入运行任务后要执行的命令,不能包含 task 命令": "请输入运行任务后要执行的命令,不能包含 task 命令",
"不能包含 task 命令": "不能包含 task 命令",
"Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/": "Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/",
"个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx": "个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx",
"docker安装在持久化config目录下的chronocat.yml文件可找到": "docker安装在持久化config目录下的chronocat.yml文件可找到",
"请选择": "请选择",
"请输入": "请输入"
}
+16 -17
View File
@@ -33,6 +33,7 @@ import IconFont from '@/components/iconfont';
import { getCommandScript, getEditorMode } from '@/utils';
import VirtualList from 'rc-virtual-list';
import useScrollHeight from '@/hooks/useScrollHeight';
import dayjs from 'dayjs';
const { Text } = Typography;
@@ -52,8 +53,6 @@ interface LogItem {
filename: string;
}
const language = navigator.language || navigator.languages[0];
const CronDetailModal = ({
cron = {},
handleCancel,
@@ -121,10 +120,11 @@ const CronDetailModal = ({
};
const onClickItem = (item: LogItem) => {
localStorage.setItem('logCron', currentCron.id);
setLogUrl(
`${config.apiPrefix}logs/${item.filename}?path=${item.directory || ''}`,
);
const url = `${config.apiPrefix}logs/${item.filename}?path=${
item.directory || ''
}`;
localStorage.setItem('logCron', url);
setLogUrl(url);
request
.get(
`${config.apiPrefix}logs/${item.filename}?path=${item.directory || ''}`,
@@ -498,7 +498,12 @@ const CronDetailModal = ({
</div>
<div className="cron-detail-info-item">
<div className="cron-detail-info-title">{intl.get('定时')}</div>
<div className="cron-detail-info-value">{currentCron.schedule}</div>
<div className="cron-detail-info-value">
<div>{currentCron.schedule}</div>
{currentCron.extra_schedules?.map((x) => (
<div key={x.schedule}>{x.schedule}</div>
))}
</div>
</div>
<div className="cron-detail-info-item">
<div className="cron-detail-info-title">
@@ -506,11 +511,9 @@ const CronDetailModal = ({
</div>
<div className="cron-detail-info-value">
{currentCron.last_execution_time
? new Date(currentCron.last_execution_time * 1000)
.toLocaleString(language, {
hour12: false,
})
.replace(' 24:', ' 00:')
? dayjs(currentCron.last_execution_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)
: '-'}
</div>
</div>
@@ -530,11 +533,7 @@ const CronDetailModal = ({
</div>
<div className="cron-detail-info-value">
{currentCron.nextRunTime &&
currentCron.nextRunTime
.toLocaleString(language, {
hour12: false,
})
.replace(' 24:', ' 00:')}
dayjs(currentCron.nextRunTime).format('YYYY-MM-DD HH:mm:ss')}
</div>
</div>
</Card>
+2 -3
View File
@@ -35,7 +35,7 @@
overflow: auto;
.ant-card-body {
min-width: 600px;
min-width: 1000px;
}
.cron-detail-info-item {
@@ -58,7 +58,7 @@
.ant-card-body {
display: flex;
justify-content: space-between;
min-width: 600px;
min-width: 1000px;
}
}
@@ -78,7 +78,6 @@
display: flex;
align-items: center;
justify-content: space-between;
margin-right: 32px;
.operations {
display: flex;
+36 -27
View File
@@ -51,13 +51,14 @@ import ViewManageModal from './viewManageModal';
import { FilterValue, SorterResult } from 'antd/lib/table/interface';
import { SharedContext } from '@/layouts';
import useTableScrollHeight from '@/hooks/useTableScrollHeight';
import { getCommandScript, parseCrontab } from '@/utils';
import { getCommandScript, getCrontabsNextDate, parseCrontab } from '@/utils';
import { ColumnProps } from 'antd/lib/table';
import { useVT } from 'virtualizedtableforantd4';
import { ICrontab, OperationName, OperationPath, CrontabStatus } from './type';
import Name from '@/components/name';
import dayjs from 'dayjs';
const { Text, Paragraph } = Typography;
const { Text, Paragraph, Link } = Typography;
const { Search } = Input;
const Crontab = () => {
@@ -74,17 +75,18 @@ const Crontab = () => {
style={{
wordBreak: 'break-all',
marginBottom: 0,
color: '#1890ff'
}}
ellipsis={{ tooltip: text, rows: 2 }}
>
<a
<Link
onClick={() => {
setDetailCron(record);
setIsDetailModalVisible(true);
}}
>
{record.name || '-'}
</a>
</Link>
</Paragraph>
),
sorter: {
@@ -183,17 +185,29 @@ const Crontab = () => {
compare: (a, b) => a.schedule.localeCompare(b.schedule),
},
render: (text, record) => {
return record.extra_schedules?.length ? (
<Popover
placement="right"
content={record.extra_schedules?.map((x) => (
<div>{x.schedule}</div>
))}
return (
<Paragraph
style={{
wordBreak: 'break-all',
marginBottom: 0,
}}
ellipsis={{
tooltip: {
placement: 'right',
title: (
<>
<div>{text}</div>
{record.extra_schedules?.map((x) => (
<div key={x.schedule}>{x.schedule}</div>
))}
</>
),
},
rows: 2,
}}
>
{text}
</Popover>
) : (
text
</Paragraph>
);
},
},
@@ -224,7 +238,6 @@ const Crontab = () => {
},
},
render: (text, record) => {
const language = navigator.language || navigator.languages[0];
return (
<span
style={{
@@ -232,11 +245,9 @@ const Crontab = () => {
}}
>
{record.last_execution_time
? new Date(record.last_execution_time * 1000)
.toLocaleString(language, {
hour12: false,
})
.replace(' 24:', ' 00:')
? dayjs(record.last_execution_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)
: '-'}
</span>
);
@@ -251,12 +262,7 @@ const Crontab = () => {
},
},
render: (text, record) => {
const language = navigator.language || navigator.languages[0];
return record.nextRunTime
.toLocaleString(language, {
hour12: false,
})
.replace(' 24:', ' 00:');
return dayjs(record.nextRunTime).format('YYYY-MM-DD HH:mm:ss');
},
},
{
@@ -389,7 +395,7 @@ const Crontab = () => {
data.map((x) => {
return {
...x,
nextRunTime: parseCrontab(x.schedule),
nextRunTime: getCrontabsNextDate(x.schedule, x.extra_schedules),
};
}),
);
@@ -677,7 +683,10 @@ const Crontab = () => {
if (code === 200) {
const index = value.findIndex((x) => x.id === cron.id);
const result = [...value];
data.nextRunTime = parseCrontab(data.schedule);
data.nextRunTime = getCrontabsNextDate(
data.schedule,
data.extra_schedules,
);
if (index !== -1) {
result.splice(index, 1, {
...cron,
+25 -8
View File
@@ -1,6 +1,14 @@
import intl from 'react-intl-universal';
import React, { useEffect, useRef, useState } from 'react';
import { Modal, message, Input, Form, Statistic, Button } from 'antd';
import {
Modal,
message,
Input,
Form,
Statistic,
Button,
Typography,
} from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import {
@@ -10,6 +18,7 @@ import {
import { PageLoading } from '@ant-design/pro-layout';
import { logEnded } from '@/utils';
import { CrontabStatus } from './type';
import Ansi from 'ansi-to-react';
const { Countdown } = Statistic;
@@ -31,6 +40,7 @@ const CronLogModal = ({
const [executing, setExecuting] = useState<any>(true);
const [isPhone, setIsPhone] = useState(false);
const scrollInfoRef = useRef({ value: 0, down: true });
const uniqPath = logUrl ? logUrl : String(cron?.id);
const getCronLog = (isFirst?: boolean) => {
if (isFirst) {
@@ -41,7 +51,7 @@ const CronLogModal = ({
.then(({ code, data }) => {
if (
code === 200 &&
localStorage.getItem('logCron') === String(cron.id) &&
localStorage.getItem('logCron') === uniqPath &&
data !== value
) {
const log = data as string;
@@ -49,10 +59,15 @@ const CronLogModal = ({
const hasNext = Boolean(
log && !logEnded(log) && !log.includes('任务未运行'),
);
if (!hasNext && !logEnded(value) && value !== intl.get('启动中...')) {
setTimeout(() => {
autoScroll();
});
}
setExecuting(hasNext);
if (hasNext) {
autoScroll();
setTimeout(() => {
autoScroll();
getCronLog();
}, 2000);
}
@@ -87,18 +102,20 @@ const CronLogModal = ({
if (scrollInfoRef.current.down) {
scrollInfoRef.current = {
value: sTop,
down: sTop > scrollInfoRef.current.value || !sTop,
down: sTop - scrollInfoRef.current.value > -5 || !sTop,
};
}
};
const titleElement = () => {
return (
<>
<div style={{ display: 'flex', alignItems: 'center' }}>
{(executing || loading) && <Loading3QuartersOutlined spin />}
{!executing && !loading && <CheckCircleOutlined />}
<span style={{ marginLeft: 5 }}>{cron && cron.name}</span>
</>
<Typography.Text ellipsis={true} style={{ marginLeft: 5 }}>
{cron && cron.name}
</Typography.Text>
</div>
);
};
@@ -148,7 +165,7 @@ const CronLogModal = ({
: {}
}
>
{value}
<Ansi>{value}</Ansi>
</pre>
)}
<div id="log-flag"></div>
+37 -3
View File
@@ -74,7 +74,11 @@ const CronModal = ({
name="form_in_modal"
initialValues={cron}
>
<Form.Item name="name" label={intl.get('名称')}>
<Form.Item
name="name"
label={intl.get('名称')}
rules={[{ required: true, whitespace: true }]}
>
<Input placeholder={intl.get('请输入任务名称')} />
</Form.Item>
<Form.Item
@@ -149,11 +153,26 @@ const CronModal = ({
tooltip={intl.get(
'运行任务前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js',
)}
rules={[
{
validator(rule, value) {
if (
value &&
(value.includes(' task ') || value.startsWith('task '))
) {
return Promise.reject(intl.get('不能包含 task 命令'));
}
return Promise.resolve();
},
},
]}
>
<Input.TextArea
rows={4}
autoSize={{ minRows: 1, maxRows: 5 }}
placeholder={intl.get('请输入运行任务前要执行的命令')}
placeholder={intl.get(
'请输入运行任务前要执行的命令,不能包含 task 命令',
)}
/>
</Form.Item>
<Form.Item
@@ -162,11 +181,26 @@ const CronModal = ({
tooltip={intl.get(
'运行任务后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js',
)}
rules={[
{
validator(rule, value) {
if (
value &&
(value.includes(' task ') || value.startsWith('task '))
) {
return Promise.reject(intl.get('不能包含 task 命令'));
}
return Promise.resolve();
},
},
]}
>
<Input.TextArea
rows={4}
autoSize={{ minRows: 1, maxRows: 5 }}
placeholder={intl.get('请输入运行任务后要执行的命令')}
placeholder={intl.get(
'请输入运行任务后要执行的命令,不能包含 task 命令',
)}
/>
</Form.Item>
</Form>
+1 -1
View File
@@ -92,7 +92,7 @@ const Dependence = () => {
const columns: any = [
{
title: intl.get('序号'),
width: 80,
width: 90,
render: (text: string, record: any, index: number) => {
return <span style={{ cursor: 'text' }}>{index + 1} </span>;
},
+4 -7
View File
@@ -41,6 +41,7 @@ import { SharedContext } from '@/layouts';
import useTableScrollHeight from '@/hooks/useTableScrollHeight';
import Copy from '../../components/copy';
import { useVT } from 'virtualizedtableforantd4';
import dayjs from 'dayjs';
const { Paragraph } = Typography;
const { Search } = Input;
@@ -137,13 +138,9 @@ const Env = () => {
},
},
render: (text: string, record: any) => {
const language = navigator.language || navigator.languages[0];
const time = record.updatedAt || record.timestamp;
const date = new Date(time)
.toLocaleString(language, {
hour12: false,
})
.replace(' 24:', ' 00:');
const date = dayjs(record.updatedAt || record.timestamp).format(
'YYYY-MM-DD HH:mm:ss',
);
return (
<Tooltip
placement="topLeft"
+2 -1
View File
@@ -16,6 +16,7 @@ import { request } from '@/utils/http';
import { useTheme } from '@/utils/hooks';
import { MobileOutlined } from '@ant-design/icons';
import { SharedContext } from '@/layouts';
import dayjs from 'dayjs';
const FormItem = Form.Item;
const { Countdown } = Statistic;
@@ -86,7 +87,7 @@ const Login = () => {
<>
<div>
{intl.get('上次登录时间:')}
{lastlogon ? new Date(lastlogon).toLocaleString() : '-'}
{lastlogon ? dayjs(lastlogon).format('YYYY-MM-DD HH:mm:ss') : '-'}
</div>
<div>
{intl.get('上次登录地点:')}
+3 -2
View File
@@ -16,6 +16,7 @@ import SettingModal from './setting';
import { useTheme } from '@/utils/hooks';
import { getEditorMode, logEnded } from '@/utils';
import WebSocketManager from '@/utils/websocket';
import Ansi from 'ansi-to-react';
const { Option } = Select;
@@ -116,7 +117,7 @@ const EditModal = ({
}, 300);
}
setLog(p=>`${p}${_message}`);
setLog((p) => `${p}${_message}`);
}, []);
useEffect(() => {
@@ -246,7 +247,7 @@ const EditModal = ({
padding: '0 15px',
}}
>
{log}
<Ansi>{log}</Ansi>
</pre>
</SplitPane>
<SaveModal
+2 -2
View File
@@ -77,7 +77,7 @@ const CheckUpdate = ({ systemInfo }: any) => {
</div>
</>
),
content: <pre>{lastLog}</pre>,
content: <pre><Ansi>{lastLog}</Ansi></pre>,
okText: intl.get('下载更新'),
cancelText: intl.get('以后再说'),
onOk() {
@@ -102,7 +102,7 @@ const CheckUpdate = ({ systemInfo }: any) => {
okButtonProps: { disabled: true },
title: intl.get('下载更新中...'),
centered: true,
content: <pre>{value}</pre>,
content: <pre><Ansi>{value}</Ansi></pre>,
});
};
+3 -18
View File
@@ -32,8 +32,8 @@ import About from './about';
import { useOutletContext } from '@umijs/max';
import { SharedContext } from '@/layouts';
import './index.less';
import CodeMirror from '@uiw/react-codemirror';
import useResizeObserver from '@react-hook/resize-observer';
import SystemLog from './systemLog';
const { Text } = Typography;
const isDemoEnv = window.__ENV__DeployEnv === 'demo';
@@ -334,7 +334,7 @@ const Setting = () => {
dataSource={dataSource}
rowKey="id"
size="middle"
scroll={{ x: 768 }}
scroll={{ x: 1000 }}
loading={loading}
/>
),
@@ -347,22 +347,7 @@ const Setting = () => {
{
key: 'syslog',
label: intl.get('系统日志'),
children: (
<CodeMirror
maxHeight={`${height}px`}
value={systemLogData}
onCreateEditor={(view) => {
setTimeout(() => {
view.scrollDOM.scrollTo({
top: view.scrollDOM.scrollHeight,
behavior: 'smooth',
});
}, 300);
}}
readOnly={true}
theme={theme.includes('dark') ? 'dark' : 'light'}
/>
),
children: <SystemLog data={systemLogData} height={height} theme={theme}/>,
},
{
key: 'login',
+2 -1
View File
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react';
import { Typography, Table, Tag, Button, Spin, message } from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import dayjs from 'dayjs';
const { Text, Link } = Typography;
@@ -30,7 +31,7 @@ const columns = [
key: 'timestamp',
width: 120,
render: (text: string, record: any) => {
return new Date(record.timestamp).toLocaleString();
return dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss');
},
},
{
+2 -2
View File
@@ -76,7 +76,7 @@ const NotificationSetting = ({ data }: any) => {
>
{x.items ? (
<Select
placeholder={x.placeholder || `请选择${x.label}`}
placeholder={x.placeholder || `${intl.get('请选择')} ${x.label}`}
disabled={loading}
>
{x.items.map((y) => (
@@ -89,7 +89,7 @@ const NotificationSetting = ({ data }: any) => {
<Input.TextArea
disabled={loading}
autoSize={{ minRows: 1, maxRows: 5 }}
placeholder={x.placeholder || `请输入${x.label}`}
placeholder={x.placeholder || `${intl.get('请输入')} ${x.label}`}
/>
)}
</Form.Item>
+1 -1
View File
@@ -253,7 +253,7 @@ const Other = ({
method="put"
showUploadList={false}
maxCount={1}
action="/api/system/data/import"
action={`${config.apiPrefix}system/data/import`}
onChange={(e) => {
if (e.event?.percent) {
showUploadProgress(parseFloat(e.event?.percent.toFixed(1)));
+5 -3
View File
@@ -97,14 +97,16 @@ const SecuritySettings = ({ user, userChange }: any) => {
const onChange = (e) => {
if (e.file && e.file.response) {
setAvatar(`/api/static/${e.file.response.data}`);
setAvatar(
`${config.apiPrefix}static/${e.file.response.data}`,
);
userChange();
}
};
useEffect(() => {
setTwoFactorActivated(user && user.twoFactorActivated);
setAvatar(user.avatar && `/api/static/${user.avatar}`);
setAvatar(user.avatar && `${config.apiPrefix}static/${user.avatar}`);
}, [user]);
return twoFactoring ? (
@@ -250,7 +252,7 @@ const SecuritySettings = ({ user, userChange }: any) => {
method="put"
showUploadList={false}
maxCount={1}
action="/api/user/avatar"
action={`${config.apiPrefix}user/avatar`}
onChange={onChange}
name="avatar"
headers={{
+58
View File
@@ -0,0 +1,58 @@
import React, { useRef } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { Button } from 'antd';
import {
VerticalAlignBottomOutlined,
VerticalAlignTopOutlined,
} from '@ant-design/icons';
const SystemLog = ({ data, height, theme }: any) => {
const editorRef = useRef<any>(null);
const scrollTo = (position: 'start' | 'end') => {
editorRef.current.scrollDOM.scrollTo({
top: position === 'start' ? 0 : editorRef.current.scrollDOM.scrollHeight,
});
};
return (
<div style={{ position: 'relative' }}>
<CodeMirror
maxHeight={`${height}px`}
value={data}
onCreateEditor={(view) => {
editorRef.current = view;
}}
readOnly={true}
theme={theme.includes('dark') ? 'dark' : 'light'}
/>
<div
style={{
position: 'absolute',
bottom: 20,
right: 20,
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
>
<Button
size='small'
icon={<VerticalAlignTopOutlined />}
onClick={() => {
scrollTo('start');
}}
/>
<Button
size='small'
icon={<VerticalAlignBottomOutlined />}
onClick={() => {
scrollTo('end');
}}
/>
</div>
</div>
);
};
export default SystemLog;
+2 -1
View File
@@ -9,6 +9,7 @@ import {
} from '@ant-design/icons';
import { PageLoading } from '@ant-design/pro-layout';
import { logEnded } from '@/utils';
import Ansi from 'ansi-to-react';
const SubscriptionLogModal = ({
subscription,
@@ -122,7 +123,7 @@ const SubscriptionLogModal = ({
: {}
}
>
{value}
<Ansi>{value}</Ansi>
</pre>
)}
</div>
+62 -19
View File
@@ -98,6 +98,7 @@ export default {
{ value: 'email', label: intl.get('邮箱') },
{ value: 'lark', label: intl.get('飞书机器人') },
{ value: 'pushMe', label: 'PushMe' },
{ value: 'chronocat', label: 'Chronocat' },
{ value: 'webhook', label: intl.get('自定义通知') },
{ value: 'closed', label: intl.get('已关闭') },
],
@@ -126,14 +127,16 @@ export default {
goCqHttpBot: [
{
label: 'goCqHttpBotUrl',
tip: intl.get('推送到个人QQ: http://127.0.0.1/send_private_msg,群:http://127.0.0.1/send_group_msg',
tip: intl.get(
'推送到个人QQ: http://127.0.0.1/send_private_msg,群:http://127.0.0.1/send_group_msg',
),
required: true,
},
{ label: 'goCqHttpBotToken', tip: intl.get('访问密钥'), required: true },
{
label: 'goCqHttpBotQq',
tip: intl.get('如果GOBOT_URL设置 /send_private_msg 则需要填入 user_id=个人QQ 相反如果是 /send_group_msg 则需要填入 group_id=QQ群',
tip: intl.get(
'如果GOBOT_URL设置 /send_private_msg 则需要填入 user_id=个人QQ 相反如果是 /send_group_msg 则需要填入 group_id=QQ群',
),
required: true,
},
@@ -153,14 +156,16 @@ export default {
},
{
label: 'pushDeerUrl',
tip: intl.get('PushDeer的自架API endpoint,默认是 https://api2.pushdeer.com/message/push',
tip: intl.get(
'PushDeer的自架API endpoint,默认是 https://api2.pushdeer.com/message/push',
),
},
],
bark: [
{
label: 'barkPush',
tip: intl.get('Bark的信息IP/设备码,例如:https://api.day.app/XXXXXXXX',
tip: intl.get(
'Bark的信息IP/设备码,例如:https://api.day.app/XXXXXXXX',
),
required: true,
},
@@ -188,7 +193,8 @@ export default {
telegramBot: [
{
label: 'telegramBotToken',
tip: intl.get('telegram机器人的token,例如:1077xxx4424:AAFjv0FcqxxxxxxgEMGfi22B4yh15R5uw',
tip: intl.get(
'telegram机器人的token,例如:1077xxx4424:AAFjv0FcqxxxxxxgEMGfi22B4yh15R5uw',
),
required: true,
},
@@ -201,7 +207,8 @@ export default {
{ label: 'telegramBotProxyPort', tip: intl.get('代理端口') },
{
label: 'telegramBotProxyAuth',
tip: intl.get('telegram代理配置认证参数,用户名与密码用英文冒号连接 user:password',
tip: intl.get(
'telegram代理配置认证参数,用户名与密码用英文冒号连接 user:password',
),
},
{
@@ -212,20 +219,23 @@ export default {
dingtalkBot: [
{
label: 'dingtalkBotToken',
tip: intl.get('钉钉机器人webhook token,例如:5a544165465465645d0f31dca676e7bd07415asdasd',
tip: intl.get(
'钉钉机器人webhook token,例如:5a544165465465645d0f31dca676e7bd07415asdasd',
),
required: true,
},
{
label: 'dingtalkBotSecret',
tip: intl.get('密钥,机器人安全设置页面,加签一栏下面显示的SEC开头的字符串',
tip: intl.get(
'密钥,机器人安全设置页面,加签一栏下面显示的SEC开头的字符串',
),
},
],
weWorkBot: [
{
label: 'weWorkBotKey',
tip: intl.get('企业微信机器人的webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770),例如:693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa',
tip: intl.get(
'企业微信机器人的webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770),例如:693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa',
),
required: true,
},
@@ -237,7 +247,8 @@ export default {
weWorkApp: [
{
label: 'weWorkAppKey',
tip: intl.get('corpid、corpsecret、touser(注:多个成员ID使用|隔开)、agentid、消息类型(选填,不填默认文本消息类型) 注意用,号隔开(英文输入法的逗号),例如:wwcfrs,B-76WERQ,qinglong,1000001,2COat',
tip: intl.get(
'corpid、corpsecret、touser(注:多个成员ID使用|隔开)、agentid、消息类型(选填,不填默认文本消息类型) 注意用,号隔开(英文输入法的逗号),例如:wwcfrs,B-76WERQ,qinglong,1000001,2COat',
),
required: true,
},
@@ -249,7 +260,8 @@ export default {
aibotk: [
{
label: 'aibotkKey',
tip: intl.get('密钥key,智能微秘书个人中心获取apikey,申请地址:https://wechat.aibotk.com/signup?from=ql',
tip: intl.get(
'密钥key,智能微秘书个人中心获取apikey,申请地址:https://wechat.aibotk.com/signup?from=ql',
),
required: true,
},
@@ -265,7 +277,8 @@ export default {
},
{
label: 'aibotkName',
tip: intl.get('要发送的用户昵称或群名,如果目标是群,需要填群名,如果目标是好友,需要填好友昵称',
tip: intl.get(
'要发送的用户昵称或群名,如果目标是群,需要填群名,如果目标是好友,需要填好友昵称',
),
required: true,
},
@@ -273,7 +286,8 @@ export default {
iGot: [
{
label: 'iGotPushKey',
tip: intl.get('iGot的信息推送key,例如:https://push.hellyw.com/XXXXXXXX',
tip: intl.get(
'iGot的信息推送key,例如:https://push.hellyw.com/XXXXXXXX',
),
required: true,
},
@@ -281,20 +295,23 @@ export default {
pushPlus: [
{
label: 'pushPlusToken',
tip: intl.get('微信扫码登录后一对一推送或一对多推送下面的token(您的Token),不提供PUSH_PLUS_USER则默认为一对一推送,参考 https://www.pushplus.plus/',
tip: intl.get(
'微信扫码登录后一对一推送或一对多推送下面的token(您的Token),不提供PUSH_PLUS_USER则默认为一对一推送,参考 https://www.pushplus.plus/',
),
required: true,
},
{
label: 'pushPlusUser',
tip: intl.get('一对多推送的“群组编码”(一对多推送下面->您的群组(如无则创建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)',
tip: intl.get(
'一对多推送的“群组编码”(一对多推送下面->您的群组(如无则创建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)',
),
},
],
lark: [
{
label: 'larkKey',
tip: intl.get('飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973',
tip: intl.get(
'飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973',
),
required: true,
},
@@ -302,7 +319,8 @@ export default {
email: [
{
label: 'emailService',
tip: intl.get('邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://nodemailer.com/smtp/well-known/',
tip: intl.get(
'邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://nodemailer.com/smtp/well-known/',
),
required: true,
},
@@ -316,6 +334,29 @@ export default {
required: true,
},
],
chronocat: [
{
label: 'chronocatURL',
tip: intl.get(
'Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/',
),
required: true,
},
{
label: 'chronocatQQ',
tip: intl.get(
'个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx',
),
required: true,
},
{
label: 'chronocatToken',
tip: intl.get(
'docker安装在持久化config目录下的chronocat.yml文件可找到',
),
required: true,
},
],
webhook: [
{
label: 'webhookMethod',
@@ -335,7 +376,8 @@ export default {
},
{
label: 'webhookUrl',
tip: intl.get('请求链接以http或者https开头。url或者body中必须包含$title$content可选,对应api内容的位置',
tip: intl.get(
'请求链接以http或者https开头。url或者body中必须包含$title$content可选,对应api内容的位置',
),
required: true,
placeholder: 'https://xxx.cn/api?content=$title\n',
@@ -347,7 +389,8 @@ export default {
},
{
label: 'webhookBody',
tip: intl.get('请求体格式key1: value1,多个换行分割。url或者body中必须包含$title$content可选,对应api内容的位置',
tip: intl.get(
'请求体格式key1: value1,多个换行分割。url或者body中必须包含$title$content可选,对应api内容的位置',
),
placeholder: 'key1: $title\nkey2: $content',
},
+15 -2
View File
@@ -329,7 +329,7 @@ export function getCommandScript(
return [s, p];
}
export function parseCrontab(schedule: string): Date {
export function parseCrontab(schedule: string): Date | null {
try {
const time = cron_parser.parseExpression(schedule);
if (time) {
@@ -337,7 +337,20 @@ export function parseCrontab(schedule: string): Date {
}
} catch (error) { }
return new Date('1970');
return null;
}
export function getCrontabsNextDate(schedule: string, extra_schedules: string[]): Date | null {
let date = parseCrontab(schedule)
if (extra_schedules?.length) {
extra_schedules.forEach(x => {
const _date = parseCrontab(x)
if (_date && (!date || _date < date)) {
date = _date;
}
})
}
return date;
}
export function getExtension(filename: string) {
+2 -9
View File
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ES2015",
"moduleResolution": "Bundler",
"module": "commonjs",
"moduleResolution": "node",
"importHelpers": true,
"jsx": "react-jsx",
"esModuleInterop": true,
@@ -25,14 +25,7 @@
"include": ["src/**/*", ".umirc.ts", "typings.d.ts", "back/**/*"],
"exclude": [
"node_modules",
"lib",
"es",
"static",
"data",
"typings",
"**/__test__",
"test",
"docs",
"tests"
]
}
+17 -10
View File
@@ -1,11 +1,18 @@
version: 2.16.3
changeLogLink: https://t.me/jiao_long/394
publishTime: 2023-09-23 12:00
version: 2.16.4
changeLogLink: https://t.me/jiao_long/395
publishTime: 2023-10-18 23:00
changeLog: |
1. 定时任务支持设置多定时规则、任务执行前和执行后命令
2. 增加启动环境变量 QlPort, 支持修改 hosts 模式青龙启动端口
3. 消息通知 Bark 增加额外参数: 时效性通知、跳转Url
4. 修复调试脚本日志丢失
5. 环境变量名称增加复制功能
6. 修复系统设置展示版本失败
7. 其他 bug 修复
1. 脚本推送增加自定义 webhook 方式
3. 增加 chronocat 无头模式的QQNT推送
2. task 命令支持给脚本传参,使用 -- 分割,后面的参数都会传给脚本
4. 系统日志增加置顶置底按钮
5. 定时任务详情增加额外定时展示
6. 定时任务增加前后规则校验,不能包含 task 命令
7. 修复定时任务间隔较小,任务状态不准确
8. 修复定时任务日志滚动
9. 修复 QlBaseUrl 末尾不加斜杠无法访问
10. 修复定时任务详情页查看历史日志异常
11. 修复定时任务添加 task_after 命令无法停止
12. 修复清空文件夹报错
13. 修复设置代理时, schedule 服务通信异常
14. 其他优化