Compare commits

...
28 Commits
Author SHA1 Message Date
whyour 118c92d9e5 更新版本 v2.18.2 2025-02-28 00:56:43 +08:00
whyour 280085668e 修复未初始化时区设置 2025-02-28 00:56:40 +08:00
whyour bae4073a64 增加重置密码命令 2025-02-27 23:57:26 +08:00
whyour af3e358a6a 系统设置增加时区设置 2025-02-27 00:45:21 +08:00
whyour 64fcbff715 脚本管理增加可预览检查 2025-02-26 01:17:11 +08:00
whyour f9f78b4e05 修改系统内置通知模块名称,避免重复 2025-02-25 00:32:13 +08:00
whyour fa83761d27 修改定时规则类型 2025-02-21 01:35:08 +08:00
whyour 8173075b67 定时任务支持 @once 和 @boot 任务 2025-02-20 02:18:59 +08:00
whyour 496918131f 修复群晖通知参数,任务视图不属于筛选 2025-02-16 12:15:45 +08:00
whyour 5d64aae452 修复 mirror action 2025-02-07 01:56:23 +08:00
whyour 952ea2859e 修复最后运行时长多语言显示 2025-01-31 23:02:25 +08:00
whyour 25e8e8198d 修改 QLAPI 调用校验 2025-01-30 00:55:48 +08:00
whyour 3ab5b0d86b 修复登录失败没有提示 2025-01-29 23:50:46 +08:00
whyour 6df651aa63 更新 ts-proto 版本 2025-01-29 23:29:45 +08:00
whyour 924dfd6a6f 更新版本 v2.18.1 2025-01-22 01:17:15 +08:00
whyour 07f43538df 修复写入文件 mode 2025-01-22 01:17:13 +08:00
whyour 4fa5fa2014 修改 lock 文件名称规则 2025-01-16 00:35:47 +08:00
whyour f9b9543a6d 修复 JavaScript telegram 通知 2025-01-15 23:23:54 +08:00
whyour af97543918 修改错误提示 2025-01-14 23:20:53 +08:00
whyour a508522872 增加安全报告提示 2025-01-14 01:05:48 +08:00
whyour f1ca2134b7 移除 nedb 和 sentry 2025-01-14 00:24:25 +08:00
whyour a9755655b2 更新 readme 2025-01-12 18:32:48 +08:00
whyour 6a76e82f26 修改 QLAPI python 示例 2025-01-12 18:11:42 +08:00
whyour 6775f5d123 修复 QLAPI 系统通知响应 2025-01-12 17:02:21 +08:00
whyour ad6e08525c 修改 QLAPI 系统通知 2025-01-12 15:39:28 +08:00
whyour 51ef4e7476 修改任务状态更新失败提示,重复运行提示 2025-01-12 00:19:14 +08:00
whyour e5b35273f9 修复更新环境变量 2025-01-11 17:14:30 +08:00
whyour 647ed3b66c QLAPI 支持操作环境变量和系统通知 2025-01-11 01:59:46 +08:00
69 changed files with 5287 additions and 1916 deletions
+10 -6
View File
@@ -20,10 +20,12 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pixta-dev/repository-mirroring-action@v1
- uses: wearerequired/git-mirror-action@v1
env:
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
with:
target_repo_url: git@gitlab.com:whyour/qinglong.git
ssh_private_key: ${{ secrets.GITLAB_SSH_PK }}
source-repo: https://github.com/whyour/qinglong.git
destination-repo: git@gitlab.com:whyour/qinglong.git
to_gitee:
runs-on: ubuntu-latest
@@ -31,10 +33,12 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pixta-dev/repository-mirroring-action@v1
- uses: wearerequired/git-mirror-action@v1
env:
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
with:
target_repo_url: git@gitee.com:whyour/qinglong.git
ssh_private_key: ${{ secrets.GITLAB_SSH_PK }}
source-repo: https://github.com/whyour/qinglong.git
destination-repo: git@gitee.com:whyour/qinglong.git
build-static:
runs-on: ubuntu-latest
+1
View File
@@ -27,3 +27,4 @@ __pycache__
/shell/preload/env.*
/shell/preload/notify.*
/shell/preload/*-notify.json
/shell/preload/__ql_notify__.*
+4 -127
View File
@@ -56,138 +56,15 @@ npm i @whyour/qinglong
## Deployment
### Docker (Recommended)
[View Documentation](https://qinglong.online/guide/getting-started/installation-guide)
```bash
# curl -sSL get.docker.com | sh
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, 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" \
--name qinglong \
--hostname qinglong \
--restart unless-stopped \
whyour/qinglong:latest
```
## Built-in API
### BaoTa Panel one-click deployment (Recommended)
1. To install Pagoda Panel, go to the official website of [BaoTa Panel](https://www.bt.cn/u/EcDAFU), select the official version of the script to download and install.
2. After installation, login to Pagoda Panel, click `Docker` in the menu bar, the first time you enter, you will be prompted to install `Docker` service, click Install Now, follow the prompts to complete the installation.
3. After the installation is complete, find `Qinglong Panel` in the app shop, click Install, configure the domain name and other basic information to complete the installation.
### Docker-compose (Recommended)
```bash
# curl -L https://github.com/docker/compose/releases/download/1.16.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
mkdir qinglong
wget https://raw.githubusercontent.com/whyour/qinglong/master/docker/docker-compose.yml
# start
docker-compose up -d
# stop
docker-compose down
```
### Podman (Recommended)
```bash
# https://podman.io/getting-started/installation
podman run -dit \
--network bridge \
-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, 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" \
--name qinglong \
--hostname qinglong \
docker.io/whyour/qinglong:latest
```
### Npm (Local)
It is recommended to use a pure system installation to avoid losing the original system data, you need to install node/npm/python3/pip3/pnpm yourself
```bash
# Debian/Ubuntu
curl -sL https://deb.nodesource.com/setup_20.x | sudo -E bash -
```
```bash
npm install -g node-pre-gyp pnpm@8.3.1
npm install -g @whyour/qinglong
qinglong
# Add the environment variables QL_DIR and QL_DATA_DIR when prompted, QL_DATA_DIR must end with /data.
export QL_DIR=""
export QL_DATA_DIR=""
# Run again
qinglong
```
[View Documentation](https://qinglong.online/guide/user-guide/built-in-api)
## Built-in commands
- 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>
# 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
```
- 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
```
| **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 |
| extensions | Pull the branch of the repository |
| branch | Number of days of logs to be kept |
| days | File path for task execution |
| file_path | The name of the environment variable that needs to be concurrent or specified at the time of task execution |
[View Documentation](https://qinglong.online/guide/user-guide/basic-explanation)
## Development
+4 -125
View File
@@ -58,136 +58,15 @@ npm i @whyour/qinglong
## 部署
### docker (推荐)
[查看文档](https://qinglong.online/guide/getting-started/installation-guide)
```bash
# curl -sSL get.docker.com | sh
docker run -dit \
-v $PWD/ql/data:/ql/data \
# 冒号后面的 5700 为默认端口,如果设置了 QlPort, 需要跟 QlPort 保持一致
-p 5700:5700 \
# 部署路径非必须,比如 /test
-e QlBaseUrl="/" \
# 部署端口非必须,当使用 host 模式时,可以设置服务启动后的端口,默认 5700
-e QlPort="5700" \
--name qinglong \
--hostname qinglong \
--restart unless-stopped \
whyour/qinglong:latest
```
## 内置 API
### 宝塔面板一键部署(推荐)
1. 安装宝塔面板,前往 [宝塔面板](https://www.bt.cn/u/EcDAFU) 官网,选择正式版的脚本下载安装
2. 安装后登录宝塔面板,在菜单栏中点击 `Docker`,首次进入会提示安装`Docker`服务,点击立即安装,按提示完成安装
3. 安装完成后在应用商店中找到`青龙面板`,点击安装,配置域名等基本信息即可完成安装
### docker-compose (推荐)
```bash
# curl -L https://github.com/docker/compose/releases/download/1.16.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
mkdir qinglong && cd $_
wget https://raw.githubusercontent.com/whyour/qinglong/master/docker/docker-compose.yml
# 启动
docker-compose up -d
# 停止
docker-compose down
```
### podman (推荐)
```bash
# https://podman.io/getting-started/installation
podman run -dit \
--network bridge \
-v $PWD/ql/data:/ql/data \
# 冒号后面的 5700 为默认端口,如果设置了 QlPort, 需要跟 QlPort 保持一致
-p 5700:5700 \
# 部署路径非必须,比如 /test
-e QlBaseUrl="/" \
# 部署端口非必须,当使用 host 模式时,可以设置服务启动后的端口,默认 5700
-e QlPort="5700" \
--name qinglong \
--hostname qinglong \
docker.io/whyour/qinglong:latest
```
### npm (本地)
建议使用纯净系统安装,避免系统原有数据丢失,需要自己安装 node/npm/python3/pip3/pnpm
```bash
# Debian/Ubuntu
curl -sL https://deb.nodesource.com/setup_20.x | sudo -E bash -
```
```bash
npm install -g node-pre-gyp pnpm@8.3.1
npm install -g @whyour/qinglong
qinglong
# 根据提示增加环境变量 QL_DIR 和 QL_DATA_DIRQL_DATA_DIR 必须以 /data 结尾
export QL_DIR=""
export QL_DATA_DIR=""
# 再次执行
qinglong
```
[查看文档](https://qinglong.online/guide/user-guide/built-in-api)
## 内置命令
- 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>
# 使用 -- 分割,-- 后面的参数会传给脚本,下面的例子,脚本就可接收到参数 -u whyour -p password
task <file_path> -- -u whyour -p password
```
- 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
```
| **参数** | **说明** |
|------------|---------------------------------------------------------------------------------------------|
| file_url | 脚本地址 |
| repo_url | 仓库地址 |
| whitelist | 拉取仓库时的白名单,即就是需要拉取的脚本的路径包含的字符串,多个竖线分割 |
| blacklist | 拉取仓库时的黑名单,即就是需要拉取的脚本的路径不包含的字符串,多个竖线分割 |
| dependence | 拉取仓库需要的依赖文件,会直接从仓库拷贝到scripts下的仓库目录,不受黑名单影响,多个竖线分割 |
| extensions | 拉取仓库的文件后缀,多个竖线分割 |
| branch | 拉取仓库的分支 |
| days | 需要保留的日志的天数 |
| file_path | 任务执行时的文件路径 |
[查看文档](https://qinglong.online/guide/user-guide/basic-explanation)
## 开发
+5
View File
@@ -0,0 +1,5 @@
## Reporting a Vulnerability
To report a vulnerability, please open a private vulnerability report at <https://github.com/whyour/qinglong/security>.
While the discovery of new vulnerabilities is rare, we also recommend always using the latest versions of Qinglong to ensure your application remains as secure as possible.
Vendored
+9
View File
@@ -0,0 +1,9 @@
import 'express';
declare global {
namespace Express {
interface Request {
platform: string;
}
}
}
+5 -31
View File
@@ -4,7 +4,8 @@ import { Logger } from 'winston';
import CronService from '../services/cron';
import CronViewService from '../services/cronView';
import { celebrate, Joi } from 'celebrate';
import cron_parser from 'cron-parser';
import { commonCronSchema } from '../validation/schedule';
const route = Router();
export default (app: Router) => {
@@ -170,27 +171,14 @@ export default (app: Router) => {
route.post(
'/',
celebrate({
body: Joi.object({
command: Joi.string().required(),
schedule: Joi.string().required(),
name: Joi.string().optional(),
labels: Joi.array().optional(),
sub_id: Joi.number().optional().allow(null),
extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null),
}),
body: Joi.object(commonCronSchema),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
if (cron_parser.parseExpression(req.body.schedule).hasNext()) {
const cronService = Container.get(CronService);
const data = await cronService.create(req.body);
return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
}
} catch (e) {
return next(e);
}
@@ -331,30 +319,16 @@ export default (app: Router) => {
'/',
celebrate({
body: Joi.object({
labels: Joi.array().optional().allow(null),
command: Joi.string().required(),
schedule: Joi.string().required(),
name: Joi.string().optional().allow(null),
sub_id: Joi.number().optional().allow(null),
extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null),
...commonCronSchema,
id: Joi.number().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
if (
!req.body.schedule ||
cron_parser.parseExpression(req.body.schedule).hasNext()
) {
const cronService = Container.get(CronService);
const data = await cronService.update(req.body);
return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
}
} catch (e) {
return next(e);
}
@@ -418,7 +392,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.import_crontab();
const data = await cronService.importCrontab();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
+2 -2
View File
@@ -2,7 +2,7 @@ import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import config from '../config';
import { getFileContentByName, readDirs, rmPath } from '../config/util';
import { getFileContentByName, readDirs, removeAnsi, rmPath } from '../config/util';
import { join, resolve } from 'path';
import { celebrate, Joi } from 'celebrate';
const route = Router();
@@ -42,7 +42,7 @@ export default (app: Router) => {
return res.send({ code: 403, message: '暂无权限' });
}
const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: content });
res.send({ code: 200, data: removeAnsi(content) });
} catch (e) {
return next(e);
}
+19
View File
@@ -384,6 +384,7 @@ export default (app: Router) => {
body: Joi.object({
retries: Joi.number().optional(),
twoFactorActivated: Joi.boolean().optional(),
password: Joi.string().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -396,4 +397,22 @@ export default (app: Router) => {
}
},
);
route.put(
'/config/timezone',
celebrate({
body: Joi.object({
timezone: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateTimezone(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
};
+1 -1
View File
@@ -1,4 +1,3 @@
import './loaders/sentry'
import 'reflect-metadata'; // We need this in order to use @Decorators
import config from './config';
import express from 'express';
@@ -18,6 +17,7 @@ async function startServer() {
Logger.debug(`✌️ 后端服务启动成功!`);
console.debug(`✌️ 后端服务启动成功!`);
process.send?.('ready');
require('./loaders/bootAfter').default();
})
.on('error', (err) => {
Logger.error(err);
+2 -4
View File
@@ -42,8 +42,8 @@ const systemLogPath = path.join(dataPath, 'syslog/');
const envFile = path.join(preloadPath, 'env.sh');
const jsEnvFile = path.join(preloadPath, 'env.js');
const pyEnvFile = path.join(preloadPath, 'env.py');
const jsNotifyFile = path.join(preloadPath, 'notify.js');
const pyNotifyFile = path.join(preloadPath, 'notify.py');
const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js');
const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py');
const confFile = path.join(configPath, 'config.sh');
const crontabFile = path.join(configPath, 'crontab.list');
const authConfigFile = path.join(configPath, 'auth.json');
@@ -51,7 +51,6 @@ const extraFile = path.join(configPath, 'extra.sh');
const confBakDir = path.join(dataPath, 'config/bak/');
const sampleFile = path.join(samplePath, 'config.sample.sh');
const sqliteFile = path.join(samplePath, 'database.sqlite');
const systemNotifyFile = path.join(preloadPath, 'system-notify.json');
const authError = '错误的用户名密码,请重试';
const loginFaild = '请先登录!';
@@ -135,5 +134,4 @@ export default {
sqliteFile,
sshdPath,
systemLogPath,
systemNotifyFile,
};
+31 -6
View File
@@ -22,6 +22,10 @@ export async function getFileContentByName(fileName: string) {
return '';
}
export function removeAnsi(text: string) {
return text.replace(/\x1b\[\d+m/g, '');
}
export async function getLastModifyFilePath(dir: string) {
let filePath = '';
@@ -153,7 +157,12 @@ export function getPlatform(userAgent: string): 'mobile' | 'desktop' {
let platform = 'desktop';
if (system === 'windows' || system === 'macos' || system === 'linux') {
platform = 'desktop';
} else if (system === 'android' || system === 'ios' || system === 'openharmony' || testUa(/mobile/g)) {
} else if (
system === 'android' ||
system === 'ios' ||
system === 'openharmony' ||
testUa(/mobile/g)
) {
platform = 'mobile';
}
@@ -233,14 +242,14 @@ interface IFile {
key: string;
type: 'directory' | 'file';
parent: string;
mtime: number;
createTime: number;
size?: number;
children?: IFile[];
}
export function dirSort(a: IFile, b: IFile): number {
if (a.type === 'file' && b.type === 'file') {
return b.mtime - a.mtime;
return b.createTime - a.createTime;
} else if (a.type === 'directory' && b.type === 'directory') {
return a.title.localeCompare(b.title);
} else {
@@ -274,7 +283,7 @@ export async function readDirs(
key,
type: 'directory',
parent: relativePath,
mtime: stats.mtime.getTime(),
createTime: stats.birthtime.getTime(),
children: children.sort(sort),
});
} else {
@@ -284,7 +293,7 @@ export async function readDirs(
key,
parent: relativePath,
size: stats.size,
mtime: stats.mtime.getTime(),
createTime: stats.birthtime.getTime(),
});
}
}
@@ -518,7 +527,7 @@ export function safeJSONParse(value?: string) {
try {
return JSON.parse(value);
} catch (error) {
Logger.error('[JSON.parse失败]', error);
Logger.error('[safeJSONParse失败]', error);
return {};
}
}
@@ -533,3 +542,19 @@ export async function rmPath(path: string) {
Logger.error('[rmPath失败]', error);
}
}
export async function setSystemTimezone(timezone: string): Promise<boolean> {
try {
if (!(await fileExist(`/usr/share/zoneinfo/${timezone}`))) {
throw new Error('Invalid timezone');
}
await promiseExec(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`);
await promiseExec(`echo "${timezone}" > /etc/timezone`);
return true;
} catch (error) {
Logger.error('[setSystemTimezone失败]', error);
return false;
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ export class Env {
this.timestamp = new Date().toString();
this.position = options.position;
this.name = options.name;
this.remarks = options.remarks;
this.remarks = options.remarks || '';
}
}
+4 -5
View File
@@ -49,9 +49,8 @@ export class PushDeerNotification extends NotificationBaseInfo {
public pushDeerUrl = '';
}
export class ChatNotification extends NotificationBaseInfo {
public chatUrl = '';
public chatToken = '';
export class synologyChatNotification extends NotificationBaseInfo {
public synologyChatUrl = '';
}
export class BarkNotification extends NotificationBaseInfo {
@@ -61,7 +60,7 @@ export class BarkNotification extends NotificationBaseInfo {
public barkGroup = 'qinglong';
public barkLevel = 'active';
public barkUrl = '';
public barkArchive=""
public barkArchive = '';
}
export class TelegramBotNotification extends NotificationBaseInfo {
@@ -163,7 +162,7 @@ export interface NotificationInfo
GotifyNotification,
ServerChanNotification,
PushDeerNotification,
ChatNotification,
synologyChatNotification,
BarkNotification,
TelegramBotNotification,
DingtalkBotNotification,
+1
View File
@@ -37,6 +37,7 @@ export interface SystemConfigInfo {
nodeMirror?: string;
pythonMirror?: string;
linuxMirror?: string;
timezone?: string;
}
export interface LoginLogInfo {
+13
View File
@@ -0,0 +1,13 @@
export enum ScheduleType {
BOOT = '@boot',
ONCE = '@once',
}
export type ScheduleValidator = (schedule?: string) => boolean;
export type CronSchedulerPayload = {
name: string;
id: string;
schedule: string;
command: string;
extra_schedules: Array<{ schedule: string }>;
};
+8
View File
@@ -0,0 +1,8 @@
import Container from 'typedi';
import CronService from '../services/cron';
export default async () => {
const cronService = Container.get(CronService);
await cronService.bootTask();
};
+7 -81
View File
@@ -1,15 +1,11 @@
import Logger from './logger';
import path from 'path';
import DataStore from 'nedb';
import { EnvModel } from '../data/env';
import { CrontabModel } from '../data/cron';
import { DependenceModel } from '../data/dependence';
import { AppModel } from '../data/open';
import { SystemModel } from '../data/system';
import { fileExist } from '../config/util';
import { SubscriptionModel } from '../data/subscription';
import { CrontabViewModel } from '../data/cronView';
import config from '../config';
import { sequelize } from '../data';
export default async () => {
@@ -48,88 +44,18 @@ export default async () => {
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column sub_id NUMBER');
} catch (error) { }
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column extra_schedules JSON');
} catch (error) { }
await sequelize.query(
'alter table Crontabs add column extra_schedules JSON',
);
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column task_before TEXT');
} catch (error) { }
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column task_after TEXT');
} catch (error) { }
// 2.10-2.11 升级
const cronDbFile = path.join(config.rootPath, 'db/crontab.db');
const envDbFile = path.join(config.rootPath, 'db/env.db');
const appDbFile = path.join(config.rootPath, 'db/app.db');
const authDbFile = path.join(config.rootPath, 'db/auth.db');
const dependenceDbFile = path.join(config.rootPath, 'db/dependence.db');
const crondbExist = await fileExist(cronDbFile);
const dependenceDbExist = await fileExist(dependenceDbFile);
const envDbExist = await fileExist(envDbFile);
const appDbExist = await fileExist(appDbFile);
const authDbExist = await fileExist(authDbFile);
const cronCount = await CrontabModel.count();
const dependenceCount = await DependenceModel.count();
const envCount = await EnvModel.count();
const appCount = await AppModel.count();
const authCount = await SystemModel.count();
if (crondbExist && cronCount === 0) {
const cronDb = new DataStore({
filename: cronDbFile,
autoload: true,
});
cronDb.persistence.compactDatafile();
cronDb.find({}).exec(async (err, docs) => {
await CrontabModel.bulkCreate(docs, { ignoreDuplicates: true });
});
}
if (dependenceDbExist && dependenceCount === 0) {
const dependenceDb = new DataStore({
filename: dependenceDbFile,
autoload: true,
});
dependenceDb.persistence.compactDatafile();
dependenceDb.find({}).exec(async (err, docs) => {
await DependenceModel.bulkCreate(docs, { ignoreDuplicates: true });
});
}
if (envDbExist && envCount === 0) {
const envDb = new DataStore({
filename: envDbFile,
autoload: true,
});
envDb.persistence.compactDatafile();
envDb.find({}).exec(async (err, docs) => {
await EnvModel.bulkCreate(docs, { ignoreDuplicates: true });
});
}
if (appDbExist && appCount === 0) {
const appDb = new DataStore({
filename: appDbFile,
autoload: true,
});
appDb.persistence.compactDatafile();
appDb.find({}).exec(async (err, docs) => {
await AppModel.bulkCreate(docs, { ignoreDuplicates: true });
});
}
if (authDbExist && authCount === 0) {
const authDb = new DataStore({
filename: authDbFile,
autoload: true,
});
authDb.persistence.compactDatafile();
authDb.find({}).exec(async (err, docs) => {
await SystemModel.bulkCreate(docs, { ignoreDuplicates: true });
});
}
} catch (error) {}
console.log('✌️ DB loaded');
Logger.info('✌️ DB loaded');
+2 -5
View File
@@ -6,7 +6,6 @@ import config from '../config';
import { UnauthorizedError, expressjwt } from 'express-jwt';
import { getPlatform, getToken } from '../config/util';
import rewrite from 'express-urlrewrite';
import * as Sentry from '@sentry/node';
import { errors } from 'celebrate';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { serveEnv } from '../config/serverEnv';
@@ -131,8 +130,6 @@ export default ({ app }: { app: Application }) => {
app.use(errors());
Sentry.setupExpressErrorHandler(app);
app.use(
(
err: Error & { status: number },
@@ -162,8 +159,8 @@ export default ({ app }: { app: Application }) => {
.status(500)
.send({
code: 400,
message: `${err.name} ${err.message}`,
validation: err.errors,
message: `${err.message}`,
errors: err.errors,
})
.end();
}
+16 -7
View File
@@ -13,10 +13,11 @@ import { AuthDataType, SystemModel } from '../data/system';
import SystemService from '../services/system';
import UserService from '../services/user';
import { writeFile, readFile } from 'fs/promises';
import { safeJSONParse } from '../config/util';
import { createRandomString, safeJSONParse } from '../config/util';
import OpenService from '../services/open';
import { shareStore } from '../shared/store';
import Logger from './logger';
import { AppModel } from '../data/open';
export default async () => {
const cronService = Container.get(CronService);
@@ -27,10 +28,23 @@ export default async () => {
const openService = Container.get(OpenService);
// 初始化增加系统配置
let systemApp = (
await AppModel.findOne({
where: { name: 'system' },
})
)?.get({ plain: true });
if (!systemApp) {
systemApp = await AppModel.create({
name: 'system',
scopes: ['crons', 'system'],
client_id: createRandomString(12, 12),
client_secret: createRandomString(24, 24),
});
}
const [systemConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.systemConfig },
});
const [notifyConfig] = await SystemModel.findOrCreate({
await SystemModel.findOrCreate({
where: { type: AuthDataType.notification },
});
const [authConfig] = await SystemModel.findOrCreate({
@@ -54,11 +68,6 @@ export default async () => {
});
}
// 初始化通知配置
if (notifyConfig.info) {
await writeFile(config.systemNotifyFile, JSON.stringify(notifyConfig.info));
}
const installDependencies = () => {
// 初始化时安装所有处于安装中,安装成功,安装失败的依赖
DependenceModel.findAll({
+2 -2
View File
@@ -27,8 +27,8 @@ const sampleNotifyJsFile = path.join(samplePath, 'notify.js');
const sampleNotifyPyFile = path.join(samplePath, 'notify.py');
const scriptNotifyJsFile = path.join(scriptPath, 'sendNotify.js');
const scriptNotifyPyFile = path.join(scriptPath, 'notify.py');
const jsNotifyFile = path.join(preloadPath, 'notify.js');
const pyNotifyFile = path.join(preloadPath, 'notify.py');
const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js');
const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py');
const TaskBeforeFile = path.join(configPath, 'task_before.sh');
const TaskBeforeJsFile = path.join(configPath, 'task_before.js');
const TaskBeforePyFile = path.join(configPath, 'task_before.py');
+5 -1
View File
@@ -38,7 +38,8 @@ export default async () => {
// 运行删除日志任务
const data = await systemService.getSystemConfig();
if (data && data.info && data.info.logRemoveFrequency) {
if (data && data.info) {
if (data.info.logRemoveFrequency) {
const rmlogCron = {
id: data.id as number,
name: '删除日志',
@@ -55,6 +56,9 @@ export default async () => {
);
}
systemService.updateTimezone(data.info);
}
await subscriptionService.setSshConfig();
const subs = await subscriptionService.list();
for (const sub of subs) {
-26
View File
@@ -1,26 +0,0 @@
import * as Sentry from '@sentry/node';
import Logger from './logger';
import fs from 'fs';
import config from '../config';
import { parseContentVersion } from '../config/util';
let version = '1.0.0';
try {
const content = fs.readFileSync(config.versionFile, 'utf-8');
({ version } = parseContentVersion(content));
} catch (error) {}
Sentry.init({
ignoreErrors: [
/SequelizeUniqueConstraintError/i,
/Validation error/i,
/UnauthorizedError/i,
/celebrate request validation failed/i,
],
dsn: 'https://8b5c84cfef3e22541bc84de0ed00497b@o1098464.ingest.sentry.io/6122819',
tracesSampleRate: 0.5,
release: version,
});
Logger.info('✌️ Sentry loaded');
console.log('✌️ Sentry loaded');
+142
View File
@@ -0,0 +1,142 @@
syntax = "proto3";
package com.ql.api;
message EnvItem {
optional int32 id = 1;
optional string name = 2;
optional string value = 3;
optional string remarks = 4;
optional int32 status = 5;
optional int64 position = 6;
}
message GetEnvsRequest { string searchValue = 1; }
message CreateEnvRequest { repeated EnvItem envs = 1; }
message UpdateEnvRequest { EnvItem env = 1; }
message DeleteEnvsRequest { repeated int32 ids = 1; }
message MoveEnvRequest {
int32 id = 1;
int32 fromIndex = 2;
int32 toIndex = 3;
}
message DisableEnvsRequest { repeated int32 ids = 1; }
message EnableEnvsRequest { repeated int32 ids = 1; }
message UpdateEnvNamesRequest {
repeated int32 ids = 1;
string name = 2;
}
message GetEnvByIdRequest { int32 id = 1; }
message EnvsResponse {
int32 code = 1;
repeated EnvItem data = 2;
optional string message = 3;
}
message EnvResponse {
int32 code = 1;
EnvItem data = 2;
optional string message = 3;
}
message Response {
int32 code = 1;
optional string message = 2;
}
message SystemNotifyRequest {
string title = 1;
string content = 2;
}
message ExtraScheduleItem {
string schedule = 1;
}
message CronItem {
optional int32 id = 1;
optional string command = 2;
optional string schedule = 3;
optional string name = 4;
repeated string labels = 5;
optional int32 sub_id = 6;
repeated ExtraScheduleItem extra_schedules = 7;
optional string task_before = 8;
optional string task_after = 9;
optional int32 status = 10;
optional string log_path = 11;
optional int32 pid = 12;
optional int64 last_running_time = 13;
optional int64 last_execution_time = 14;
}
message CreateCronRequest {
string command = 1;
string schedule = 2;
optional string name = 3;
repeated string labels = 4;
optional int32 sub_id = 5;
repeated ExtraScheduleItem extra_schedules = 6;
optional string task_before = 7;
optional string task_after = 8;
}
message UpdateCronRequest {
int32 id = 1;
optional string command = 2;
optional string schedule = 3;
optional string name = 4;
repeated string labels = 5;
optional int32 sub_id = 6;
repeated ExtraScheduleItem extra_schedules = 7;
optional string task_before = 8;
optional string task_after = 9;
}
message DeleteCronsRequest { repeated int32 ids = 1; }
message CronsResponse {
int32 code = 1;
repeated CronItem data = 2;
optional string message = 3;
}
message CronResponse {
int32 code = 1;
CronItem data = 2;
optional string message = 3;
}
message CronDetailRequest { string log_path = 1; }
message CronDetailResponse {
int32 code = 1;
CronItem data = 2;
optional string message = 3;
}
service Api {
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
rpc UpdateEnv(UpdateEnvRequest) returns (EnvResponse) {}
rpc DeleteEnvs(DeleteEnvsRequest) returns (Response) {}
rpc MoveEnv(MoveEnvRequest) returns (EnvResponse) {}
rpc DisableEnvs(DisableEnvsRequest) returns (Response) {}
rpc EnableEnvs(EnableEnvsRequest) returns (Response) {}
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
rpc DeleteCrons(DeleteCronsRequest) returns (Response) {}
}
+2801
View File
File diff suppressed because it is too large Load Diff
+114 -91
View File
@@ -1,17 +1,23 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.17.3
// source: back/protos/cron.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import {
CallOptions,
type CallOptions,
ChannelCredentials,
Client,
ClientOptions,
ClientUnaryCall,
handleUnaryCall,
type ClientOptions,
type ClientUnaryCall,
type handleUnaryCall,
makeGenericClientConstructor,
Metadata,
ServiceError,
UntypedServiceImplementation,
type ServiceError,
type UntypedServiceImplementation,
} from "@grpc/grpc-js";
import _m0 from "protobufjs/minimal";
export const protobufPackage = "com.ql.cron";
@@ -23,7 +29,7 @@ export interface ICron {
id: string;
schedule: string;
command: string;
extraSchedules: ISchedule[];
extra_schedules: ISchedule[];
name: string;
}
@@ -45,22 +51,22 @@ function createBaseISchedule(): ISchedule {
return { schedule: "" };
}
export const ISchedule = {
encode(message: ISchedule, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
export const ISchedule: MessageFns<ISchedule> = {
encode(message: ISchedule, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.schedule !== "") {
writer.uint32(10).string(message.schedule);
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): ISchedule {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
decode(input: BinaryReader | Uint8Array, length?: number): ISchedule {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseISchedule();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
case 1: {
if (tag !== 10) {
break;
}
@@ -68,28 +74,30 @@ export const ISchedule = {
message.schedule = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): ISchedule {
return { schedule: isSet(object.schedule) ? String(object.schedule) : "" };
return { schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : "" };
},
toJSON(message: ISchedule): unknown {
const obj: any = {};
message.schedule !== undefined && (obj.schedule = message.schedule);
if (message.schedule !== "") {
obj.schedule = message.schedule;
}
return obj;
},
create<I extends Exact<DeepPartial<ISchedule>, I>>(base?: I): ISchedule {
return ISchedule.fromPartial(base ?? {});
return ISchedule.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ISchedule>, I>>(object: I): ISchedule {
const message = createBaseISchedule();
message.schedule = object.schedule ?? "";
@@ -98,11 +106,11 @@ export const ISchedule = {
};
function createBaseICron(): ICron {
return { id: "", schedule: "", command: "", extraSchedules: [], name: "" };
return { id: "", schedule: "", command: "", extra_schedules: [], name: "" };
}
export const ICron = {
encode(message: ICron, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
export const ICron: MessageFns<ICron> = {
encode(message: ICron, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.id !== "") {
writer.uint32(10).string(message.id);
}
@@ -112,8 +120,8 @@ export const ICron = {
if (message.command !== "") {
writer.uint32(26).string(message.command);
}
for (const v of message.extraSchedules) {
ISchedule.encode(v!, writer.uint32(34).fork()).ldelim();
for (const v of message.extra_schedules) {
ISchedule.encode(v!, writer.uint32(34).fork()).join();
}
if (message.name !== "") {
writer.uint32(42).string(message.name);
@@ -121,42 +129,46 @@ export const ICron = {
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): ICron {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
decode(input: BinaryReader | Uint8Array, length?: number): ICron {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseICron();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
case 1: {
if (tag !== 10) {
break;
}
message.id = reader.string();
continue;
case 2:
}
case 2: {
if (tag !== 18) {
break;
}
message.schedule = reader.string();
continue;
case 3:
}
case 3: {
if (tag !== 26) {
break;
}
message.command = reader.string();
continue;
case 4:
}
case 4: {
if (tag !== 34) {
break;
}
message.extraSchedules.push(ISchedule.decode(reader, reader.uint32()));
message.extra_schedules.push(ISchedule.decode(reader, reader.uint32()));
continue;
case 5:
}
case 5: {
if (tag !== 42) {
break;
}
@@ -164,50 +176,56 @@ export const ICron = {
message.name = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): ICron {
return {
id: isSet(object.id) ? String(object.id) : "",
schedule: isSet(object.schedule) ? String(object.schedule) : "",
command: isSet(object.command) ? String(object.command) : "",
extraSchedules: Array.isArray(object?.extraSchedules)
? object.extraSchedules.map((e: any) => ISchedule.fromJSON(e))
id: isSet(object.id) ? globalThis.String(object.id) : "",
schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : "",
command: isSet(object.command) ? globalThis.String(object.command) : "",
extra_schedules: globalThis.Array.isArray(object?.extra_schedules)
? object.extra_schedules.map((e: any) => ISchedule.fromJSON(e))
: [],
name: isSet(object.name) ? String(object.name) : "",
name: isSet(object.name) ? globalThis.String(object.name) : "",
};
},
toJSON(message: ICron): unknown {
const obj: any = {};
message.id !== undefined && (obj.id = message.id);
message.schedule !== undefined && (obj.schedule = message.schedule);
message.command !== undefined && (obj.command = message.command);
if (message.extraSchedules) {
obj.extraSchedules = message.extraSchedules.map((e) => e ? ISchedule.toJSON(e) : undefined);
} else {
obj.extraSchedules = [];
if (message.id !== "") {
obj.id = message.id;
}
if (message.schedule !== "") {
obj.schedule = message.schedule;
}
if (message.command !== "") {
obj.command = message.command;
}
if (message.extra_schedules?.length) {
obj.extra_schedules = message.extra_schedules.map((e) => ISchedule.toJSON(e));
}
if (message.name !== "") {
obj.name = message.name;
}
message.name !== undefined && (obj.name = message.name);
return obj;
},
create<I extends Exact<DeepPartial<ICron>, I>>(base?: I): ICron {
return ICron.fromPartial(base ?? {});
return ICron.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ICron>, I>>(object: I): ICron {
const message = createBaseICron();
message.id = object.id ?? "";
message.schedule = object.schedule ?? "";
message.command = object.command ?? "";
message.extraSchedules = object.extraSchedules?.map((e) => ISchedule.fromPartial(e)) || [];
message.extra_schedules = object.extra_schedules?.map((e) => ISchedule.fromPartial(e)) || [];
message.name = object.name ?? "";
return message;
},
@@ -217,22 +235,22 @@ function createBaseAddCronRequest(): AddCronRequest {
return { crons: [] };
}
export const AddCronRequest = {
encode(message: AddCronRequest, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
export const AddCronRequest: MessageFns<AddCronRequest> = {
encode(message: AddCronRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
for (const v of message.crons) {
ICron.encode(v!, writer.uint32(10).fork()).ldelim();
ICron.encode(v!, writer.uint32(10).fork()).join();
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): AddCronRequest {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
decode(input: BinaryReader | Uint8Array, length?: number): AddCronRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseAddCronRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
case 1: {
if (tag !== 10) {
break;
}
@@ -240,32 +258,30 @@ export const AddCronRequest = {
message.crons.push(ICron.decode(reader, reader.uint32()));
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): AddCronRequest {
return { crons: Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [] };
return { crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [] };
},
toJSON(message: AddCronRequest): unknown {
const obj: any = {};
if (message.crons) {
obj.crons = message.crons.map((e) => e ? ICron.toJSON(e) : undefined);
} else {
obj.crons = [];
if (message.crons?.length) {
obj.crons = message.crons.map((e) => ICron.toJSON(e));
}
return obj;
},
create<I extends Exact<DeepPartial<AddCronRequest>, I>>(base?: I): AddCronRequest {
return AddCronRequest.fromPartial(base ?? {});
return AddCronRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<AddCronRequest>, I>>(object: I): AddCronRequest {
const message = createBaseAddCronRequest();
message.crons = object.crons?.map((e) => ICron.fromPartial(e)) || [];
@@ -277,13 +293,13 @@ function createBaseAddCronResponse(): AddCronResponse {
return {};
}
export const AddCronResponse = {
encode(_: AddCronResponse, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
export const AddCronResponse: MessageFns<AddCronResponse> = {
encode(_: AddCronResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): AddCronResponse {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
decode(input: BinaryReader | Uint8Array, length?: number): AddCronResponse {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseAddCronResponse();
while (reader.pos < end) {
@@ -293,7 +309,7 @@ export const AddCronResponse = {
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
reader.skip(tag & 7);
}
return message;
},
@@ -308,9 +324,8 @@ export const AddCronResponse = {
},
create<I extends Exact<DeepPartial<AddCronResponse>, I>>(base?: I): AddCronResponse {
return AddCronResponse.fromPartial(base ?? {});
return AddCronResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<AddCronResponse>, I>>(_: I): AddCronResponse {
const message = createBaseAddCronResponse();
return message;
@@ -321,22 +336,22 @@ function createBaseDeleteCronRequest(): DeleteCronRequest {
return { ids: [] };
}
export const DeleteCronRequest = {
encode(message: DeleteCronRequest, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
export const DeleteCronRequest: MessageFns<DeleteCronRequest> = {
encode(message: DeleteCronRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
for (const v of message.ids) {
writer.uint32(10).string(v!);
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): DeleteCronRequest {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
decode(input: BinaryReader | Uint8Array, length?: number): DeleteCronRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDeleteCronRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
case 1: {
if (tag !== 10) {
break;
}
@@ -344,32 +359,30 @@ export const DeleteCronRequest = {
message.ids.push(reader.string());
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): DeleteCronRequest {
return { ids: Array.isArray(object?.ids) ? object.ids.map((e: any) => String(e)) : [] };
return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.String(e)) : [] };
},
toJSON(message: DeleteCronRequest): unknown {
const obj: any = {};
if (message.ids) {
obj.ids = message.ids.map((e) => e);
} else {
obj.ids = [];
if (message.ids?.length) {
obj.ids = message.ids;
}
return obj;
},
create<I extends Exact<DeepPartial<DeleteCronRequest>, I>>(base?: I): DeleteCronRequest {
return DeleteCronRequest.fromPartial(base ?? {});
return DeleteCronRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<DeleteCronRequest>, I>>(object: I): DeleteCronRequest {
const message = createBaseDeleteCronRequest();
message.ids = object.ids?.map((e) => e) || [];
@@ -381,13 +394,13 @@ function createBaseDeleteCronResponse(): DeleteCronResponse {
return {};
}
export const DeleteCronResponse = {
encode(_: DeleteCronResponse, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
export const DeleteCronResponse: MessageFns<DeleteCronResponse> = {
encode(_: DeleteCronResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): DeleteCronResponse {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
decode(input: BinaryReader | Uint8Array, length?: number): DeleteCronResponse {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDeleteCronResponse();
while (reader.pos < end) {
@@ -397,7 +410,7 @@ export const DeleteCronResponse = {
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
reader.skip(tag & 7);
}
return message;
},
@@ -412,9 +425,8 @@ export const DeleteCronResponse = {
},
create<I extends Exact<DeepPartial<DeleteCronResponse>, I>>(base?: I): DeleteCronResponse {
return DeleteCronResponse.fromPartial(base ?? {});
return DeleteCronResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<DeleteCronResponse>, I>>(_: I): DeleteCronResponse {
const message = createBaseDeleteCronResponse();
return message;
@@ -484,12 +496,14 @@ export interface CronClient extends Client {
export const CronClient = makeGenericClientConstructor(CronService, "com.ql.cron.Cron") as unknown as {
new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): CronClient;
service: typeof CronService;
serviceName: string;
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
export type DeepPartial<T> = T extends Builtin ? T
: T extends Array<infer U> ? Array<DeepPartial<U>> : T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
@@ -500,3 +514,12 @@ export type Exact<P, I extends P> = P extends Builtin ? P
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
export interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
+50 -29
View File
@@ -1,19 +1,25 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.17.3
// source: back/protos/health.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import {
CallOptions,
type CallOptions,
ChannelCredentials,
Client,
ClientOptions,
ClientReadableStream,
ClientUnaryCall,
handleServerStreamingCall,
handleUnaryCall,
type ClientOptions,
type ClientReadableStream,
type ClientUnaryCall,
type handleServerStreamingCall,
type handleUnaryCall,
makeGenericClientConstructor,
Metadata,
ServiceError,
UntypedServiceImplementation,
type ServiceError,
type UntypedServiceImplementation,
} from "@grpc/grpc-js";
import _m0 from "protobufjs/minimal";
export const protobufPackage = "com.ql.health";
@@ -74,22 +80,22 @@ function createBaseHealthCheckRequest(): HealthCheckRequest {
return { service: "" };
}
export const HealthCheckRequest = {
encode(message: HealthCheckRequest, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
export const HealthCheckRequest: MessageFns<HealthCheckRequest> = {
encode(message: HealthCheckRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.service !== "") {
writer.uint32(10).string(message.service);
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): HealthCheckRequest {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
decode(input: BinaryReader | Uint8Array, length?: number): HealthCheckRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseHealthCheckRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
case 1: {
if (tag !== 10) {
break;
}
@@ -97,28 +103,30 @@ export const HealthCheckRequest = {
message.service = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): HealthCheckRequest {
return { service: isSet(object.service) ? String(object.service) : "" };
return { service: isSet(object.service) ? globalThis.String(object.service) : "" };
},
toJSON(message: HealthCheckRequest): unknown {
const obj: any = {};
message.service !== undefined && (obj.service = message.service);
if (message.service !== "") {
obj.service = message.service;
}
return obj;
},
create<I extends Exact<DeepPartial<HealthCheckRequest>, I>>(base?: I): HealthCheckRequest {
return HealthCheckRequest.fromPartial(base ?? {});
return HealthCheckRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<HealthCheckRequest>, I>>(object: I): HealthCheckRequest {
const message = createBaseHealthCheckRequest();
message.service = object.service ?? "";
@@ -130,22 +138,22 @@ function createBaseHealthCheckResponse(): HealthCheckResponse {
return { status: 0 };
}
export const HealthCheckResponse = {
encode(message: HealthCheckResponse, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
export const HealthCheckResponse: MessageFns<HealthCheckResponse> = {
encode(message: HealthCheckResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.status !== 0) {
writer.uint32(8).int32(message.status);
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): HealthCheckResponse {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
decode(input: BinaryReader | Uint8Array, length?: number): HealthCheckResponse {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseHealthCheckResponse();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
case 1: {
if (tag !== 8) {
break;
}
@@ -153,10 +161,11 @@ export const HealthCheckResponse = {
message.status = reader.int32() as any;
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
reader.skip(tag & 7);
}
return message;
},
@@ -167,14 +176,15 @@ export const HealthCheckResponse = {
toJSON(message: HealthCheckResponse): unknown {
const obj: any = {};
message.status !== undefined && (obj.status = healthCheckResponse_ServingStatusToJSON(message.status));
if (message.status !== 0) {
obj.status = healthCheckResponse_ServingStatusToJSON(message.status);
}
return obj;
},
create<I extends Exact<DeepPartial<HealthCheckResponse>, I>>(base?: I): HealthCheckResponse {
return HealthCheckResponse.fromPartial(base ?? {});
return HealthCheckResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<HealthCheckResponse>, I>>(object: I): HealthCheckResponse {
const message = createBaseHealthCheckResponse();
message.status = object.status ?? 0;
@@ -236,12 +246,14 @@ export interface HealthClient extends Client {
export const HealthClient = makeGenericClientConstructor(HealthService, "com.ql.health.Health") as unknown as {
new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): HealthClient;
service: typeof HealthService;
serviceName: string;
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
export type DeepPartial<T> = T extends Builtin ? T
: T extends Array<infer U> ? Array<DeepPartial<U>> : T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
@@ -252,3 +264,12 @@ export type Exact<P, I extends P> = P extends Builtin ? P
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
export interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
+5 -5
View File
@@ -10,7 +10,7 @@ const addCron = (
callback: sendUnaryData<AddCronResponse>,
) => {
for (const item of call.request.crons) {
const { id, schedule, command, extraSchedules, name } = item;
const { id, schedule, command, extra_schedules, name } = item;
if (scheduleStacks.has(id)) {
scheduleStacks.get(id)?.forEach((x) => x.cancel());
}
@@ -23,8 +23,8 @@ const addCron = (
command,
);
if (extraSchedules?.length) {
extraSchedules.forEach((x) => {
if (extra_schedules?.length) {
extra_schedules.forEach((x) => {
Logger.info(
'[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
@@ -40,8 +40,8 @@ const addCron = (
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
}),
...(extraSchedules?.length
? extraSchedules.map((x) =>
...(extra_schedules?.length
? extra_schedules.map((x) =>
nodeSchedule.scheduleJob(id, x.schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
+275
View File
@@ -0,0 +1,275 @@
import 'reflect-metadata';
import { Container } from 'typedi';
import EnvService from '../services/env';
import { sendUnaryData, ServerUnaryCall } from '@grpc/grpc-js';
import {
CreateEnvRequest,
CronItem,
DeleteEnvsRequest,
DisableEnvsRequest,
EnableEnvsRequest,
EnvItem,
EnvResponse,
EnvsResponse,
GetEnvByIdRequest,
GetEnvsRequest,
MoveEnvRequest,
Response,
SystemNotifyRequest,
UpdateEnvNamesRequest,
UpdateEnvRequest,
} from '../protos/api';
import LoggerInstance from '../loaders/logger';
import pick from 'lodash/pick';
import SystemService from '../services/system';
import CronService from '../services/cron';
import {
CronDetailRequest,
CronDetailResponse,
CreateCronRequest,
UpdateCronRequest,
DeleteCronsRequest,
CronResponse,
} from '../protos/api';
Container.set('logger', LoggerInstance);
export const getEnvs = async (
call: ServerUnaryCall<GetEnvsRequest, EnvsResponse>,
callback: sendUnaryData<EnvsResponse>,
) => {
try {
if (!call.request.searchValue) {
return callback(null, {
code: 400,
data: [],
message: 'searchValue is required',
});
}
const envService = Container.get(EnvService);
const data = await envService.envs(call.request.searchValue);
callback(null, {
code: 200,
data: data.map((x) => ({ ...x, remarks: x.remarks || '' })),
});
} catch (e: any) {
callback(null, {
code: 500,
data: [],
message: e.message,
});
}
};
export const createEnv = async (
call: ServerUnaryCall<CreateEnvRequest, EnvsResponse>,
callback: sendUnaryData<EnvsResponse>,
) => {
try {
const envService = Container.get(EnvService);
const data = await envService.create(call.request.envs);
callback(null, { code: 200, data });
} catch (e: any) {
callback(e);
}
};
export const updateEnv = async (
call: ServerUnaryCall<UpdateEnvRequest, EnvResponse>,
callback: sendUnaryData<EnvResponse>,
) => {
try {
const envService = Container.get(EnvService);
const data = await envService.update(
pick(call.request.env, ['id', 'name', 'value', 'remark']) as EnvItem,
);
callback(null, { code: 200, data });
} catch (e: any) {
callback(e);
}
};
export const deleteEnvs = async (
call: ServerUnaryCall<DeleteEnvsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
const envService = Container.get(EnvService);
await envService.remove(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const moveEnv = async (
call: ServerUnaryCall<MoveEnvRequest, EnvResponse>,
callback: sendUnaryData<EnvResponse>,
) => {
try {
const envService = Container.get(EnvService);
const data = await envService.move(call.request.id, {
fromIndex: call.request.fromIndex,
toIndex: call.request.toIndex,
});
callback(null, { code: 200, data });
} catch (e: any) {
callback(e);
}
};
export const disableEnvs = async (
call: ServerUnaryCall<DisableEnvsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
const envService = Container.get(EnvService);
await envService.disabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const enableEnvs = async (
call: ServerUnaryCall<EnableEnvsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
const envService = Container.get(EnvService);
await envService.enabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const updateEnvNames = async (
call: ServerUnaryCall<UpdateEnvNamesRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
const envService = Container.get(EnvService);
await envService.updateNames({
ids: call.request.ids,
name: call.request.name,
});
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const getEnvById = async (
call: ServerUnaryCall<GetEnvByIdRequest, EnvResponse>,
callback: sendUnaryData<EnvResponse>,
) => {
try {
const envService = Container.get(EnvService);
const data = await envService.getDb({ id: call.request.id });
callback(null, {
code: 200,
data: { ...data, remarks: data.remarks || '' },
});
} catch (e: any) {
callback(e);
}
};
export const systemNotify = async (
call: ServerUnaryCall<SystemNotifyRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
const systemService = Container.get(SystemService);
const data = await systemService.notify(call.request);
callback(null, data);
} catch (e: any) {
callback(e);
}
};
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
if (!data) return undefined;
return {
...data,
sub_id: data.sub_id ?? undefined,
extra_schedules: data.extra_schedules ?? undefined,
pid: data.pid ?? undefined,
task_before: data.task_before ?? undefined,
task_after: data.task_after ?? undefined,
};
};
export const getCronDetail = async (
call: ServerUnaryCall<CronDetailRequest, CronDetailResponse>,
callback: sendUnaryData<CronDetailResponse>,
) => {
try {
if (!call.request.log_path) {
return callback(null, {
code: 400,
data: undefined,
message: 'log_path is required',
});
}
const cronService = Container.get(CronService);
const data = (await cronService.find({
log_path: call.request.log_path,
})) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(e);
}
};
export const createCron = async (
call: ServerUnaryCall<CreateCronRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
const cronService = Container.get(CronService);
const data = (await cronService.create(call.request)) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(e);
}
};
export const updateCron = async (
call: ServerUnaryCall<UpdateCronRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
const cronService = Container.get(CronService);
const { id, ...fields } = call.request;
const updateRequest = {
id,
...Object.entries(fields).reduce((acc: any, [key, value]) => {
if (value !== undefined) {
acc[key] = value;
}
return acc;
}, {}),
} as UpdateCronRequest;
const data = (await cronService.update(updateRequest)) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(e);
}
};
export const deleteCrons = async (
call: ServerUnaryCall<DeleteCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
const cronService = Container.get(CronService);
await cronService.remove(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
+3
View File
@@ -6,10 +6,13 @@ import { HealthService } from '../protos/health';
import { check } from './health';
import config from '../config';
import Logger from '../loaders/logger';
import { ApiService } from '../protos/api';
import * as Api from './api';
const server = new Server({ 'grpc.enable_http_proxy': 0 });
server.addService(HealthService, { check });
server.addService(CronService, { addCron, delCron });
server.addService(ApiService, Api);
server.bindAsync(
`0.0.0.0:${config.cronPort}`,
ServerCredentials.createInsecure(),
+73 -25
View File
@@ -22,6 +22,7 @@ import dayjs from 'dayjs';
import pickBy from 'lodash/pickBy';
import omit from 'lodash/omit';
import { writeFileWithLock } from '../shared/utils';
import { ScheduleType } from '../interface/schedule';
@Service()
export default class CronService {
@@ -35,22 +36,36 @@ export default class CronService {
return false;
}
private isOnceSchedule(schedule?: string) {
return schedule?.startsWith(ScheduleType.ONCE);
}
private isBootSchedule(schedule?: string) {
return schedule?.startsWith(ScheduleType.BOOT);
}
private isSpecialSchedule(schedule?: string) {
return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule);
}
public async create(payload: Crontab): Promise<Crontab> {
const tab = new Crontab(payload);
tab.saved = false;
const doc = await this.insert(tab);
if (this.isNodeCron(doc)) {
if (this.isNodeCron(doc) && !this.isSpecialSchedule(doc.schedule)) {
await cronClient.addCron([
{
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extraSchedules: doc.extra_schedules || [],
extra_schedules: doc.extra_schedules || [],
},
]);
}
await this.set_crontab();
await this.setCrontab();
return doc;
}
@@ -58,29 +73,33 @@ export default class CronService {
return await CrontabModel.create(payload, { returning: true });
}
public async update(payload: Crontab): Promise<Crontab> {
public async update(payload: Partial<Crontab>): Promise<Crontab> {
const doc = await this.getDb({ id: payload.id });
const tab = new Crontab({ ...doc, ...payload });
tab.saved = false;
const newDoc = await this.updateDb(tab);
if (doc.isDisabled === 1) {
return newDoc;
}
if (this.isNodeCron(doc)) {
await cronClient.delCron([String(doc.id)]);
}
if (this.isNodeCron(newDoc)) {
if (this.isNodeCron(newDoc) && !this.isSpecialSchedule(newDoc.schedule)) {
await cronClient.addCron([
{
name: doc.name || '',
id: String(newDoc.id),
schedule: newDoc.schedule!,
command: this.makeCommand(newDoc),
extraSchedules: newDoc.extra_schedules || [],
extra_schedules: newDoc.extra_schedules || [],
},
]);
}
await this.set_crontab();
await this.setCrontab();
return newDoc;
}
@@ -135,7 +154,7 @@ export default class CronService {
public async remove(ids: number[]) {
await CrontabModel.destroy({ where: { id: ids } });
await cronClient.delCron(ids.map(String));
await this.set_crontab();
await this.setCrontab();
}
public async pin(ids: number[]) {
@@ -179,8 +198,8 @@ export default class CronService {
for (const col of viewQuery.filters) {
const { property, value, operation } = col;
let q: any = {};
let operate2 = null;
let operate = null;
let operate2: any = null;
let operate: any = null;
switch (operation) {
case 'Reg':
operate = Op.like;
@@ -202,11 +221,18 @@ export default class CronService {
break;
case 'Nin':
q[Op.and] = [
{
[Op.or]: [
{
[property]: {
[Op.notIn]: Array.isArray(value) ? value : [value],
},
},
{
[property]: { [Op.is]: null },
},
],
},
property === 'status' && value.includes(2)
? { isDisabled: { [Op.ne]: 1 } }
: {},
@@ -320,10 +346,10 @@ export default class CronService {
log_path,
}: {
log_path: string;
}): Promise<Crontab | null> {
}): Promise<Crontab | undefined> {
try {
const result = await CrontabModel.findOne({ where: { log_path } });
return result;
return result?.get({ plain: true });
} catch (error) {
throw error;
}
@@ -374,7 +400,7 @@ export default class CronService {
try {
const result = await CrontabModel.findAll(condition);
const count = await CrontabModel.count({ where: query });
return { data: result, total: count };
return { data: result.map((x) => x.get({ plain: true })), total: count };
} catch (error) {
throw error;
}
@@ -424,7 +450,7 @@ export default class CronService {
name: cron.name,
command: cron.command,
schedule: cron.schedule,
extraSchedules: cron.extra_schedules,
extra_schedules: cron.extra_schedules,
};
if (cron.status !== CrontabStatus.queued) {
resolve(params);
@@ -495,7 +521,7 @@ export default class CronService {
public async disabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
await cronClient.delCron(ids.map(String));
await this.set_crontab();
await this.setCrontab();
}
public async enabled(ids: number[]) {
@@ -508,10 +534,10 @@ export default class CronService {
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extraSchedules: doc.extra_schedules || [],
extra_schedules: doc.extra_schedules || [],
}));
await cronClient.addCron(sixCron);
await this.set_crontab();
await this.setCrontab();
}
public async log(id: number) {
@@ -579,7 +605,7 @@ export default class CronService {
return crontab_job_string;
}
private async set_crontab(data?: { data: Crontab[]; total: number }) {
private async setCrontab(data?: { data: Crontab[]; total: number }) {
const tabs = data ?? (await this.crontabs());
var crontab_string = '';
tabs.data.forEach((tab) => {
@@ -587,7 +613,8 @@ export default class CronService {
if (
tab.isDisabled === 1 ||
_schedule!.length !== 5 ||
tab.extra_schedules?.length
tab.extra_schedules?.length ||
this.isSpecialSchedule(tab.schedule)
) {
crontab_string += '# ';
crontab_string += tab.schedule;
@@ -608,7 +635,7 @@ export default class CronService {
await CrontabModel.update({ saved: true }, { where: {} });
}
public import_crontab() {
public importCrontab() {
exec('crontab -l', (error, stdout, stderr) => {
const lines = stdout.split('\n');
const namePrefix = new Date().getTime();
@@ -644,17 +671,38 @@ export default class CronService {
public async autosave_crontab() {
const tabs = await this.crontabs();
this.set_crontab(tabs);
this.setCrontab(tabs);
const sixCron = tabs.data
.filter((x) => this.isNodeCron(x) && x.isDisabled !== 1)
const regularCrons = tabs.data
.filter(
(x) =>
this.isNodeCron(x) &&
x.isDisabled !== 1 &&
!this.isSpecialSchedule(x.schedule),
)
.map((doc) => ({
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extraSchedules: doc.extra_schedules || [],
extra_schedules: doc.extra_schedules || [],
}));
await cronClient.addCron(sixCron);
await cronClient.addCron(regularCrons);
}
public async bootTask() {
const tabs = await this.crontabs();
const bootTasks = tabs.data.filter(
(x) => !x.isDisabled && this.isBootSchedule(x.schedule),
);
if (bootTasks.length > 0) {
await CrontabModel.update(
{ status: CrontabStatus.queued },
{ where: { id: bootTasks.map((t) => t.id!) } },
);
for (const task of bootTasks) {
await this.runSingle(task.id!);
}
}
}
}
+7 -7
View File
@@ -41,7 +41,7 @@ export default class EnvService {
}
public async insert(payloads: Env[]): Promise<Env[]> {
const result = [];
const result: Env[] = [];
for (const env of payloads) {
const doc = await EnvModel.create(env, { returning: true });
result.push(doc);
@@ -62,7 +62,7 @@ export default class EnvService {
return await this.getDb({ id: payload.id });
}
public async remove(ids: string[]) {
public async remove(ids: number[]) {
await EnvModel.destroy({ where: { id: ids } });
await this.set_envs();
}
@@ -150,7 +150,7 @@ export default class EnvService {
['position', 'DESC'],
['createdAt', 'ASC'],
]);
return result as any;
return result;
} catch (error) {
throw error;
}
@@ -161,7 +161,7 @@ export default class EnvService {
where: { ...query },
order: [...sort],
});
return docs;
return docs.map((x) => x.get({ plain: true }));
}
public async getDb(query: FindOptions<Env>['where']): Promise<Env> {
@@ -172,7 +172,7 @@ export default class EnvService {
return doc.get({ plain: true });
}
public async disabled(ids: string[]) {
public async disabled(ids: number[]) {
await EnvModel.update(
{ status: EnvStatus.disabled },
{ where: { id: ids } },
@@ -180,12 +180,12 @@ export default class EnvService {
await this.set_envs();
}
public async enabled(ids: string[]) {
public async enabled(ids: number[]) {
await EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } });
await this.set_envs();
}
public async updateNames({ ids, name }: { ids: string[]; name: string }) {
public async updateNames({ ids, name }: { ids: number[]; name: string }) {
await EnvModel.update({ name }, { where: { id: ids } });
await this.set_envs();
}
+43 -51
View File
@@ -3,12 +3,9 @@ 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, safeJSONParse } from '../config/util';
import { parseBody, parseHeaders } from '../config/util';
import { NotificationInfo } from '../data/notify';
import UserService from './user';
import { readFile } from 'fs/promises';
import config from '../config';
@Service()
export default class NotificationService {
@@ -49,27 +46,6 @@ export default class NotificationService {
constructor() {}
public async externalNotify(
title: string,
content: string,
): Promise<boolean | undefined> {
const { type, ...rest } = safeJSONParse(
await readFile(config.systemNotifyFile, 'utf-8'),
);
if (type) {
this.title = title;
this.content = content;
this.params = rest;
const notificationModeAction = this.modeMap.get(type);
try {
return await notificationModeAction?.call(this);
} catch (error: any) {
throw error;
}
}
return false;
}
public async notify(
title: string,
content: string,
@@ -154,7 +130,8 @@ export default class NotificationService {
private async serverChan() {
const { serverChanKey } = this.params;
const matchResult = serverChanKey.match(/^sctp(\d+)t/i);
const url = matchResult && matchResult[1]
const url =
matchResult && matchResult[1]
? `https://${matchResult[1]}.push.ft07.com/send/${serverChanKey}.send`
: `https://sctapi.ftqq.com/${serverChanKey}.send`;
@@ -162,7 +139,9 @@ export default class NotificationService {
const res: any = await got
.post(url, {
...this.gotOption,
body: `title=${encodeURIComponent(this.title)}&desp=${encodeURIComponent(this.content)}`,
body: `title=${encodeURIComponent(
this.title,
)}&desp=${encodeURIComponent(this.content)}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.json();
@@ -203,11 +182,10 @@ export default class NotificationService {
}
private async chat() {
const { chatUrl, chatToken } = this.params;
const url = `${chatUrl}${chatToken}`;
const { synologyChatUrl } = this.params;
try {
const res: any = await got
.post(url, {
.post(synologyChatUrl, {
...this.gotOption,
body: `payload={"text":"${this.title}\n${this.content}"}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -523,7 +501,15 @@ export default class NotificationService {
}
private async pushPlus() {
const { pushPlusToken, pushPlusUser, pushplusWebhook, pushPlusTemplate, pushplusChannel, pushplusCallbackUrl, pushplusTo} = this.params;
const {
pushPlusToken,
pushPlusUser,
pushplusWebhook,
pushPlusTemplate,
pushplusChannel,
pushplusCallbackUrl,
pushplusTo,
} = this.params;
const url = `https://www.pushplus.plus/send`;
try {
let body = {
@@ -537,13 +523,11 @@ export default class NotificationService {
channel: `${pushplusChannel || 'wechat'}`,
webhook: `${pushplusWebhook || ''}`,
callbackUrl: `${pushplusCallbackUrl || ''}`,
to: `${pushplusTo || ''}`
to: `${pushplusTo || ''}`,
},
}
};
const res: any = await got
.post(url, body)
.json();
const res: any = await got.post(url, body).json();
if (res.code === 200) {
return true;
@@ -681,12 +665,14 @@ export default class NotificationService {
};
try {
const encodedTitle = encodeRfc2047(this.title);
const res: any = await got
.post(`${ntfyUrl || 'https://ntfy.sh'}/${ntfyTopic}`, {
const res: any = await got.post(
`${ntfyUrl || 'https://ntfy.sh'}/${ntfyTopic}`,
{
...this.gotOption,
body: `${this.content}`,
headers: { 'Title': encodedTitle, 'Priority': `${ntfyPriority || '3'}` },
});
headers: { Title: encodedTitle, Priority: `${ntfyPriority || '3'}` },
},
);
if (res.statusCode === 200) {
return true;
} else {
@@ -698,17 +684,24 @@ export default class NotificationService {
}
private async wxPusherBot() {
const { wxPusherBotAppToken, wxPusherBotTopicIds, wxPusherBotUids } = this.params;
const { wxPusherBotAppToken, wxPusherBotTopicIds, wxPusherBotUids } =
this.params;
// 处理 topicIds,将分号分隔的字符串转为数组
const topicIds = wxPusherBotTopicIds ? wxPusherBotTopicIds.split(';')
.map(id => id.trim())
.filter(id => id)
.map(id => parseInt(id)) : [];
const topicIds = wxPusherBotTopicIds
? wxPusherBotTopicIds
.split(';')
.map((id) => id.trim())
.filter((id) => id)
.map((id) => parseInt(id))
: [];
// 处理 uids,将分号分隔的字符串转为数组
const uids = wxPusherBotUids ? wxPusherBotUids.split(';')
.map(uid => uid.trim())
.filter(uid => uid) : [];
const uids = wxPusherBotUids
? wxPusherBotUids
.split(';')
.map((uid) => uid.trim())
.filter((uid) => uid)
: [];
// topic_ids 和 uids 至少要有一个
if (!topicIds.length && !uids.length) {
@@ -727,7 +720,7 @@ export default class NotificationService {
contentType: 2,
topicIds: topicIds,
uids: uids,
verifyPayType: 0
verifyPayType: 0,
},
})
.json();
@@ -742,7 +735,6 @@ export default class NotificationService {
}
}
private async chronocat() {
const { chronocatURL, chronocatQQ, chronocatToken } = this.params;
try {
@@ -809,7 +801,7 @@ export default class NotificationService {
webhookContentType,
} = this.params;
if (!webhookUrl.includes('$title') && !webhookBody.includes('$title')) {
if (!webhookUrl?.includes('$title') && !webhookBody?.includes('$title')) {
throw new Error('Url 或者 Body 中必须包含 $title');
}
+8 -9
View File
@@ -159,16 +159,15 @@ export default class OpenService {
value: string;
expiration: number;
}> {
let systemApp = (
await AppModel.findOne({
where: { name: 'system' },
})
)?.get({ plain: true });
const apps = await shareStore.getApps();
const systemApp = apps?.find((x) => x.name === 'system');
if (!systemApp) {
systemApp = await this.create({
name: 'system',
scopes: ['crons', 'system'],
} as App);
throw new Error('system app not found');
}
const now = Math.round(Date.now() / 1000);
const currentToken = systemApp.tokens?.find((x) => x.expiration > now);
if (currentToken) {
return currentToken;
}
const { data } = await this.authToken({
client_id: systemApp.client_id,
+23 -2
View File
@@ -16,6 +16,7 @@ import {
promiseExec,
readDirs,
rmPath,
setSystemTimezone,
} from '../config/util';
import {
DependenceModel,
@@ -50,7 +51,10 @@ export default class SystemService {
public async getSystemConfig() {
const doc = await this.getDb({ type: AuthDataType.systemConfig });
return doc;
return {
...doc,
info: { ...doc.info, timezone: doc.info?.timezone || 'Asia/Shanghai' },
};
}
private async updateAuthDb(payload: SystemInfo): Promise<SystemInfo> {
@@ -440,7 +444,7 @@ export default class SystemService {
const logs = result
.reverse()
.filter((x) => x.title.endsWith('.log'))
.filter((x) => x.mtime >= startTime && x.mtime <= endTime);
.filter((x) => x.createTime >= startTime && x.createTime <= endTime);
res.set({
'Content-Length': sum(logs.map((x) => x.size)),
@@ -471,4 +475,21 @@ export default class SystemService {
await rmPath(path.join(config.systemLogPath, log.title));
}
}
public async updateTimezone(info: SystemModelInfo) {
if (!info.timezone) {
info.timezone = 'Asia/Shanghai';
}
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
const success = await setSystemTimezone(info.timezone);
if (success) {
return { code: 200, data: info };
} else {
return { code: 400, message: '设置时区失败' };
}
}
}
+3 -10
View File
@@ -1,15 +1,7 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import {
createFile,
createRandomString,
fileExist,
getNetIp,
getPlatform,
safeJSONParse,
} from '../config/util';
import { createRandomString, getNetIp } from '../config/util';
import config from '../config';
import * as fs from 'fs/promises';
import jwt from 'jsonwebtoken';
import { authenticator } from '@otplib/preset-default';
import {
@@ -355,11 +347,12 @@ export default class UserService {
}
public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated } = info;
const { retries, twoFactorActivated, password } = info;
const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, {
retries,
twoFactorActivated,
password,
});
}
}
+20 -3
View File
@@ -11,6 +11,9 @@ import {
IScheduleFn,
TCron,
} from './interface';
import config from '../config';
import { credentials } from '@grpc/grpc-js';
import { ApiClient } from '../protos/api';
class TaskLimit {
private dependenyLimit = new PQueue({ concurrency: 1 });
@@ -33,6 +36,11 @@ class TaskLimit {
private systemLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4),
});
private client = new ApiClient(
`0.0.0.0:${config.cronPort}`,
credentials.createInsecure(),
{ 'grpc.enable_http_proxy': 0 },
);
get cronLimitActiveCount() {
return this.cronLimit.pending;
@@ -126,9 +134,18 @@ class TaskLimit {
if (result?.length > 5) {
if (repeatTimes < 3) {
this.repeatCronNotifyMap.set(cron.id, repeatTimes + 1);
this.notificationService.externalNotify(
'任务重复运行',
`任务:${cron.name},命令:${cron.command},定时:${cron.schedule},处于运行中的超过 5 个,请检查定时设置`,
this.client.systemNotify(
{
title: '任务重复运行',
content: `任务:${cron.name},命令:${cron.command},定时:${cron.schedule},处于运行中的超过 5 个,请检查定时设置`,
},
(err, res) => {
if (err) {
Logger.error(
`[schedule][任务重复运行] 通知失败 ${JSON.stringify(err)}`,
);
}
},
);
}
Logger.warn(`[schedule][任务重复运行] 参数 ${JSON.stringify(cron)}`);
+21 -6
View File
@@ -1,27 +1,42 @@
import { lock } from 'proper-lockfile';
import { writeFile, open } from 'fs/promises';
import os from 'os';
import path from 'path';
import { writeFile, open, chmod } from 'fs/promises';
import { fileExist } from '../config/util';
function getUniqueLockPath(filePath: string) {
const sanitizedPath = filePath
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/^_/, '');
return path.join(os.tmpdir(), `${sanitizedPath}.ql_lock`);
}
export async function writeFileWithLock(
path: string,
filePath: string,
content: string | Buffer,
options: Parameters<typeof writeFile>[2] = {},
) {
if (typeof options === 'string') {
options = { encoding: options };
}
if (!(await fileExist(path))) {
const fileHandle = await open(path, 'w');
if (!(await fileExist(filePath))) {
const fileHandle = await open(filePath, 'w');
fileHandle.close();
}
const release = await lock(path, {
const lockfilePath = getUniqueLockPath(filePath);
const release = await lock(filePath, {
retries: {
retries: 10,
factor: 2,
minTimeout: 100,
maxTimeout: 3000,
},
lockfilePath,
});
await writeFile(path, content, { encoding: 'utf8', ...options });
await writeFile(filePath, content, { encoding: 'utf8', ...options });
if (options?.mode) {
await chmod(filePath, options.mode);
}
await release();
}
+40
View File
@@ -0,0 +1,40 @@
import { Joi } from 'celebrate';
import cron_parser from 'cron-parser';
import { ScheduleType } from '../interface/schedule';
const validateSchedule = (value: string, helpers: any) => {
if (
value.startsWith(ScheduleType.ONCE) ||
value.startsWith(ScheduleType.BOOT)
) {
return value;
}
try {
if (cron_parser.parseExpression(value).hasNext()) {
return value;
}
} catch (e) {
return helpers.error('any.invalid');
}
return helpers.error('any.invalid');
};
export const scheduleSchema = Joi.string()
.required()
.custom(validateSchedule)
.messages({
'any.invalid': '无效的定时规则',
'string.empty': '定时规则不能为空',
});
export const commonCronSchema = {
name: Joi.string().optional(),
command: Joi.string().required(),
schedule: scheduleSchema,
labels: Joi.array().optional(),
sub_id: Joi.number().optional().allow(null),
extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null),
};
+8 -10
View File
@@ -13,7 +13,8 @@
"schedule": "npm run build:back && node static/build/schedule/index.js",
"public": "npm run build:back && node static/build/public.js",
"update": "npm run build:back && node static/build/update.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",
"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",
@@ -61,8 +62,8 @@
},
"dependencies": {
"@grpc/grpc-js": "^1.12.3",
"@grpc/proto-loader": "^0.7.13",
"@otplib/preset-default": "^12.0.1",
"@sentry/node": "^8.42.0",
"body-parser": "^1.20.3",
"celebrate": "^15.0.3",
"chokidar": "^4.0.1",
@@ -71,7 +72,7 @@
"cross-spawn": "^7.0.6",
"dayjs": "^1.11.13",
"dotenv": "^16.4.6",
"express": "^4.21.1",
"express": "^4.21.2",
"express-jwt": "^8.4.1",
"express-rate-limit": "^7.4.1",
"express-urlrewrite": "^2.0.3",
@@ -84,11 +85,10 @@
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"multer": "1.4.5-lts.1",
"nedb": "^1.8.0",
"node-schedule": "^2.1.0",
"nodemailer": "^6.9.16",
"p-queue-cjs": "7.3.4",
"protobufjs": "^7.4.0",
"@bufbuild/protobuf": "^2.2.3",
"pstree.remy": "^1.1.8",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.5",
@@ -115,7 +115,6 @@
"@monaco-editor/react": "4.2.1",
"@react-hook/resize-observer": "^2.0.2",
"react-router-dom": "6.26.1",
"@sentry/react": "^8.42.0",
"@types/body-parser": "^1.19.2",
"@types/cors": "^2.8.12",
"@types/cross-spawn": "^6.0.2",
@@ -126,7 +125,6 @@
"@types/jsonwebtoken": "^8.5.8",
"@types/lodash": "^4.14.185",
"@types/multer": "^1.4.7",
"@types/nedb": "^1.8.12",
"@types/node": "^17.0.21",
"@types/node-schedule": "^1.3.2",
"@types/nodemailer": "^6.4.4",
@@ -142,11 +140,11 @@
"@types/proper-lockfile": "^4.1.4",
"@uiw/codemirror-extensions-langs": "^4.21.9",
"@uiw/react-codemirror": "^4.21.9",
"@umijs/max": "^4.3.36",
"@umijs/max": "^4.4.4",
"@umijs/ssr-darkreader": "^4.9.45",
"ahooks": "^3.7.8",
"ansi-to-react": "^6.1.6",
"antd": "^4.24.8",
"antd": "^4.24.16",
"antd-img-crop": "^4.23.0",
"axios": "^1.4.0",
"compression-webpack-plugin": "9.2.0",
@@ -173,7 +171,7 @@
"react-split-pane": "^0.1.92",
"sockjs-client": "^1.6.0",
"ts-node": "^10.9.2",
"ts-proto": "^1.146.0",
"ts-proto": "^2.6.1",
"tslib": "^2.4.0",
"typescript": "5.2.2",
"vh-check": "^2.0.5",
+165 -930
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -428,7 +428,7 @@ function tgBotNotify(text, desp) {
TG_PROXY_AUTH,
} = push_config;
if (TG_BOT_TOKEN && TG_USER_ID) {
const options = {
let options = {
url: `${TG_API_HOST}/bot${TG_BOT_TOKEN}/sendMessage`,
json: {
chat_id: `${TG_USER_ID}`,
@@ -442,20 +442,20 @@ function tgBotNotify(text, desp) {
};
if (TG_PROXY_HOST && TG_PROXY_PORT) {
const { HttpProxyAgent, HttpsProxyAgent } = require('hpagent');
const options = {
const _options = {
keepAlive: true,
keepAliveMsecs: 1000,
maxSockets: 256,
maxFreeSockets: 256,
proxy: `http://${TG_PROXY_AUTH}${TG_PROXY_HOST}:${TG_PROXY_PORT}`,
};
const httpAgent = new HttpProxyAgent(options);
const httpsAgent = new HttpsProxyAgent(options);
const httpAgent = new HttpProxyAgent(_options);
const httpsAgent = new HttpsProxyAgent(_options);
const agent = {
http: httpAgent,
https: httpsAgent,
};
Object.assign(options, { agent });
options.agent = agent;
}
$.post(options, (err, resp, data) => {
try {
+6
View File
@@ -6,4 +6,10 @@
*/
console.log('test scripts');
QLAPI.notify('test scripts', 'test desc');
QLAPI.getEnvs({ searchValue: 'dddd' }).then((x) => {
console.log('getEnvs', x);
});
QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => {
console.log('systemNotify', x);
});
console.log('test desc');
+6 -1
View File
@@ -4,6 +4,11 @@ name: script name
定时规则
cron: 1 9 * * *
"""
print("test script")
QLAPI.notify('test script', 'test desc')
print(QLAPI.notify("test script", "test desc"))
print("test systemNotify")
print(QLAPI.systemNotify({"title": "test script", "content": "dddd"}))
print("test getEnvs")
print(QLAPI.getEnvs({"searchValue": "1"}))
print("test desc")
+4 -1
View File
@@ -178,7 +178,10 @@ update_cron() {
code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message)
if [[ $code != 200 ]]; then
echo -e "\n## 更新任务状态失败(${message})\n"
if [[ ! $message ]]; then
message="$api"
fi
echo -e "${message}"
fi
}
+154
View File
@@ -0,0 +1,154 @@
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const { join } = require('path');
class GrpcClient {
static #config = {
protoPath: join(process.env.QL_DIR, 'back/protos/api.proto'),
serverAddress: '0.0.0.0:5500',
protoOptions: {
keepCase: true,
longs: String,
enums: String,
defaults: true,
},
grpcOptions: {
'grpc.enable_http_proxy': 0,
'grpc.keepalive_time_ms': 120000,
'grpc.keepalive_timeout_ms': 20000,
'grpc.max_receive_message_length': 100 * 1024 * 1024,
},
defaultTimeout: 30000,
};
static #methods = [
'getEnvs',
'createEnv',
'updateEnv',
'deleteEnvs',
'moveEnv',
'disableEnvs',
'enableEnvs',
'updateEnvNames',
'getEnvById',
'systemNotify',
'getCronDetail',
'createCron',
'updateCron',
'deleteCrons',
];
#client;
#api = {};
constructor() {
this.#initializeClient();
this.#bindMethods();
}
#initializeClient() {
try {
const { protoPath, protoOptions, serverAddress, grpcOptions } =
GrpcClient.#config;
const packageDefinition = protoLoader.loadSync(protoPath, protoOptions);
const apiProto = grpc.loadPackageDefinition(packageDefinition).com.ql.api;
this.#client = new apiProto.Api(
serverAddress,
grpc.credentials.createInsecure(),
grpcOptions,
);
this.#checkConnection();
} catch (error) {
console.error('Failed to initialize gRPC client:', error);
process.exit(1);
}
}
#checkConnection() {
this.#client.waitForReady(Date.now() + 5000, (error) => {
if (error) {
console.error('gRPC client connection failed:', error);
process.exit(1);
}
});
}
#promisifyMethod(methodName) {
const capitalizedMethod =
methodName.charAt(0).toUpperCase() + methodName.slice(1);
const method = this.#client[capitalizedMethod].bind(this.#client);
return async (params = {}) => {
return new Promise((resolve, reject) => {
const metadata = new grpc.Metadata();
const deadline = new Date(
Date.now() + GrpcClient.#config.defaultTimeout,
);
method(params, metadata, { deadline }, (error, response) => {
if (error) {
return reject(error);
}
resolve(response);
});
});
};
}
#bindMethods() {
GrpcClient.#methods.forEach((method) => {
this.#api[method] = this.#promisifyMethod(method);
});
}
getApi() {
return {
...this.#api,
close: this.close.bind(this),
};
}
close() {
if (this.#client) {
this.#client.close();
this.#client = null;
}
}
}
const grpcClient = new GrpcClient();
process.on('SIGTERM', () => {
grpcClient.close();
process.exit(0);
});
process.on('SIGINT', () => {
grpcClient.close();
process.exit(0);
});
process.on('unhandledRejection', (reason, promise) => {
if (reason instanceof Error) {
if (reason.stack) {
const relevantStack = reason.stack
.split('\n')
.filter((line) => {
return (
!line.includes('node:internal') &&
!line.includes('node_modules/@grpc') &&
!line.includes('processTicksAndRejections')
);
})
.join('\n');
console.error(relevantStack);
}
} else {
console.error(reason);
}
});
module.exports = grpcClient.getApi();
+283
View File
@@ -0,0 +1,283 @@
import subprocess
import json
import tempfile
import os
from typing import Dict, List, TypedDict, Optional
from functools import wraps
def error_handler(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except TypeError as e:
if "missing" in str(e):
func_name = func.__name__
annotations = func.__annotations__
param_type = next(
(t for name, t in annotations.items() if name != "return"), None
)
if param_type and hasattr(param_type, "__annotations__"):
required_fields = {
k: v
for k, v in param_type.__annotations__.items()
if not getattr(param_type, "__total__", True)
or k in getattr(param_type, "__required_keys__", set())
}
fields_str = ", ".join(
f'"{k}": {v.__name__}' for k, v in required_fields.items()
)
raise Exception(
f"{func_name}() requires a dictionary with parameters: {{{fields_str}}}"
) from None
raise Exception(f"{str(e)}") from None
except Exception as e:
error_msg = str(e)
if "Error:" in error_msg:
error_msg = error_msg.split("Error:")[-1].split("\n")[0].strip()
raise Exception(f"{error_msg}") from None
return wrapper
class EnvItem(TypedDict, total=False):
id: Optional[int]
name: Optional[str]
value: Optional[str]
remarks: Optional[str]
status: Optional[int]
position: Optional[int]
class GetEnvsParams(TypedDict, total=False):
searchValue: str
class CreateEnvParams(TypedDict):
envs: List[EnvItem]
class UpdateEnvParams(TypedDict):
env: EnvItem
class DeleteEnvsParams(TypedDict):
ids: List[int]
class MoveEnvParams(TypedDict):
id: int
fromIndex: int
toIndex: int
class DisableEnvsParams(TypedDict):
ids: List[int]
class EnableEnvsParams(TypedDict):
ids: List[int]
class UpdateEnvNamesParams(TypedDict):
ids: List[int]
name: str
class GetEnvByIdParams(TypedDict):
id: int
class SystemNotifyParams(TypedDict):
title: str
content: str
class EnvsResponse(TypedDict):
code: int
data: List[EnvItem]
message: Optional[str]
class EnvResponse(TypedDict):
code: int
data: EnvItem
message: Optional[str]
class Response(TypedDict):
code: int
message: Optional[str]
class ExtraScheduleItem(TypedDict, total=False):
schedule: Optional[str]
class CronItem(TypedDict, total=False):
id: Optional[int]
command: Optional[str]
schedule: Optional[str]
name: Optional[str]
labels: List[str]
sub_id: Optional[int]
extra_schedules: List[ExtraScheduleItem]
task_before: Optional[str]
task_after: Optional[str]
status: Optional[int]
log_path: Optional[str]
pid: Optional[int]
last_running_time: Optional[int]
last_execution_time: Optional[int]
class CreateCronParams(TypedDict):
command: str
schedule: str
name: Optional[str]
labels: List[str]
sub_id: Optional[int]
extra_schedules: List[ExtraScheduleItem]
task_before: Optional[str]
task_after: Optional[str]
class UpdateCronParams(TypedDict):
id: int
command: str
schedule: str
name: Optional[str]
labels: List[str]
sub_id: Optional[int]
extra_schedules: List[ExtraScheduleItem]
task_before: Optional[str]
task_after: Optional[str]
class DeleteCronsParams(TypedDict):
ids: List[int]
class CronDetailParams(TypedDict):
log_path: str
class CronsResponse(TypedDict):
code: int
data: List[CronItem]
message: Optional[str]
class CronResponse(TypedDict):
code: int
data: CronItem
message: Optional[str]
class Client:
def __init__(self):
self.temp_dir = tempfile.mkdtemp(prefix="node_client_")
self.temp_script = os.path.join(self.temp_dir, "temp_script.js")
def __del__(self):
try:
if os.path.exists(self.temp_script):
os.remove(self.temp_script)
os.rmdir(self.temp_dir)
except Exception:
pass
@error_handler
def _execute_node(self, method: str, params: Dict = None) -> Dict:
node_code = f"""
const api = require('{os.getenv("QL_DIR")}/shell/preload/client.js');
(async () => {{
try {{
const result = await api.{method}({json.dumps(params) if params else ''});
console.log(JSON.stringify(result));
}} catch (error) {{
console.error(JSON.stringify({{
error: error.message,
stack: error.stack,
name: error.name
}}));
process.exit(1);
}}
}})();
"""
with open(self.temp_script, "w", encoding="utf-8") as f:
f.write(node_code)
result = subprocess.run(
["node", self.temp_script],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
error_data = json.loads(result.stderr)
raise Exception(
f"{error_data.get('name', 'Error')}: {error_data.get('stack')}"
)
return json.loads(result.stdout)
@error_handler
def getEnvs(self, params: GetEnvsParams = None) -> EnvsResponse:
return self._execute_node("getEnvs", params)
@error_handler
def createEnv(self, data: CreateEnvParams) -> EnvsResponse:
return self._execute_node("createEnv", data)
@error_handler
def updateEnv(self, data: UpdateEnvParams) -> EnvResponse:
return self._execute_node("updateEnv", data)
@error_handler
def deleteEnvs(self, data: DeleteEnvsParams) -> Response:
return self._execute_node("deleteEnvs", data)
@error_handler
def moveEnv(self, data: MoveEnvParams) -> EnvResponse:
return self._execute_node("moveEnv", data)
@error_handler
def disableEnvs(self, data: DisableEnvsParams) -> Response:
return self._execute_node("disableEnvs", data)
@error_handler
def enableEnvs(self, data: EnableEnvsParams) -> Response:
return self._execute_node("enableEnvs", data)
@error_handler
def updateEnvNames(self, data: UpdateEnvNamesParams) -> Response:
return self._execute_node("updateEnvNames", data)
@error_handler
def getEnvById(self, data: GetEnvByIdParams) -> EnvResponse:
return self._execute_node("getEnvById", data)
@error_handler
def systemNotify(self, data: SystemNotifyParams) -> Response:
return self._execute_node("systemNotify", data)
@error_handler
def getCronDetail(self, data: CronDetailParams) -> CronResponse:
return self._execute_node("getCronDetail", data)
@error_handler
def createCron(self, data: CreateCronParams) -> CronResponse:
return self._execute_node("createCron", data)
@error_handler
def updateCron(self, data: UpdateCronParams) -> CronResponse:
return self._execute_node("updateCron", data)
@error_handler
def deleteCrons(self, data: DeleteCronsParams) -> Response:
return self._execute_node("deleteCrons", data)
+3 -1
View File
@@ -1,4 +1,5 @@
const { execSync } = require('child_process');
const client = require('./client.js');
require(`./env.js`);
function expandRange(rangeStr, max) {
@@ -97,9 +98,10 @@ try {
run();
const { sendNotify } = require('./notify.js');
const { sendNotify } = require('./__ql_notify__.js');
global.QLAPI = {
notify: sendNotify,
...client,
};
} catch (error) {
console.log(`run builtin code error: `, error, '\n');
+3 -2
View File
@@ -6,6 +6,7 @@ import builtins
import sys
import env
import signal
from client import Client
def try_parse_int(value):
@@ -106,9 +107,9 @@ try:
run()
from notify import send
from __ql_notify__ import send
class BaseApi:
class BaseApi(Client):
def notify(self, *args, **kwargs):
return send(*args, **kwargs)
+16 -5
View File
@@ -438,8 +438,14 @@ clear_env() {
}
handle_task_start() {
[[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp"
echo -e "## 开始执行... $begin_time\n"
local error_message=""
if [[ $ID ]]; then
local error=$(update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp")
if [[ $error ]]; then
error_message=", 任务状态更新失败(${error})"
fi
fi
echo -e "## 开始执行... ${begin_time}${error_message}\n"
}
run_task_before() {
@@ -468,12 +474,17 @@ handle_task_end() {
local end_timestamp=$(format_timestamp "$time_format" "$etime")
local diff_time=$(($end_timestamp - $begin_timestamp))
local suffix=""
[[ "$MANUAL" == "true" ]] && suffix="(手动停止)"
[[ "${MANUAL:=}" == "true" ]] && suffix="(手动停止)"
[[ "$diff_time" == 0 ]] && diff_time=1
echo -e "\n## 执行结束$suffix... $end_time 耗时 $diff_time 秒     "
[[ $ID ]] && update_cron "\"$ID\"" "1" "" "$log_path" "$begin_timestamp" "$diff_time"
if [[ $ID ]]; then
local error=$(update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time")
if [[ $error ]]; then
error_message=", 任务状态更新失败(${error})"
fi
fi
echo -e "\n## 执行结束$suffix... $end_time 耗时 $diff_time${error_message:=}     "
}
init_env
+6 -2
View File
@@ -489,7 +489,6 @@ main() {
local time_format="%Y-%m-%d %H:%M:%S"
local time=$(date "+$time_format")
local begin_timestamp=$(format_timestamp "$time_format" "$time")
[[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp"
local begin_time=$(format_time "$time_format" "$time")
@@ -497,6 +496,8 @@ main() {
eval echo -e "\#\# 开始执行... $begin_time\\\n" $cmd
fi
[[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp"
case $p1 in
update)
fix_config
@@ -542,6 +543,9 @@ main() {
resettfa)
eval update_auth_config "\\\"twoFactorActivated\\\":false" "禁用两步验证" $cmd
;;
resetpwd)
eval update_auth_config "\\\"password\\\":\\\"$p2\\\"" "重置密码" $cmd
;;
*)
eval echo -e "命令输入错误...\\\n" $cmd
eval usage $cmd
@@ -552,7 +556,7 @@ main() {
local end_time=$(format_time "$time_format" "$etime")
local end_timestamp=$(format_timestamp "$time_format" "$etime")
local diff_time=$(($end_timestamp - $begin_timestamp))
[[ $ID ]] && update_cron "\"$ID\"" "1" "" "$log_path" "$begin_timestamp" "$diff_time"
[[ $ID ]] && update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time"
if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then
eval echo -e "\\\n\#\# 执行结束... $end_time 耗时 $diff_time 秒     " $cmd
+25 -39
View File
@@ -1,35 +1,21 @@
import intl from 'react-intl-universal';
import React, { useEffect, useState } from 'react';
import ProLayout, { PageLoading } from '@ant-design/pro-layout';
import * as DarkReader from '@umijs/ssr-darkreader';
import defaultProps from './defaultProps';
import { Link, history, Outlet, useLocation } from '@umijs/max';
import config from '@/utils/config';
import { useCtx, useTheme } from '@/utils/hooks';
import { request } from '@/utils/http';
import {
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
UserOutlined,
} from '@ant-design/icons';
import config from '@/utils/config';
import { request } from '@/utils/http';
import './index.less';
import ProLayout, { PageLoading } from '@ant-design/pro-layout';
import { history, Link, Outlet, useLocation } from '@umijs/max';
import * as DarkReader from '@umijs/ssr-darkreader';
import { Avatar, Badge, Dropdown, Image, MenuProps, Tooltip } from 'antd';
import React, { useEffect, useState } from 'react';
import intl from 'react-intl-universal';
import vhCheck from 'vh-check';
import { useCtx, useTheme } from '@/utils/hooks';
import {
message,
Badge,
Modal,
Avatar,
Dropdown,
Menu,
Image,
Popover,
Descriptions,
Tooltip,
MenuProps,
} from 'antd';
// @ts-ignore
import * as Sentry from '@sentry/react';
import defaultProps from './defaultProps';
import './index.less';
import { init } from '../utils/init';
import WebSocketManager from '../utils/websocket';
@@ -178,9 +164,9 @@ export default function () {
useEffect(() => {
if (!user || !user.username) return;
const ws = WebSocketManager.getInstance(
`${window.location.origin}${config.apiPrefix}ws?token=${localStorage.getItem(
config.authKey,
)}`,
`${window.location.origin}${
config.apiPrefix
}ws?token=${localStorage.getItem(config.authKey)}`,
);
return () => {
@@ -201,9 +187,6 @@ export default function () {
console.log(
`从开始至load总耗时: ${timing.loadEventEnd - timing.navigationStart}`,
);
Sentry.captureMessage(
`白屏时间 ${timing.responseStart - timing.navigationStart}`,
);
};
}, []);
@@ -254,18 +237,15 @@ export default function () {
<ProLayout
selectedKeys={[location.pathname]}
loading={loading}
ErrorBoundary={Sentry.ErrorBoundary}
logo={
<>
<Image preview={false} src="https://qn.whyour.cn/logo.png" />
<div className="title">
<span className="title">{intl.get('青龙')}</span>
<a
href={systemInfo?.changeLogLink}
target="_blank"
rel="noopener noreferrer"
<span
onClick={(e) => {
e.stopPropagation();
window.open(systemInfo?.changeLogLink, '_blank');
}}
>
<Tooltip
@@ -289,7 +269,7 @@ export default function () {
</span>
</Badge>
</Tooltip>
</a>
</span>
</div>
</>
}
@@ -320,7 +300,9 @@ export default function () {
shape="square"
size="small"
icon={<UserOutlined />}
src={user.avatar ? `${config.apiPrefix}static/${user.avatar}` : ''}
src={
user.avatar ? `${config.apiPrefix}static/${user.avatar}` : ''
}
/>
<span style={{ marginLeft: 5 }}>{user.username}</span>
</span>
@@ -342,7 +324,11 @@ export default function () {
shape="square"
size="small"
icon={<UserOutlined />}
src={user.avatar ? `${config.apiPrefix}static/${user.avatar}` : ''}
src={
user.avatar
? `${config.apiPrefix}static/${user.avatar}`
: ''
}
/>
<span style={{ marginLeft: 5 }}>{user.username}</span>
</span>
+8 -3
View File
@@ -186,6 +186,7 @@
"秒后重试": "Retry after seconds",
"在您的设备上打开两步验证应用程序以查看您的身份验证代码并验证您的身份。": "Open the two-factor authentication application on your device to view your authentication code and verify your identity.",
"请选择脚本文件": "Please select a script file",
"当前文件不支持预览": "The current file does not support preview",
"清空日志": "Clear Logs",
"设置": "Settings",
"退出": "Exit",
@@ -344,7 +345,7 @@
"gotify的url地址,例如 https://push.example.de:8080": "gotify URL address, e.g., https://push.example.de:8080",
"gotify的消息应用token码": "gotify message application token code",
"推送消息的优先级": "Priority of Push Messages",
"chat的url地址": "Chat URL address",
"synologyChat的url地址": "Synology Chat Webhook URL address",
"chat的token码": "Chat token code",
"推送到个人QQ: http://127.0.0.1/send_private_msg,群:http://127.0.0.1/send_group_msg": "Push to personal QQ: http://127.0.0.1/send_private_msg, group: http://127.0.0.1/send_group_msg",
"访问密钥": "Access Key",
@@ -393,7 +394,7 @@
"SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "The SMTP login password may also be a special passphrase, depending on the specific email service provider's instructions",
"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的url地址,例如 https://ntfy.sh": "The URL address of ntfy, for example, https://ntfy.sh.",
"ntfy的消息应用topic": "The topic for ntfy's messaging application.",
"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",
@@ -496,5 +497,9 @@
"NPM 镜像源": "NPM Mirror Source",
"PyPI 镜像源": "PyPI Mirror Source",
"alpine linux 镜像源": "Alpine Linux Mirror Source",
"如果恢复失败,可进入容器执行": "If recovery fails, you can enter the container and execute"
"如果恢复失败,可进入容器执行": "If recovery fails, you can enter the container and execute",
"常规定时": "Normal Timing",
"手动运行": "Manual Run",
"开机运行": "Boot Run",
"时区": "Timezone"
}
+8 -2
View File
@@ -186,6 +186,7 @@
"秒后重试": "秒后重试",
"在您的设备上打开两步验证应用程序以查看您的身份验证代码并验证您的身份。": "在您的设备上打开两步验证应用程序以查看您的身份验证代码并验证您的身份。",
"请选择脚本文件": "请选择脚本文件",
"当前文件不支持预览": "当前文件不支持预览",
"清空日志": "清空日志",
"设置": "设置",
"退出": "退出",
@@ -344,7 +345,7 @@
"gotify的url地址,例如 https://push.example.de:8080": "gotify的url地址,例如 https://push.example.de:8080",
"gotify的消息应用token码": "gotify的消息应用token码",
"推送消息的优先级": "推送消息的优先级",
"chat的url地址": "chat的url地址",
"synologyChat的url地址": "synologyChat的webhook url地址",
"chat的token码": "chat的token码",
"推送到个人QQ: http://127.0.0.1/send_private_msg,群:http://127.0.0.1/send_group_msg": "推送到个人QQ: http://127.0.0.1/send_private_msg,群:http://127.0.0.1/send_group_msg",
"访问密钥": "访问密钥",
@@ -496,5 +497,10 @@
"NPM 镜像源": "NPM 镜像源",
"PyPI 镜像源": "PyPI 镜像源",
"alpine linux 镜像源": "alpine linux 镜像源",
"如果恢复失败,可进入容器执行": "如果恢复失败,可进入容器执行"
"如果恢复失败,可进入容器执行": "如果恢复失败,可进入容器执行",
"常规定时": "常规定时",
"手动运行": "手动运行",
"开机运行": "开机运行",
"时区": "时区"
}
+13
View File
@@ -0,0 +1,13 @@
import { ScheduleType } from './type';
export const scheduleTypeMap = {
[ScheduleType.Normal]: '',
[ScheduleType.Once]: '@once',
[ScheduleType.Boot]: '@boot',
};
export const getScheduleType = (schedule?: string): ScheduleType => {
if (schedule?.startsWith('@once')) return ScheduleType.Once;
if (schedule?.startsWith('@boot')) return ScheduleType.Boot;
return ScheduleType.Normal;
};
+63 -54
View File
@@ -1,64 +1,64 @@
import intl from 'react-intl-universal';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import useTableScrollHeight from '@/hooks/useTableScrollHeight';
import { SharedContext } from '@/layouts';
import { getCommandScript, getCrontabsNextDate } from '@/utils';
import config from '@/utils/config';
import { diffTime } from '@/utils/date';
import { request } from '@/utils/http';
import {
CheckCircleOutlined,
CheckOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
CopyOutlined,
DeleteOutlined,
DownOutlined,
EditOutlined,
EllipsisOutlined,
FieldTimeOutlined,
Loading3QuartersOutlined,
PlusOutlined,
PushpinOutlined,
SettingOutlined,
StopOutlined,
UnorderedListOutlined
} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout';
import { history, useOutletContext } from '@umijs/max';
import {
Button,
Dropdown,
Input,
MenuProps,
message,
Modal,
Table,
Tag,
Space,
Tooltip,
Dropdown,
Menu,
Typography,
Input,
Popover,
Tabs,
Table,
TablePaginationConfig,
MenuProps,
Tabs,
Tag,
Typography
} from 'antd';
import {
ClockCircleOutlined,
Loading3QuartersOutlined,
CloseCircleOutlined,
FileTextOutlined,
EllipsisOutlined,
PlayCircleOutlined,
CheckCircleOutlined,
EditOutlined,
StopOutlined,
DeleteOutlined,
PauseCircleOutlined,
FieldTimeOutlined,
PushpinOutlined,
DownOutlined,
SettingOutlined,
PlusOutlined,
UnorderedListOutlined,
CheckOutlined,
CopyOutlined,
} from '@ant-design/icons';
import config from '@/utils/config';
import { PageContainer } from '@ant-design/pro-layout';
import { request } from '@/utils/http';
import CronModal, { CronLabelModal } from './modal';
import CronLogModal from './logModal';
import CronDetailModal from './detail';
import { diffTime } from '@/utils/date';
import { history, useOutletContext } from '@umijs/max';
import './index.less';
import ViewCreateModal from './viewCreateModal';
import ViewManageModal from './viewManageModal';
import { FilterValue, SorterResult } from 'antd/lib/table/interface';
import { SharedContext } from '@/layouts';
import useTableScrollHeight from '@/hooks/useTableScrollHeight';
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 { FilterValue, SorterResult } from 'antd/lib/table/interface';
import dayjs from 'dayjs';
import { noop, omit } from 'lodash';
import React, { useEffect, useRef, useState } from 'react';
import intl from 'react-intl-universal';
import { useVT } from 'virtualizedtableforantd4';
import { getScheduleType } from './const';
import CronDetailModal from './detail';
import './index.less';
import CronLogModal from './logModal';
import CronModal, { CronLabelModal } from './modal';
import {
CrontabStatus,
ICrontab,
OperationName,
OperationPath,
ScheduleType,
} from './type';
import ViewCreateModal from './viewCreateModal';
import ViewManageModal from './viewManageModal';
const { Text, Paragraph, Link } = Typography;
const { Search } = Input;
@@ -263,7 +263,9 @@ const Crontab = () => {
},
},
render: (text, record) => {
return dayjs(record.nextRunTime).format('YYYY-MM-DD HH:mm:ss');
return record.nextRunTime
? dayjs(record.nextRunTime).format('YYYY-MM-DD HH:mm:ss')
: '-';
},
},
{
@@ -396,9 +398,14 @@ const Crontab = () => {
setValue(
data.map((x) => {
const scheduleType = getScheduleType(x.schedule);
const nextRunTime =
scheduleType === ScheduleType.Normal
? getCrontabsNextDate(x.schedule, x.extra_schedules)
: null;
return {
...x,
nextRunTime: getCrontabsNextDate(x.schedule, x.extra_schedules),
nextRunTime,
subscription: subscriptionMap?.[x.sub_id],
};
}),
@@ -806,7 +813,9 @@ const Crontab = () => {
useEffect(() => {
if (viewConf && enabledCronViews && enabledCronViews.length > 0) {
const view = enabledCronViews.slice(SHOW_TAB_COUNT).find((x) => x.id === viewConf.id);
const view = enabledCronViews
.slice(SHOW_TAB_COUNT)
.find((x) => x.id === viewConf.id);
setMoreMenuActive(!!view);
}
}, [viewConf, enabledCronViews]);
+103 -62
View File
@@ -1,11 +1,13 @@
import intl from 'react-intl-universal';
import React, { useEffect, useState } from 'react';
import { Modal, message, Input, Form, Button, Space } from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import cronParse from 'cron-parser';
import EditableTagGroup from '@/components/tag';
import config from '@/utils/config';
import { request } from '@/utils/http';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Form, Input, Modal, Select, Space, message } from 'antd';
import cronParse from 'cron-parser';
import { useEffect, useState } from 'react';
import intl from 'react-intl-universal';
import { getScheduleType, scheduleTypeMap } from './const';
import { ScheduleType } from './type';
const CronModal = ({
cron,
@@ -18,15 +20,26 @@ const CronModal = ({
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [scheduleType, setScheduleType] = useState<ScheduleType>(
cron ? getScheduleType(cron.schedule) : ScheduleType.Normal,
);
const handleOk = async (values: any) => {
setLoading(true);
try {
const method = cron?.id ? 'put' : 'post';
const payload = { ...values };
const payload = {
...values,
schedule:
scheduleType !== ScheduleType.Normal
? scheduleTypeMap[scheduleType]
: values.schedule,
};
if (cron?.id) {
payload.id = cron.id;
}
try {
const { code, data } = await request[method](
`${config.apiPrefix}crons`,
payload,
@@ -38,74 +51,57 @@ const CronModal = ({
);
handleCancel(data);
}
setLoading(false);
} catch (error: any) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
form.resetFields();
setScheduleType(getScheduleType(cron?.schedule));
}, [cron, visible]);
const handleScheduleTypeChange = (type: ScheduleType) => {
setScheduleType(type);
form.setFieldValue('schedule', '');
};
const renderScheduleOptions = () => (
<Select
defaultValue={scheduleType}
value={scheduleType}
onChange={handleScheduleTypeChange}
>
<Select.Option value={ScheduleType.Normal}>
{intl.get('常规定时')}
</Select.Option>
<Select.Option value={ScheduleType.Once}>
{intl.get('手动运行')}
</Select.Option>
<Select.Option value={ScheduleType.Boot}>
{intl.get('开机运行')}
</Select.Option>
</Select>
);
const renderScheduleFields = () => {
if (scheduleType !== ScheduleType.Normal) return null;
return (
<Modal
title={cron?.id ? intl.get('编辑任务') : intl.get('创建任务')}
open={visible}
forceRender
centered
maskClosable={false}
onOk={() => {
form
.validateFields()
.then((values) => {
handleOk(values);
})
.catch((info) => {
console.log('Validate Failed:', info);
});
}}
onCancel={() => handleCancel()}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
name="form_in_modal"
initialValues={cron}
>
<Form.Item
name="name"
label={intl.get('名称')}
rules={[{ required: true, whitespace: true }]}
>
<Input placeholder={intl.get('请输入任务名称')} />
</Form.Item>
<Form.Item
name="command"
label={intl.get('命令/脚本')}
rules={[{ required: true, whitespace: true }]}
>
<Input.TextArea
rows={4}
autoSize={{ minRows: 1, maxRows: 5 }}
placeholder={intl.get(
'支持输入脚本路径/任意系统可执行命令/task 脚本路径',
)}
/>
</Form.Item>
<>
<Form.Item
name="schedule"
label={intl.get('定时规则')}
rules={[
{ required: true },
{
validator: (rule, value) => {
validator: (_, value) => {
if (!value || cronParse.parseExpression(value).hasNext()) {
return Promise.resolve();
} else {
return Promise.reject(intl.get('Cron表达式格式有误'));
}
return Promise.reject(intl.get('Cron表达式格式有误'));
},
},
]}
@@ -136,14 +132,58 @@ const CronModal = ({
))}
<Form.Item>
<a onClick={() => add({ schedule: '' })}>
<PlusOutlined />
{intl.get('新增定时规则')}
<PlusOutlined /> {intl.get('新增定时规则')}
</a>
</Form.Item>
<Form.ErrorList errors={errors} />
</>
)}
</Form.List>
</>
);
};
return (
<Modal
title={cron?.id ? intl.get('编辑任务') : intl.get('创建任务')}
open={visible}
forceRender
centered
maskClosable={false}
onOk={() => form.validateFields().then(handleOk)}
onCancel={() => handleCancel()}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
name="form_in_modal"
initialValues={cron}
>
<Form.Item
name="name"
label={intl.get('名称')}
rules={[{ required: true, whitespace: true }]}
>
<Input placeholder={intl.get('请输入任务名称')} />
</Form.Item>
<Form.Item
name="command"
label={intl.get('命令/脚本')}
rules={[{ required: true, whitespace: true }]}
>
<Input.TextArea
rows={4}
autoSize={{ minRows: 1, maxRows: 5 }}
placeholder={intl.get(
'支持输入脚本路径/任意系统可执行命令/task 脚本路径',
)}
/>
</Form.Item>
<Form.Item label={intl.get('定时类型')} required>
{renderScheduleOptions()}
</Form.Item>
{renderScheduleFields()}
<Form.Item name="labels" label={intl.get('标签')}>
<EditableTagGroup />
</Form.Item>
@@ -155,7 +195,7 @@ const CronModal = ({
)}
rules={[
{
validator(rule, value) {
validator(_, value) {
if (
value &&
(value.includes(' task ') || value.startsWith('task '))
@@ -183,7 +223,7 @@ const CronModal = ({
)}
rules={[
{
validator(rule, value) {
validator(_, value) {
if (
value &&
(value.includes(' task ') || value.startsWith('task '))
@@ -284,4 +324,5 @@ const CronLabelModal = ({
);
};
export { CronModal as default, CronLabelModal };
export { CronLabelModal, CronModal as default };
+7 -1
View File
@@ -36,5 +36,11 @@ export interface ICrontab {
last_execution_time?: number;
nextRunTime: Date;
sub_id: number;
extra_schedules?: Array<{ schedule: string; }>;
extra_schedules?: Array<{ schedule: string }>;
}
export enum ScheduleType {
Normal = 'normal',
Once = 'once',
Boot = 'boot',
}
+24 -9
View File
@@ -46,6 +46,7 @@ import RenameModal from './renameModal';
import { langs } from '@uiw/codemirror-extensions-langs';
import { useHotkeys } from 'react-hotkeys-hook';
import prettyBytes from 'pretty-bytes';
import { canPreviewInMonaco } from '@/utils/monaco';
const { Text } = Typography;
const Script = () => {
@@ -67,6 +68,10 @@ const Script = () => {
const [currentNode, setCurrentNode] = useState<any>();
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
const handleIsEditing = (filename: string, value: boolean) => {
setIsEditing(value && canPreviewInMonaco(filename));
};
const getScripts = (needLoading: boolean = true) => {
needLoading && setLoading(true);
request
@@ -128,6 +133,11 @@ const Script = () => {
return;
}
if (!canPreviewInMonaco(node.title)) {
setValue(intl.get('当前文件不支持预览'));
return;
}
const newMode = getEditorMode(value);
setMode(isPhone && newMode === 'typescript' ? 'javascript' : newMode);
setValue(intl.get('加载中...'));
@@ -149,14 +159,14 @@ const Script = () => {
content: <>{intl.get('当前修改未保存,确定离开吗')}</>,
onOk() {
onSelect(keys[0], e.node);
setIsEditing(false);
handleIsEditing(e.node.title, false);
},
onCancel() {
console.log('Cancel');
},
});
} else {
setIsEditing(false);
handleIsEditing(e.node.title, false);
onSelect(keys[0], e.node);
}
},
@@ -196,18 +206,18 @@ const Script = () => {
if (node.type === 'file') {
setSelect(node.key);
setCurrentNode(node);
setIsEditing(true);
handleIsEditing(node.title, true);
}
};
const editFile = () => {
setTimeout(() => {
setIsEditing(true);
handleIsEditing(currentNode.title, true);
}, 300);
};
const cancelEdit = () => {
setIsEditing(false);
handleIsEditing(currentNode.title, false);
setValue(intl.get('加载中...'));
getDetail(currentNode);
};
@@ -240,7 +250,7 @@ const Script = () => {
if (code === 200) {
message.success(`保存成功`);
setValue(content);
setIsEditing(false);
handleIsEditing(currentNode.title, false);
}
resolve(null);
})
@@ -342,7 +352,7 @@ const Script = () => {
}
setData(newData);
onSelect(_file.title, _file);
setIsEditing(true);
handleIsEditing(_file.title, true);
}
setIsAddFileModalVisible(false);
};
@@ -472,7 +482,9 @@ const Script = () => {
label: intl.get('编辑'),
key: 'edit',
icon: <EditOutlined />,
disabled: !select,
disabled:
!select ||
(currentNode && !canPreviewInMonaco(currentNode?.title)),
},
{
label: intl.get('重命名'),
@@ -554,7 +566,10 @@ const Script = () => {
</Tooltip>,
<Tooltip title={intl.get('编辑')}>
<Button
disabled={!select}
disabled={
!select ||
(currentNode && !canPreviewInMonaco(currentNode?.title))
}
type="primary"
onClick={editFile}
icon={<EditOutlined />}
+10
View File
@@ -32,3 +32,13 @@
display: flex;
gap: 40px;
}
.ql-container-wrapper.ql-setting-container {
.ant-tabs-tabpane > div {
padding-left: 2px;
}
.ant-tabs-tabpane > .ant-form {
padding-left: 2px;
}
}
+31
View File
@@ -23,10 +23,12 @@ import Countdown from 'antd/lib/statistic/Countdown';
import useProgress from './progress';
import pick from 'lodash/pick';
import { disableBody } from '@/utils';
import { TIMEZONES } from '@/utils/const';
const dataMap = {
'log-remove-frequency': 'logRemoveFrequency',
'cron-concurrency': 'cronConcurrency',
timezone: 'timezone',
};
const Other = ({
@@ -37,6 +39,7 @@ const Other = ({
const [systemConfig, setSystemConfig] = useState<{
logRemoveFrequency?: number | null;
cronConcurrency?: number | null;
timezone?: string | null;
}>();
const [form] = Form.useForm();
const [exportLoading, setExportLoading] = useState(false);
@@ -252,6 +255,34 @@ const Other = ({
</Button>
</Input.Group>
</Form.Item>
<Form.Item label={intl.get('时区')} name="timezone">
<Input.Group compact>
<Select
value={systemConfig?.timezone}
style={{ width: 180 }}
onChange={(value) => {
setSystemConfig({ ...systemConfig, timezone: value });
}}
options={TIMEZONES.map((timezone) => ({
value: timezone,
label: timezone,
}))}
showSearch
filterOption={(input, option) =>
option?.value?.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
/>
<Button
type="primary"
onClick={() => {
updateSystemConfig('timezone');
}}
style={{ width: 84 }}
>
{intl.get('确认')}
</Button>
</Input.Group>
</Form.Item>
<Form.Item label={intl.get('语言')} name="lang">
<Select
defaultValue={localStorage.getItem('lang') || ''}
+18 -23
View File
@@ -135,11 +135,10 @@ export default {
],
chat: [
{
label: 'chatUrl',
tip: intl.get('chat的url地址'),
label: 'synologyChatUrl',
tip: intl.get('synologyChat的url地址'),
required: true,
},
{ label: 'chatToken', tip: intl.get('chat的token码'), required: true },
],
goCqHttpBot: [
{
@@ -329,33 +328,23 @@ export default {
},
{
label: 'pushplusTemplate',
tip: intl.get(
'发送模板',
),
tip: intl.get('发送模板'),
},
{
label: 'pushplusChannel',
tip: intl.get(
'发送渠道',
),
tip: intl.get('发送渠道'),
},
{
label: 'pushplusWebhook',
tip: intl.get(
'webhook编码',
),
tip: intl.get('webhook编码'),
},
{
label: 'pushplusCallbackUrl',
tip: intl.get(
'发送结果回调地址',
),
tip: intl.get('发送结果回调地址'),
},
{
label: 'pushplusTo',
tip: intl.get(
'好友令牌',
),
tip: intl.get('好友令牌'),
},
],
wePlusBot: [
@@ -368,9 +357,7 @@ export default {
},
{
label: 'wePlusBotReceiver',
tip: intl.get(
'消息接收人',
),
tip: intl.get('消息接收人'),
},
{
label: 'wePlusBotVersion',
@@ -414,7 +401,13 @@ export default {
required: true,
},
{ label: 'emailUser', tip: intl.get('邮箱地址'), required: true },
{ label: 'emailPass', tip: intl.get('SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定'), required: true },
{
label: 'emailPass',
tip: intl.get(
'SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定',
),
required: true,
},
],
pushMe: [
{
@@ -424,7 +417,9 @@ export default {
},
{
label: 'pushMeUrl',
tip: intl.get('自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口'),
tip: intl.get(
'自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口',
),
required: false,
},
],
+450 -1
View File
@@ -7,5 +7,454 @@ export const LANG_MAP = {
'.sh': 'shell',
'.ts': 'typescript',
'.ini': 'ini',
'.json': 'json'
'.json': 'json',
};
export const TIMEZONES = [
'UTC',
'Africa/Abidjan',
'Africa/Accra',
'Africa/Addis Ababa',
'Africa/Algiers',
'Africa/Asmara',
'Africa/Bamako',
'Africa/Bangui',
'Africa/Banjul',
'Africa/Bissau',
'Africa/Blantyre',
'Africa/Brazzaville',
'Africa/Bujumbura',
'Africa/Cairo',
'Africa/Casablanca',
'Africa/Ceuta',
'Africa/Conakry',
'Africa/Dakar',
'Africa/Dar es Salaam',
'Africa/Djibouti',
'Africa/Douala',
'Africa/El Aaiun',
'Africa/Freetown',
'Africa/Gaborone',
'Africa/Harare',
'Africa/Johannesburg',
'Africa/Juba',
'Africa/Kampala',
'Africa/Khartoum',
'Africa/Kigali',
'Africa/Kinshasa',
'Africa/Lagos',
'Africa/Libreville',
'Africa/Lome',
'Africa/Luanda',
'Africa/Lubumbashi',
'Africa/Lusaka',
'Africa/Malabo',
'Africa/Maputo',
'Africa/Maseru',
'Africa/Mbabane',
'Africa/Mogadishu',
'Africa/Monrovia',
'Africa/Nairobi',
'Africa/Ndjamena',
'Africa/Niamey',
'Africa/Nouakchott',
'Africa/Ouagadougou',
'Africa/Porto-Novo',
'Africa/Sao Tome',
'Africa/Tripoli',
'Africa/Tunis',
'Africa/Windhoek',
'America/Adak',
'America/Anchorage',
'America/Anguilla',
'America/Antigua',
'America/Araguaina',
'America/Argentina/Buenos Aires',
'America/Argentina/Catamarca',
'America/Argentina/Cordoba',
'America/Argentina/Jujuy',
'America/Argentina/La Rioja',
'America/Argentina/Mendoza',
'America/Argentina/Rio Gallegos',
'America/Argentina/Salta',
'America/Argentina/San Juan',
'America/Argentina/San Luis',
'America/Argentina/Tucuman',
'America/Argentina/Ushuaia',
'America/Aruba',
'America/Asuncion',
'America/Atikokan',
'America/Bahia',
'America/Bahia Banderas',
'America/Barbados',
'America/Belem',
'America/Belize',
'America/Blanc-Sablon',
'America/Boa Vista',
'America/Bogota',
'America/Boise',
'America/Cambridge Bay',
'America/Campo Grande',
'America/Cancun',
'America/Caracas',
'America/Cayenne',
'America/Cayman',
'America/Chicago',
'America/Chihuahua',
'America/Ciudad Juarez',
'America/Costa Rica',
'America/Creston',
'America/Cuiaba',
'America/Curacao',
'America/Danmarkshavn',
'America/Dawson',
'America/Dawson Creek',
'America/Denver',
'America/Detroit',
'America/Dominica',
'America/Edmonton',
'America/Eirunepe',
'America/El Salvador',
'America/Fort Nelson',
'America/Fortaleza',
'America/Glace Bay',
'America/Goose Bay',
'America/Grand Turk',
'America/Grenada',
'America/Guadeloupe',
'America/Guatemala',
'America/Guayaquil',
'America/Guyana',
'America/Halifax',
'America/Havana',
'America/Hermosillo',
'America/Indiana/Indianapolis',
'America/Indiana/Knox',
'America/Indiana/Marengo',
'America/Indiana/Petersburg',
'America/Indiana/Tell City',
'America/Indiana/Vevay',
'America/Indiana/Vincennes',
'America/Indiana/Winamac',
'America/Inuvik',
'America/Iqaluit',
'America/Jamaica',
'America/Juneau',
'America/Kentucky/Louisville',
'America/Kentucky/Monticello',
'America/Kralendijk',
'America/La Paz',
'America/Lima',
'America/Los Angeles',
'America/Lower Princes',
'America/Maceio',
'America/Managua',
'America/Manaus',
'America/Marigot',
'America/Martinique',
'America/Matamoros',
'America/Mazatlan',
'America/Menominee',
'America/Merida',
'America/Metlakatla',
'America/Mexico City',
'America/Miquelon',
'America/Moncton',
'America/Monterrey',
'America/Montevideo',
'America/Montserrat',
'America/Nassau',
'America/New York',
'America/Nome',
'America/Noronha',
'America/North Dakota/Beulah',
'America/North Dakota/Center',
'America/North Dakota/New Salem',
'America/Nuuk',
'America/Ojinaga',
'America/Panama',
'America/Paramaribo',
'America/Phoenix',
'America/Port of Spain',
'America/Port-au-Prince',
'America/Porto Velho',
'America/Puerto Rico',
'America/Punta Arenas',
'America/Rankin Inlet',
'America/Recife',
'America/Regina',
'America/Resolute',
'America/Rio Branco',
'America/Santarem',
'America/Santiago',
'America/Santo Domingo',
'America/Sao Paulo',
'America/Scoresbysund',
'America/Sitka',
'America/St Barthelemy',
'America/St Johns',
'America/St Kitts',
'America/St Lucia',
'America/St Thomas',
'America/St Vincent',
'America/Swift Current',
'America/Tegucigalpa',
'America/Thule',
'America/Tijuana',
'America/Toronto',
'America/Tortola',
'America/Vancouver',
'America/Whitehorse',
'America/Winnipeg',
'America/Yakutat',
'Antarctica/Casey',
'Antarctica/Davis',
'Antarctica/DumontDUrville',
'Antarctica/Macquarie',
'Antarctica/Mawson',
'Antarctica/McMurdo',
'Antarctica/Palmer',
'Antarctica/Rothera',
'Antarctica/Syowa',
'Antarctica/Troll',
'Antarctica/Vostok',
'Arctic/Longyearbyen',
'Asia/Aden',
'Asia/Almaty',
'Asia/Amman',
'Asia/Anadyr',
'Asia/Aqtau',
'Asia/Aqtobe',
'Asia/Ashgabat',
'Asia/Atyrau',
'Asia/Baghdad',
'Asia/Bahrain',
'Asia/Baku',
'Asia/Bangkok',
'Asia/Barnaul',
'Asia/Beirut',
'Asia/Bishkek',
'Asia/Brunei',
'Asia/Chita',
'Asia/Choibalsan',
'Asia/Colombo',
'Asia/Damascus',
'Asia/Dhaka',
'Asia/Dili',
'Asia/Dubai',
'Asia/Dushanbe',
'Asia/Famagusta',
'Asia/Gaza',
'Asia/Hebron',
'Asia/Ho Chi Minh',
'Asia/Hong Kong',
'Asia/Hovd',
'Asia/Irkutsk',
'Asia/Jakarta',
'Asia/Jayapura',
'Asia/Jerusalem',
'Asia/Kabul',
'Asia/Kamchatka',
'Asia/Karachi',
'Asia/Kathmandu',
'Asia/Khandyga',
'Asia/Kolkata',
'Asia/Krasnoyarsk',
'Asia/Kuala Lumpur',
'Asia/Kuching',
'Asia/Kuwait',
'Asia/Macau',
'Asia/Magadan',
'Asia/Makassar',
'Asia/Manila',
'Asia/Muscat',
'Asia/Nicosia',
'Asia/Novokuznetsk',
'Asia/Novosibirsk',
'Asia/Omsk',
'Asia/Oral',
'Asia/Phnom Penh',
'Asia/Pontianak',
'Asia/Pyongyang',
'Asia/Qatar',
'Asia/Qostanay',
'Asia/Qyzylorda',
'Asia/Riyadh',
'Asia/Sakhalin',
'Asia/Samarkand',
'Asia/Seoul',
'Asia/Shanghai',
'Asia/Singapore',
'Asia/Srednekolymsk',
'Asia/Taipei',
'Asia/Tashkent',
'Asia/Tbilisi',
'Asia/Tehran',
'Asia/Thimphu',
'Asia/Tokyo',
'Asia/Tomsk',
'Asia/Ulaanbaatar',
'Asia/Urumqi',
'Asia/Ust-Nera',
'Asia/Vientiane',
'Asia/Vladivostok',
'Asia/Yakutsk',
'Asia/Yangon',
'Asia/Yekaterinburg',
'Asia/Yerevan',
'Atlantic/Azores',
'Atlantic/Bermuda',
'Atlantic/Canary',
'Atlantic/Cape Verde',
'Atlantic/Faroe',
'Atlantic/Madeira',
'Atlantic/Reykjavik',
'Atlantic/South Georgia',
'Atlantic/St Helena',
'Atlantic/Stanley',
'Australia/Adelaide',
'Australia/Brisbane',
'Australia/Broken Hill',
'Australia/Darwin',
'Australia/Eucla',
'Australia/Hobart',
'Australia/Lindeman',
'Australia/Lord Howe',
'Australia/Melbourne',
'Australia/Perth',
'Australia/Sydney',
'Etc/GMT',
'Etc/GMT+1',
'Etc/GMT+10',
'Etc/GMT+11',
'Etc/GMT+12',
'Etc/GMT+2',
'Etc/GMT+3',
'Etc/GMT+4',
'Etc/GMT+5',
'Etc/GMT+6',
'Etc/GMT+7',
'Etc/GMT+8',
'Etc/GMT+9',
'Etc/GMT-1',
'Etc/GMT-10',
'Etc/GMT-11',
'Etc/GMT-12',
'Etc/GMT-13',
'Etc/GMT-14',
'Etc/GMT-2',
'Etc/GMT-3',
'Etc/GMT-4',
'Etc/GMT-5',
'Etc/GMT-6',
'Etc/GMT-7',
'Etc/GMT-8',
'Etc/GMT-9',
'Europe/Amsterdam',
'Europe/Andorra',
'Europe/Astrakhan',
'Europe/Athens',
'Europe/Belgrade',
'Europe/Berlin',
'Europe/Bratislava',
'Europe/Brussels',
'Europe/Bucharest',
'Europe/Budapest',
'Europe/Busingen',
'Europe/Chisinau',
'Europe/Copenhagen',
'Europe/Dublin',
'Europe/Gibraltar',
'Europe/Guernsey',
'Europe/Helsinki',
'Europe/Isle of Man',
'Europe/Istanbul',
'Europe/Jersey',
'Europe/Kaliningrad',
'Europe/Kirov',
'Europe/Kyiv',
'Europe/Lisbon',
'Europe/Ljubljana',
'Europe/London',
'Europe/Luxembourg',
'Europe/Madrid',
'Europe/Malta',
'Europe/Mariehamn',
'Europe/Minsk',
'Europe/Monaco',
'Europe/Moscow',
'Europe/Oslo',
'Europe/Paris',
'Europe/Podgorica',
'Europe/Prague',
'Europe/Riga',
'Europe/Rome',
'Europe/Samara',
'Europe/San Marino',
'Europe/Sarajevo',
'Europe/Saratov',
'Europe/Simferopol',
'Europe/Skopje',
'Europe/Sofia',
'Europe/Stockholm',
'Europe/Tallinn',
'Europe/Tirane',
'Europe/Ulyanovsk',
'Europe/Vaduz',
'Europe/Vatican',
'Europe/Vienna',
'Europe/Vilnius',
'Europe/Volgograd',
'Europe/Warsaw',
'Europe/Zagreb',
'Europe/Zurich',
'Indian/Antananarivo',
'Indian/Chagos',
'Indian/Christmas',
'Indian/Cocos',
'Indian/Comoro',
'Indian/Kerguelen',
'Indian/Mahe',
'Indian/Maldives',
'Indian/Mauritius',
'Indian/Mayotte',
'Indian/Reunion',
'Pacific/Apia',
'Pacific/Auckland',
'Pacific/Bougainville',
'Pacific/Chatham',
'Pacific/Chuuk',
'Pacific/Easter',
'Pacific/Efate',
'Pacific/Fakaofo',
'Pacific/Fiji',
'Pacific/Funafuti',
'Pacific/Galapagos',
'Pacific/Gambier',
'Pacific/Guadalcanal',
'Pacific/Guam',
'Pacific/Honolulu',
'Pacific/Kanton',
'Pacific/Kiritimati',
'Pacific/Kosrae',
'Pacific/Kwajalein',
'Pacific/Majuro',
'Pacific/Marquesas',
'Pacific/Midway',
'Pacific/Nauru',
'Pacific/Niue',
'Pacific/Norfolk',
'Pacific/Noumea',
'Pacific/Pago Pago',
'Pacific/Palau',
'Pacific/Pitcairn',
'Pacific/Pohnpei',
'Pacific/Port Moresby',
'Pacific/Rarotonga',
'Pacific/Saipan',
'Pacific/Tahiti',
'Pacific/Tarawa',
'Pacific/Tongatapu',
'Pacific/Wake',
'Pacific/Wallis',
];
+6 -4
View File
@@ -1,3 +1,5 @@
import Intl from 'react-intl-universal';
export function diffTime(num: number) {
const diff = num * 1000;
@@ -12,15 +14,15 @@ export function diffTime(num: number) {
const leave3 = leave2 % (60 * 1000);
const seconds = Math.round(leave3 / 1000);
let returnStr = seconds + '秒';
let returnStr = `${seconds} ${Intl.get('秒')}`;
if (minutes > 0) {
returnStr = minutes + '分' + returnStr;
returnStr = `${minutes} ${Intl.get('分')} ` + returnStr;
}
if (hours > 0) {
returnStr = hours + '时' + returnStr;
returnStr = `${hours} ${Intl.get('时')} ` + returnStr;
}
if (days > 0) {
returnStr = days + '天' + returnStr;
returnStr = `${days} ${Intl.get('天')} ` + returnStr;
}
return returnStr;
}
+24 -9
View File
@@ -1,5 +1,5 @@
import intl from 'react-intl-universal';
import { message } from 'antd';
import { message, notification } from 'antd';
import config from './config';
import { history } from '@umijs/max';
import axios, {
@@ -14,7 +14,7 @@ export interface IResponseData {
code?: number;
data?: any;
message?: string;
error?: any;
errors?: any[];
}
export type Override<
@@ -41,7 +41,7 @@ const errorHandler = function (
) {
if (error.response) {
const msg = error.response.data
? error.response.data.message || error.message || error.response.data
? error.response.data.message || error.message
: error.response.statusText;
const responseStatus = error.response.status;
if ([502, 504].includes(responseStatus)) {
@@ -57,9 +57,18 @@ const errorHandler = function (
return error.config?.onError(error.response);
}
message.error({
content: msg,
style: { maxWidth: 500, margin: '0 auto' },
msg &&
notification.error({
message: msg,
description: error.response?.data?.errors ? (
<>
{error.response?.data?.errors?.map((item: any) => (
<div>
{item.message} ({item.value})
</div>
))}
</>
) : undefined,
});
}
} else {
@@ -107,9 +116,15 @@ _request.interceptors.response.use(async (response) => {
if (res.code !== 200) {
const msg = res.message || res.data;
msg &&
message.error({
content: msg,
style: { maxWidth: 500, margin: '0 auto' },
notification.error({
message: msg,
description: res?.errors ? (
<>
{res?.errors.map((item: any) => (
<div>{item.message}</div>
))}
</>
) : undefined,
});
}
return res;
-35
View File
@@ -1,42 +1,7 @@
import * as Sentry from '@sentry/react';
import { loader } from '@monaco-editor/react';
import config from './config';
import { useEffect } from 'react';
import {
createRoutesFromChildren,
matchRoutes,
useLocation,
useNavigationType,
} from 'react-router-dom';
export function init(version: string) {
// sentry监控 init
Sentry.init({
dsn: 'https://49b9ad1a6201bfe027db296ab7c6d672@o1098464.ingest.sentry.io/6122818',
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
Sentry.replayIntegration(),
],
beforeBreadcrumb(breadcrumb) {
if (breadcrumb.data && breadcrumb.data.url) {
const url = breadcrumb.data.url.replace(/token=.*/, '');
breadcrumb.data.url = url;
}
return breadcrumb;
},
tracesSampleRate: 0.1,
tracePropagationTargets: [/^(?!\/api\/(ws|static)).*$/],
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 0.1,
release: version,
});
// monaco 编辑器配置cdn和locale
loader.config({
paths: {
+7
View File
@@ -0,0 +1,7 @@
import * as monaco from 'monaco-editor';
export function canPreviewInMonaco(fileName: string): boolean {
const supportedLanguages = monaco.languages.getLanguages();
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase();
return supportedLanguages.some((lang) => lang.extensions?.includes(ext));
}
+1 -1
View File
@@ -18,7 +18,7 @@
"noEmit": false,
"esModuleInterop": true
},
"include": ["./back/**/*"],
"include": ["./back/**/*", "./back.d.ts"],
"exclude": ["node_modules"],
"files": ["./back/index.d.ts"]
}
+9 -7
View File
@@ -1,8 +1,10 @@
version: 2.18.0
changeLogLink: https://t.me/jiao_long/424
publishTime: 2025-01-05 13:00
version: 2.18.2
changeLogLink: https://t.me/jiao_long/427
publishTime: 2025-02-28 00:00
changeLog: |
1. 由于安全问题,修改认证信息存储方式,不再使用 auth.json 存储
2. 修复初始化 SystemConfig 数据
3. 修改通知文件未设置时提示
4. 修复配置文件更新可能异常
1. 定时任务支持 开机运行@boot 和 手动运行@once 任务
2. 脚本管理增加可预览检查,避免无法预览文件被重复保存
3. 系统设置增加时区设置
4. 修复登录失败没有提示
5. 增加重置密码命令 ql resetpwd
6. 修复群晖通知参数,任务视图不属于筛选