Compare commits

..
78 Commits
Author SHA1 Message Date
whyour 5905c2ce25 更新版本 2.13.0 2022-05-28 00:08:48 +08:00
whyour b03ef0d494 移除sentry上报信息可能出现的token 2022-05-27 23:47:17 +08:00
whyour 585f77ed49 修改workflow 2022-05-26 15:12:45 +08:00
whyour 1c580ea2f9 修复切换订阅interval 2022-05-26 14:41:22 +08:00
whyour b1af6575b9 修改workflow 2022-05-26 00:25:31 +08:00
whyour b24c5fdee1 更新readme 2022-05-26 00:23:25 +08:00
whyour a256ada847 修复定时任务标签默认值 2022-05-26 00:18:04 +08:00
whyour a8cdab629d 修复安装依赖逻辑 2022-05-25 02:04:13 +08:00
whyour 09cca0a920 修改linux依赖安装命令 2022-05-25 01:23:33 +08:00
whyour 6a0aa6aae2 修改node依赖安装命令 2022-05-25 01:20:23 +08:00
whyour 565cc06c27 修改文本 2022-05-25 01:10:04 +08:00
whyour 29caa4b770 新建订阅支持粘贴导入 2022-05-25 01:09:05 +08:00
whyour 953cf4795a 修复日志获取 2022-05-24 10:00:42 +08:00
whyour 31bc8395aa 修复获取脚本列表 2022-05-23 19:22:20 +08:00
whyour eb94b39e40 修复主题切换 2022-05-23 12:03:01 +08:00
whyour c16bbe0f9a 修改订阅before/after执行时机 2022-05-22 22:14:32 +08:00
whyour b60caa60d5 修改新建订阅文件后缀提示 2022-05-22 21:35:02 +08:00
whyour 1ea97e9206 修复错误界面user判断 2022-05-22 21:28:30 +08:00
whyour 32970bca71 修复登录跳转 2022-05-22 18:07:48 +08:00
whyour 1fc9f87eaa 修改antd样式目录 2022-05-22 00:44:13 +08:00
whyour b2f5960097 使用ssr-darkreader代替darkreader 2022-05-21 21:19:44 +08:00
whyour 1cfcc66548 修复根组件刷新异常 2022-05-21 11:51:16 +08:00
whyour 60152a313f 修复spawn监听事件 2022-05-21 00:47:05 +08:00
whyour d50ff20ff8 修改新建订阅字段提示 2022-05-21 00:12:12 +08:00
whyour 3aed5ef955 订阅同步表字段 2022-05-20 11:18:40 +08:00
whyour 61e0e8dbe2 订阅增加before/after 2022-05-20 01:15:45 +08:00
whyour 1ba5d52ee7 修复登录成功获取用户信息 2022-05-19 23:40:19 +08:00
whyour 03bc5bb882 增加初始化订阅任务 2022-05-18 23:30:59 +08:00
whyour 8011b4b793 修改生成ssh配置 2022-05-18 20:10:28 +08:00
whyour deb124ae23 修复删除私钥 2022-05-18 20:06:46 +08:00
whyour a3114e40d1 修复私钥权限 2022-05-18 19:23:32 +08:00
whyour 0b9ecfc3a5 完善拉取私有仓库 2022-05-18 01:14:10 +08:00
whyour c32bb4157b 统一api错误提示 2022-05-17 23:31:29 +08:00
whyour 7f9c9f05b8 修复订阅执行日志输出 2022-05-17 22:27:41 +08:00
whyour 906a074634 修复环境变量PATH 2022-05-17 13:24:19 +08:00
whyour 286ec0e714 修改dockerfile 2022-05-17 12:38:58 +08:00
whyour 0f81414497 action安装pnpm 2022-05-17 12:18:29 +08:00
whyour b451831db2 移除pnpm action缓存 2022-05-17 12:14:44 +08:00
whyour ae13185319 pnpm action缓存 2022-05-17 12:13:26 +08:00
whyour f2bcbaefd2 pnpm action缓存 2022-05-17 12:12:25 +08:00
whyour 96ddcf49c5 pnpm action缓存 2022-05-17 12:10:59 +08:00
whyour 322a4e2729 删除pnpm缓存 2022-05-17 12:06:29 +08:00
whyour 8e48960253 完善拉取公开仓库 2022-05-17 00:42:18 +08:00
whyour 3b419a8e96 兼容antd Menu 2022-05-16 01:47:03 +08:00
whyour bf1685bf79 重构任务执行逻辑 2022-05-15 20:40:29 +08:00
whyour 3ab84428f0 修复编译错误 2022-05-15 15:35:52 +08:00
whyour fdda732c4c 更新新建文件订阅 2022-05-15 15:25:23 +08:00
whyour f46b30498b 完善订阅接口 2022-05-14 21:38:26 +08:00
whyour d7ee2576f2 完善新建订阅页面 2022-05-14 19:21:35 +08:00
whyour d2c03bc298 增加订阅api 2022-05-10 19:54:05 +08:00
whyour 6283e23d29 修复编译错误 2022-05-10 14:42:25 +08:00
whyour dc9923ac13 增加新建订阅 2022-05-10 14:38:06 +08:00
whyour 62ac80257a 修复设置头像 2022-05-09 15:33:18 +08:00
whyour 868452f13c 修复设置头像 2022-05-09 15:32:44 +08:00
whyour 6c368f7ea7 支持更换头像 2022-05-09 15:31:41 +08:00
whyour 732d63d588 增加ssh key操作service 2022-05-08 22:41:56 +08:00
whyour ae40947801 更新readme 2022-05-08 11:33:39 +08:00
whyour 079d4dfa06 修复用户名显示 2022-05-08 10:06:56 +08:00
whyour 67c50bf622 添加订阅管理前端 2022-05-08 09:41:06 +08:00
whyour f1e494b1fc 修复生产环境安装依赖prepare失败 2022-05-03 18:44:00 +08:00
whyour f4f275490e 修复npm prepare命令 2022-05-03 18:25:52 +08:00
whyour baad96106f 修复pnpm全局安装 2022-05-03 18:07:12 +08:00
whyour 15fa7754b5 修复peerDependence 2022-05-03 15:55:39 +08:00
whyour 8f175f8447 修复pnpm依赖 2022-05-03 15:43:15 +08:00
whyour 9c45cfd7ed 修改action依赖 2022-05-03 15:06:28 +08:00
whyour f2426c3f66 修改依赖 2022-05-03 14:59:48 +08:00
whyour 36ae6e460d 测试pnpm版本 2022-05-03 14:47:59 +08:00
whyour 7ae93efe07 测试pnpm版本 2022-05-03 14:44:10 +08:00
whyour 8b97d92d0a 添加依赖rc-tween-one 2022-05-01 14:20:00 +08:00
whyour e5c06335ec 修复pnpm版本 2022-05-01 14:15:29 +08:00
whyour 2bd8e49302 修改定时任务添加标签交互 2022-05-01 13:51:33 +08:00
whyour c0d401e296 修复环境变量转义 2022-04-25 23:55:25 +08:00
whyour e07d2b6639 修复获取最新version文件 2022-04-24 18:43:09 +08:00
whyour 220a87627d 更新版本 v2.12.2 2022-04-24 17:50:03 +08:00
whyour 87bca2ac4e 任务详情增加运行、禁用、置顶操作 2022-04-24 17:45:50 +08:00
whyour c320906149 依赖管理增加直接强制删除 2022-04-23 15:02:54 +08:00
JerryWnandGitHub 27958a1a90 修复双引号转义逻辑 #1331 (#1337) 2022-04-18 22:55:37 +08:00
whyour 82c7011522 修复定时删除日志设置,主持设置大于24天 2022-04-18 22:29:14 +08:00
79 changed files with 3717 additions and 683 deletions
+11 -14
View File
@@ -20,10 +20,8 @@ jobs:
build-static: build-static:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/setup-node@v1 - uses: actions/setup-node@v3
with:
node-version: '14'
- name: build front and back - name: build front and back
run: | run: |
@@ -56,10 +54,8 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/setup-node@v1 - uses: actions/setup-node@v3
with:
node-version: '14'
- name: Set time zone - name: Set time zone
uses: szenius/set-timezone@v1.0 uses: szenius/set-timezone@v1.0
@@ -69,13 +65,13 @@ jobs:
timezoneWindows: "China Standard Time" timezoneWindows: "China Standard Time"
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR - name: Login to GHCR
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -83,7 +79,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v3 uses: docker/metadata-action@v4
with: with:
images: | images: |
${{ github.repository }} ${{ github.repository }}
@@ -100,18 +96,19 @@ jobs:
type=semver,pattern={{major}} type=semver,pattern={{major}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v2
- name: Build and push - name: Build and push
id: docker_build id: docker_build
uses: docker/build-push-action@v2 uses: docker/build-push-action@v3
with: with:
build-args: | build-args: |
MAINTAINER=${{ github.repository_owner }} MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }} QL_BRANCH=${{ github.ref_name }}
network: host
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
context: docker/ context: docker/
push: true push: true
-31
View File
@@ -1,31 +0,0 @@
name: PublishDockerImage
on:
workflow_dispatch:
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout base
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Docker login
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: |
echo "${DOCKER_PASSWORD}" | docker login --username "${DOCKER_USERNAME}" --password-stdin
- name: Docker buildx image and push on master branch
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
docker buildx build --build-arg SSH_PRIVATE_KEY="${SSH_PRIVATE_KEY}" --output "type=image,push=true" --platform=linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/s390x --tag whyour/qinglong:latest docker/
+4
View File
@@ -9,6 +9,10 @@ export default defineConfig({
type: 'none', type: 'none',
}, },
fastRefresh: {}, fastRefresh: {},
// antd: {
// dark: true,
// },
mfsu: {},
esbuild: {}, esbuild: {},
webpack5: {}, webpack5: {},
dynamicImport: { dynamicImport: {
+197 -18
View File
@@ -1,22 +1,201 @@
MIT LICENSE Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright (c) 2021-present whyour TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Permission is hereby granted, free of charge, to any person obtaining 1. Definitions.
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be "License" shall mean the terms and conditions for use, reproduction,
included in all copies or substantial portions of the Software. and distribution as defined by Sections 1 through 9 of this document.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, "Licensor" shall mean the copyright owner or entity authorized by
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF the copyright owner that is granting the License.
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE "Legal Entity" shall mean the union of the acting entity and all
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION other entities that control, are controlled by, or are under common
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION control with that entity. For the purposes of this definition,
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. "control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2021 WHYOUR <https://github.com/whyour>.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+210
View File
@@ -0,0 +1,210 @@
<p align="center">
<a href="https://github.com/whyour/qinglong">
<img width="150" src="https://z3.ax1x.com/2021/11/18/I7MpAe.png">
</a>
</p>
<h1 align="center">Green Dragon</h1>
<div align="center">
Timed task management panel with python3, javaScript, shell, typescript support
[![docker version][docker-version-image]][docker-version-url] [![docker pulls][docker-pulls-image]][docker-pulls-url] [![docker stars][docker-stars-image]][docker-stars-url] [![docker image size][docker-image-size-image]][docker-image-size-url]
[docker-pulls-image]: https://img.shields.io/docker/pulls/whyour/qinglong?style=flat
[docker-pulls-url]: https://hub.docker.com/r/whyour/qinglong
[docker-version-image]: https://img.shields.io/docker/v/whyour/qinglong?style=flat
[docker-version-url]: https://hub.docker.com/r/whyour/qinglong/tags?page=1&ordering=last_updated
[docker-stars-image]: https://img.shields.io/docker/stars/whyour/qinglong?style=flat
[docker-stars-url]: https://hub.docker.com/r/whyour/qinglong
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
</div>
[![](https://user-images.githubusercontent.com/22700758/161788855-c4e51cb8-d4e9-44fe-bb17-ee1a56c8549b.png)](https://whyour.cn)
[简体中文](./README.md) | English
## Features
- Support for multiple scripting languages (python3, javaScript, shell, typescript)
- Support online management of scripts, environment variables, configuration files
- Support online view task log
- Support second-level task setting
- Support system level notification
- Support dark mode
- Support cell phone operation
## Deployment
### Local Deployment
```bash
# To be completed
```
### Podman Deployment
1. podman installation
```bash
https://podman.io/getting-started/installation
```
2. start the container
```bash
podman run -dit \
--network bridge \
-v $PWD/ql:/ql/data \
-p 5700:5700 \
--name qinglong \
--hostname qinglong \
--restart unless-stopped \
docker.io/whyour/qinglong:latest
```
### Docker Deployment
1. docker installation
```bash
sudo curl -sSL get.docker.com | sh
```
2. configure domestic mirror sources
```bash
mkdir -p /etc/docker
tee /etc/docker/daemon.json <<-'EOF'
{
"registry-mirrors": [
"https://0b27f0a81a00f3560fbdc00ddd2f99e0.mirror.swr.myhuaweicloud.com",
"https://ypzju6vq.mirror.aliyuncs.com",
"https://registry.docker-cn.com",
"http://hub-mirror.c.163.com",
"https://docker.mirrors.ustc.edu.cn"
]
}
EOF
systemctl daemon-reload
systemctl restart docker
```
3. start the container
```bash
docker run -dit \
-v $PWD/ql:/ql/data \
-p 5700:5700 \
--name qinglong \
--hostname qinglong \
--restart unless-stopped \
whyour/qinglong:latest
```
### Docker-compose Deployment
1. docker-compose installation
```bash
sudo curl -L https://github.com/docker/compose/releases/download/1.16.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
```
2. start the container
```bash
mkdir qinglong
wget https://raw.githubusercontent.com/whyour/qinglong/master/docker/docker-compose.yml
# start
docker-compose up -d
# stop
docker-compose down
```
## Use
1. built-in commands
```bash
# Update and restart Green Dragon
ql update
# Run custom scripts extra.sh
ql extra
# Adding a single script file
ql raw <file_url>
# Add a specific script for a single repository
ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch>
# Delete old logs
ql rmlog <days>
# Start bot
ql bot
# Detecting the Green Dragon environment and repairing it
ql check
# Reset the number of login errors
ql resetlet
# Disable two-step login
ql resettfa
# Execute in sequence, if a random delay is set, it will be randomly delayed by a certain number of seconds
task <file_path>
# Execute in sequence, regardless of whether a random delay is set, all run immediately,
# and the foreground will output the day, while recorded in the log file
task <file_path> now
# Concurrent execution, regardless of whether a random delay is set, are run immediately,
# the foreground does not generate the day, directly recorded in the log file, and can be specified account execution
task <file_path> conc <env_name> <account_number>(Optional)
# Specify the account to execute and run immediately regardless of whether a random delay is set
task <file_path> desi <env_name> <account_number>
```
2. parameter description
* file_url: Script address
* repo_url: Repository address
* whitelist: The whitelist when pulling the repository, i.e., the string contained in the path of the script to be pulled
* blacklist: Blacklisting when pulling repositories, i.e. strings that are not included in the path of the script to be pulled
* dependence: Pulling the dependencies needed for the repository will be copied directly from the repository to the repository directory under scripts, regardless of the blacklist
* branch: Pull the branch of the repository
* days: Number of days of logs to be kept
* file_path: File path for task execution
* env_name: The name of the environment variable that needs to be concurrent or specified at the time of task execution
* account_number: Specify the account number of an environment variable to be executed when the task is executed
## Links
- [nevinee](https://gitee.com/evine)
- [crontab-ui](https://github.com/alseambusher/crontab-ui)
- [Ant Design](https://ant.design)
- [Ant Design Pro](https://pro.ant.design/)
- [Umijs3.0](https://umijs.org)
- [darkreader](https://github.com/darkreader/darkreader)
- [admin-server](https://github.com/sunpu007/admin-server)
## Development
```bash
$ git clone git@github.com:whyour/qinglong.git
$ cd qinglong
$ cp .env.example .env
# Recommended use pnpm https://pnpm.io/zh/installation
$ npm install -g pnpm
$ pnpm install
$ pnpm start
```
Open your browser and visit http://127.0.0.1:5700
## Communication
[telegram channel](https://t.me/jiao_long)
## Name Origin
The Green Dragon, also known as the Canglong, is one of the four elephants and one of the [four spirits of the heavens](https://zh.wikipedia.org/wiki/%E5%A4%A9%E4%B9%8B%E5%9B%9B%E7%81%B5) in traditional Chinese culture. According to the Five Elements, it is a spirit animal representing the East as a green dragon, the five elements are wood, and the season represented is spring, with the eight trigrams dominating vibration. Like the Ying Long, the Cang Long has feathered wings. According to the Zhang Guo Xing Zong (Zhang Guo Xing Zong), "a true dragon is one that has complementary wings".
In the Book of the Later Han Dynasty (後漢書-律曆志下), it is written: "The sun is in the sky, a cold and a summer, the four seasons are ready, all things are changed, the regency moves, and the green dragon moves to the star, which is called the year. (The Year of the Star)
Among the [twenty-eight Chinese constellations](https://zh.wikipedia.org/wiki/%E4%BA%8C%E5%8D%81%E5%85%AB%E5%AE%BF), the Green Dragon is the generic name for the seven eastern constellations (Horn, Hyper, Diao, Fang, Heart, Tail and Minchi). It is known in Taoism as "Mengzhang" and in different Taoist scriptures as "Dijun", "Shengjian", "Shenjian" and He is also known in different Daoist scriptures as "Dijun", "Shengjun", "Shenjun" and "Ghost Catcher"[1], and is the guardian deity of Daoism, together with the White Tiger Supervisor of Soldiers.
+24 -3
View File
@@ -8,7 +8,7 @@
<div align="center"> <div align="center">
支持python3、javaScript、shell、typescript 的定时任务管理面板A timed task management panel that supports typescript, javaScript, python3, and shell. 支持python3、javaScript、shell、typescript 的定时任务管理面板
[![docker version][docker-version-image]][docker-version-url] [![docker pulls][docker-pulls-image]][docker-pulls-url] [![docker stars][docker-stars-image]][docker-stars-url] [![docker image size][docker-image-size-image]][docker-image-size-url] [![docker version][docker-version-image]][docker-version-url] [![docker pulls][docker-pulls-image]][docker-pulls-url] [![docker stars][docker-stars-image]][docker-stars-url] [![docker image size][docker-image-size-image]][docker-image-size-url]
@@ -73,7 +73,27 @@ podman run -dit \
sudo curl -sSL get.docker.com | sh sudo curl -sSL get.docker.com | sh
``` ```
2. 启动容器 2. 配置国内镜像源
Configure domestic mirror sources
```bash
mkdir -p /etc/docker
tee /etc/docker/daemon.json <<-'EOF'
{
"registry-mirrors": [
"https://0b27f0a81a00f3560fbdc00ddd2f99e0.mirror.swr.myhuaweicloud.com",
"https://ypzju6vq.mirror.aliyuncs.com",
"https://registry.docker-cn.com",
"http://hub-mirror.c.163.com",
"https://docker.mirrors.ustc.edu.cn"
]
}
EOF
systemctl daemon-reload
systemctl restart docker
```
3. 启动容器
```bash ```bash
docker run -dit \ docker run -dit \
@@ -117,7 +137,7 @@ ql extra
# 添加单个脚本文件 # 添加单个脚本文件
ql raw <file_url> ql raw <file_url>
# 添加单个仓库的指定脚本 # 添加单个仓库的指定脚本
ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch> ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch> <extensions>
# 删除旧日志 # 删除旧日志
ql rmlog <days> ql rmlog <days>
# 启动tg-bot # 启动tg-bot
@@ -147,6 +167,7 @@ task <file_path> desi <env_name> <account_number>
* blacklist: 拉取仓库时的黑名单,即就是需要拉取的脚本的路径不包含的字符串 * blacklist: 拉取仓库时的黑名单,即就是需要拉取的脚本的路径不包含的字符串
* dependence: 拉取仓库需要的依赖文件,会直接从仓库拷贝到scripts下的仓库目录,不受黑名单影响 * dependence: 拉取仓库需要的依赖文件,会直接从仓库拷贝到scripts下的仓库目录,不受黑名单影响
* branch: 拉取仓库的分支 * branch: 拉取仓库的分支
* extensions: 拉取仓库的文件后缀
* days: 需要保留的日志的天数 * days: 需要保留的日志的天数
* file_path: 任务执行时的文件路径 * file_path: 任务执行时的文件路径
* env_name: 任务执行时需要并发或者指定时的环境变量名称 * env_name: 任务执行时需要并发或者指定时的环境变量名称
+1 -1
View File
@@ -94,7 +94,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const dependenceService = Container.get(DependenceService); const dependenceService = Container.get(DependenceService);
const data = await dependenceService.removeDb(req.body); const data = await dependenceService.remove(req.body, true);
return res.send({ code: 200, data }); return res.send({ code: 200, data });
} catch (e) { } catch (e) {
logger.error('🔥 error: %o', e); logger.error('🔥 error: %o', e);
+2
View File
@@ -8,6 +8,7 @@ import script from './script';
import open from './open'; import open from './open';
import dependence from './dependence'; import dependence from './dependence';
import system from './system'; import system from './system';
import subscription from './subscription';
export default () => { export default () => {
const app = Router(); const app = Router();
@@ -20,6 +21,7 @@ export default () => {
open(app); open(app);
dependence(app); dependence(app);
system(app); system(app);
subscription(app);
return app; return app;
}; };
+12 -42
View File
@@ -3,7 +3,8 @@ import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import * as fs from 'fs'; import * as fs from 'fs';
import config from '../config'; import config from '../config';
import { getFileContentByName } from '../config/util'; import { getFileContentByName, readDirs } from '../config/util';
import { join } from 'path';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
@@ -12,59 +13,28 @@ export default (app: Router) => {
route.get('/', async (req: Request, res: Response, next: NextFunction) => { route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const fileList = fs.readdirSync(config.logPath, 'utf-8'); const result = readDirs(config.logPath, config.logPath);
const dirs = []; res.send({
for (let i = 0; i < fileList.length; i++) { code: 200,
const stat = fs.lstatSync(config.logPath + fileList[i]); data: result,
if (stat.isDirectory()) {
const fileListTmp = fs.readdirSync(
`${config.logPath}/${fileList[i]}`,
'utf-8',
);
dirs.push({
name: fileList[i],
isDir: true,
files: fileListTmp.reverse(),
}); });
} else {
dirs.push({
name: fileList[i],
isDir: false,
files: [],
});
}
}
res.send({ code: 200, dirs });
} catch (e) { } catch (e) {
logger.error('🔥 error: %o', e); logger.error('🔥 error: %o', e);
return next(e); return next(e);
} }
}); });
route.get(
'/:dir/:file',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const { dir, file } = req.params;
const content = getFileContentByName(
`${config.logPath}/${dir}/${file}`,
);
res.send({ code: 200, data: content });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.get( route.get(
'/:file', '/:file',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const { file } = req.params; const filePath = join(
const content = getFileContentByName(`${config.logPath}/${file}`); config.logPath,
req.query.path as string,
req.params.file,
);
const content = getFileContentByName(filePath);
res.send({ code: 200, data: content }); res.send({ code: 200, data: content });
} catch (e) { } catch (e) {
logger.error('🔥 error: %o', e); logger.error('🔥 error: %o', e);
+5 -53
View File
@@ -1,6 +1,7 @@
import { import {
fileExist, fileExist,
getFileContentByName, getFileContentByName,
readDirs,
getLastModifyFilePath, getLastModifyFilePath,
} from '../config/util'; } from '../config/util';
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
@@ -16,58 +17,10 @@ const route = Router();
export default (app: Router) => { export default (app: Router) => {
app.use('/scripts', route); app.use('/scripts', route);
route.get( route.get('/', async (req: Request, res: Response, next: NextFunction) => {
'/files',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const fileList = fs.readdirSync(config.scriptPath, 'utf-8'); const result = readDirs(config.scriptPath, config.scriptPath);
let result = [];
for (let i = 0; i < fileList.length; i++) {
const fileOrDir = fileList[i];
const fPath = path.join(config.scriptPath, fileOrDir);
const dirStat = fs.statSync(fPath);
if (['node_modules'].includes(fileOrDir)) {
continue;
}
if (dirStat.isDirectory()) {
const childFileList = fs.readdirSync(fPath, 'utf-8');
let children = [];
for (let j = 0; j < childFileList.length; j++) {
const childFile = childFileList[j];
const sPath = path.join(config.scriptPath, fileOrDir, childFile);
const _fileExist = await fileExist(sPath);
if (_fileExist && fs.statSync(sPath).isFile()) {
const statObj = fs.statSync(sPath);
children.push({
title: childFile,
value: childFile,
key: `${fileOrDir}/${childFile}`,
mtime: statObj.mtimeMs,
parent: fileOrDir,
});
}
}
result.push({
title: fileOrDir,
value: fileOrDir,
key: fileOrDir,
mtime: dirStat.mtimeMs,
disabled: true,
children: children.sort((a, b) => b.mtime - a.mtime),
});
} else {
result.push({
title: fileOrDir,
value: fileOrDir,
key: fileOrDir,
mtime: dirStat.mtimeMs,
});
}
}
res.send({ res.send({
code: 200, code: 200,
data: result, data: result,
@@ -76,8 +29,7 @@ export default (app: Router) => {
logger.error('🔥 error: %o', e); logger.error('🔥 error: %o', e);
return next(e); return next(e);
} }
}, });
);
route.get( route.get(
'/:file', '/:file',
@@ -129,7 +81,7 @@ export default (app: Router) => {
if (config.writePathList.every((x) => !path.startsWith(x))) { if (config.writePathList.every((x) => !path.startsWith(x))) {
return res.send({ return res.send({
code: 430, code: 430,
data: '文件路径禁止访问', message: '文件路径禁止访问',
}); });
} }
if (!originFilename) { if (!originFilename) {
+288
View File
@@ -0,0 +1,288 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate';
import cron_parser from 'cron-parser';
const route = Router();
export default (app: Router) => {
app.use('/subscriptions', route);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.list(
req.query.searchValue as string,
);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
route.post(
'/',
celebrate({
body: Joi.object({
type: Joi.string().required(),
schedule: Joi.string().optional().allow('').allow(null),
interval_schedule: Joi.object().optional().allow('').allow(null),
name: Joi.string().optional().allow('').allow(null),
url: Joi.string().required(),
whitelist: Joi.string().optional().allow('').allow(null),
blacklist: Joi.string().optional().allow('').allow(null),
branch: Joi.string().optional().allow('').allow(null),
dependences: Joi.string().optional().allow('').allow(null),
pull_type: Joi.string().optional().allow('').allow(null),
pull_option: Joi.object().optional().allow('').allow(null),
extensions: Joi.string().optional().allow('').allow(null),
sub_before: Joi.string().optional().allow('').allow(null),
sub_after: Joi.string().optional().allow('').allow(null),
schedule_type: Joi.string().required(),
alias: Joi.string().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 subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.create(req.body);
return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
}
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.put(
'/run',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.run(req.body);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.put(
'/stop',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.stop(req.body);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.put(
'/disable',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.disabled(req.body);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.put(
'/enable',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.enabled(req.body);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.get(
'/:id/log',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.log(req.params.id);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.put(
'/',
celebrate({
body: Joi.object({
type: Joi.string().required(),
schedule: Joi.string().optional().allow('').allow(null),
interval_schedule: Joi.object().optional().allow('').allow(null),
name: Joi.string().optional().allow('').allow(null),
url: Joi.string().required(),
whitelist: Joi.string().optional().allow('').allow(null),
blacklist: Joi.string().optional().allow('').allow(null),
branch: Joi.string().optional().allow('').allow(null),
dependences: Joi.string().optional().allow('').allow(null),
pull_type: Joi.string().optional().allow('').allow(null),
pull_option: Joi.object().optional().allow('').allow(null),
schedule_type: Joi.string().optional().allow('').allow(null),
extensions: Joi.string().optional().allow('').allow(null),
sub_before: Joi.string().optional().allow('').allow(null),
sub_after: Joi.string().optional().allow('').allow(null),
alias: Joi.string().required(),
id: Joi.number().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
if (
!req.body.schedule ||
typeof req.body.schedule === 'object' ||
cron_parser.parseExpression(req.body.schedule).hasNext()
) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.update(req.body);
return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
}
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.delete(
'/',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.remove(req.body);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.get(
'/:id',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.getDb({ id: req.params.id });
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.put(
'/status',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()),
status: Joi.string().required(),
pid: Joi.string().optional(),
log_path: Joi.string().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.status({
...req.body,
status: parseInt(req.body.status),
pid: parseInt(req.body.pid) || '',
});
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.get(
'/:id/logs',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.logs(req.params.id);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
};
+33 -3
View File
@@ -1,13 +1,26 @@
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import * as fs from 'fs';
import config from '../config';
import UserService from '../services/user'; import UserService from '../services/user';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import { getFileContentByName } from '../config/util'; import multer from 'multer';
import path from 'path';
import { v4 as uuidV4 } from 'uuid';
import config from '../config';
const route = Router(); const route = Router();
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, config.uploadPath);
},
filename: function (req, file, cb) {
const ext = path.parse(file.originalname).ext;
const key = uuidV4();
cb(null, key + ext);
},
});
const upload = multer({ storage: storage });
export default (app: Router) => { export default (app: Router) => {
app.use('/user', route); app.use('/user', route);
@@ -77,6 +90,7 @@ export default (app: Router) => {
code: 200, code: 200,
data: { data: {
username: authInfo.username, username: authInfo.username,
avatar: authInfo.avatar,
twoFactorActivated: authInfo.twoFactorActivated, twoFactorActivated: authInfo.twoFactorActivated,
}, },
}); });
@@ -238,4 +252,20 @@ export default (app: Router) => {
} }
}, },
); );
route.put(
'/avatar',
upload.single('avatar'),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const result = await userService.updateAvatar(req.file!.filename);
res.send(result);
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
}; };
+1 -5
View File
@@ -19,11 +19,7 @@ async function startServer() {
const server = app const server = app
.listen(config.port, () => { .listen(config.port, () => {
Logger.info(` Logger.debug(`✌️ Back server launched on port ${config.port}`);
################################################
🛡️ Server listening on port: ${config.port} 🛡️
################################################
`);
}) })
.on('error', (err) => { .on('error', (err) => {
Logger.error(err); Logger.error(err);
+3 -1
View File
@@ -4,7 +4,7 @@ import { createRandomString } from './util';
process.env.NODE_ENV = process.env.NODE_ENV || 'development'; process.env.NODE_ENV = process.env.NODE_ENV || 'development';
const lastVersionFile = 'http://qn.whyour.cn/version.ts?v=2.12.1'; const lastVersionFile = `http://qn.whyour.cn/version.ts?v=${Date.now()}`;
const envFound = dotenv.config(); const envFound = dotenv.config();
const rootPath = process.cwd(); const rootPath = process.cwd();
@@ -15,6 +15,7 @@ const scriptPath = path.join(dataPath, 'scripts/');
const bakPath = path.join(dataPath, 'bak/'); const bakPath = path.join(dataPath, 'bak/');
const logPath = path.join(dataPath, 'log/'); const logPath = path.join(dataPath, 'log/');
const dbPath = path.join(dataPath, 'db/'); const dbPath = path.join(dataPath, 'db/');
const uploadPath = path.join(dataPath, 'upload/');
const envFile = path.join(configPath, 'env.sh'); const envFile = path.join(configPath, 'env.sh');
const confFile = path.join(configPath, 'config.sh'); const confFile = path.join(configPath, 'config.sh');
@@ -58,6 +59,7 @@ export default {
confFile, confFile,
envFile, envFile,
dbPath, dbPath,
uploadPath,
configPath, configPath,
scriptPath, scriptPath,
samplePath, samplePath,
+37
View File
@@ -232,6 +232,14 @@ export async function fileExist(file: any) {
}); });
} }
export async function createFile(file: string, data: string = '') {
return new Promise((resolve) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, data);
resolve(true);
});
}
export async function concurrentRun( export async function concurrentRun(
fnList: Array<() => Promise<any>> = [], fnList: Array<() => Promise<any>> = [],
max = 5, max = 5,
@@ -268,3 +276,32 @@ export async function concurrentRun(
return replyList; return replyList;
} }
export function readDirs(dir: string, baseDir: string = '') {
const relativePath = path.relative(baseDir, dir);
const files = fs.readdirSync(dir);
const result: any = files.map((file: string) => {
const subPath = path.join(dir, file);
const stats = fs.statSync(subPath);
const key = path.join(relativePath, file);
if (stats.isDirectory()) {
return {
title: file,
value: file,
key,
type: 'directory',
disabled: true,
parent: relativePath,
children: readDirs(subPath, baseDir),
};
}
return {
title: file,
value: file,
type: 'file',
key,
parent: relativePath,
};
});
return result;
}
+1 -1
View File
@@ -34,7 +34,7 @@ export class Crontab {
this.isDisabled = options.isDisabled || 0; this.isDisabled = options.isDisabled || 0;
this.log_path = options.log_path || ''; this.log_path = options.log_path || '';
this.isPinned = options.isPinned || 0; this.isPinned = options.isPinned || 0;
this.labels = options.labels || ['']; this.labels = options.labels || [];
this.last_running_time = options.last_running_time || 0; this.last_running_time = options.last_running_time || 0;
this.last_execution_time = options.last_execution_time || 0; this.last_execution_time = options.last_execution_time || 0;
} }
+4 -4
View File
@@ -37,15 +37,15 @@ export enum DependenceTypes {
} }
export enum InstallDependenceCommandTypes { export enum InstallDependenceCommandTypes {
'npm i -g --force', 'pnpm add -g',
'pip3 install', 'pip3 install',
'apk add --no-cache -f', 'apk add',
} }
export enum unInstallDependenceCommandTypes { export enum unInstallDependenceCommandTypes {
'npm uninstall -g --force', 'pnpm remove -g',
'pip3 uninstall -y', 'pip3 uninstall -y',
'apk del -f', 'apk del',
} }
interface DependenceInstance interface DependenceInstance
+3 -1
View File
@@ -13,5 +13,7 @@ export class SockMessage {
export type SockMessageType = export type SockMessageType =
| 'ping' | 'ping'
| 'installDependence' | 'installDependence'
| 'uninstallDependence'
| 'updateSystemVersion' | 'updateSystemVersion'
| 'manuallyRunScript'; | 'manuallyRunScript'
| 'runSubscriptionEnd';
+106
View File
@@ -0,0 +1,106 @@
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
import { SimpleIntervalSchedule } from 'toad-scheduler';
type SimpleIntervalScheduleUnit = keyof SimpleIntervalSchedule;
export class Subscription {
id?: number;
name?: string;
type?: 'public-repo' | 'private-repo' | 'file';
schedule_type?: 'crontab' | 'interval';
schedule?: string;
interval_schedule?: { type: SimpleIntervalScheduleUnit; value: number };
url?: string;
whitelist?: string;
blacklist?: string;
dependences?: string;
branch?: string;
status?: SubscriptionStatus;
pull_type?: 'ssh-key' | 'user-pwd';
pull_option?:
| { private_key: string }
| { username: string; password: string };
pid?: number;
is_disabled?: 1 | 0;
log_path?: string;
alias: string;
command?: string;
extensions?: string;
sub_before?: string;
sub_after?: string;
constructor(options: Subscription) {
this.id = options.id;
this.name = options.name || options.alias;
this.type = options.type;
this.schedule = options.schedule;
this.status =
options.status && SubscriptionStatus[options.status]
? options.status
: SubscriptionStatus.idle;
this.url = options.url;
this.whitelist = options.whitelist;
this.blacklist = options.blacklist;
this.dependences = options.dependences;
this.branch = options.branch;
this.pull_type = options.pull_type;
this.pull_option = options.pull_option;
this.pid = options.pid;
this.is_disabled = options.is_disabled;
this.log_path = options.log_path;
this.schedule_type = options.schedule_type;
this.alias = options.alias;
this.interval_schedule = options.interval_schedule;
this.extensions = options.extensions;
this.sub_before = options.sub_before;
this.sub_after = options.sub_after;
}
}
export enum SubscriptionStatus {
'running',
'idle',
'disabled',
'queued',
}
interface SubscriptionInstance
extends Model<Subscription, Subscription>,
Subscription {}
export const SubscriptionModel = sequelize.define<SubscriptionInstance>(
'Subscription',
{
name: {
unique: 'compositeIndex',
type: DataTypes.STRING,
},
url: {
unique: 'compositeIndex',
type: DataTypes.STRING,
},
schedule: {
unique: 'compositeIndex',
type: DataTypes.STRING,
},
interval_schedule: {
unique: 'compositeIndex',
type: DataTypes.JSON,
},
type: DataTypes.STRING,
whitelist: DataTypes.STRING,
blacklist: DataTypes.STRING,
status: DataTypes.NUMBER,
dependences: DataTypes.STRING,
extensions: DataTypes.STRING,
sub_before: DataTypes.STRING,
sub_after: DataTypes.STRING,
branch: DataTypes.STRING,
pull_type: DataTypes.STRING,
pull_option: DataTypes.JSON,
pid: DataTypes.NUMBER,
is_disabled: DataTypes.NUMBER,
log_path: DataTypes.STRING,
schedule_type: DataTypes.STRING,
alias: { type: DataTypes.STRING, unique: 'alias' },
},
);
+7 -5
View File
@@ -1,14 +1,13 @@
import expressLoader from './express'; import expressLoader from './express';
import dependencyInjectorLoader from './dependencyInjector'; import depInjectorLoader from './depInjector';
import Logger from './logger'; import Logger from './logger';
import initData from './initData'; import initData from './initData';
import { Application } from 'express'; import { Application } from 'express';
import linkDeps from './deps'; import linkDeps from './deps';
import initTask from './initTask';
export default async ({ expressApp }: { expressApp: Application }) => { export default async ({ expressApp }: { expressApp: Application }) => {
await dependencyInjectorLoader({ await depInjectorLoader();
models: [],
});
Logger.info('✌️ Dependency Injector loaded'); Logger.info('✌️ Dependency Injector loaded');
await expressLoader({ app: expressApp }); await expressLoader({ app: expressApp });
@@ -18,5 +17,8 @@ export default async ({ expressApp }: { expressApp: Application }) => {
Logger.info('✌️ init data loaded'); Logger.info('✌️ init data loaded');
await linkDeps(); await linkDeps();
Logger.info('✌️ link deps'); Logger.info('✌️ link deps loaded');
initTask();
Logger.info('✌️ init task loaded');
}; };
+3
View File
@@ -8,6 +8,7 @@ import { AppModel } from '../data/open';
import { AuthModel } from '../data/auth'; import { AuthModel } from '../data/auth';
import { sequelize } from '../data'; import { sequelize } from '../data';
import { fileExist } from '../config/util'; import { fileExist } from '../config/util';
import { SubscriptionModel } from '../data/subscription';
import config from '../config'; import config from '../config';
export default async () => { export default async () => {
@@ -17,6 +18,8 @@ export default async () => {
await AppModel.sync(); await AppModel.sync();
await AuthModel.sync(); await AuthModel.sync();
await EnvModel.sync(); await EnvModel.sync();
await SubscriptionModel.sync({ alter: true });
await sequelize.sync(); await sequelize.sync();
// try { // try {
@@ -1,12 +1,8 @@
import { Container } from 'typedi'; import { Container } from 'typedi';
import LoggerInstance from './logger'; import LoggerInstance from './logger';
export default ({ models }: { models: { name: string; model: any }[] }) => { export default () => {
try { try {
models.forEach((m) => {
Container.set(m.name, m.model);
});
Container.set('logger', LoggerInstance); Container.set('logger', LoggerInstance);
} catch (e) { } catch (e) {
LoggerInstance.error('🔥 Error on dependency injector loader: %o', e); LoggerInstance.error('🔥 Error on dependency injector loader: %o', e);
+4 -1
View File
@@ -1,4 +1,4 @@
import { Request, Response, NextFunction, Application } from 'express'; import express, { Request, Response, NextFunction, Application } from 'express';
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
import cors from 'cors'; import cors from 'cors';
import routes from '../api'; import routes from '../api';
@@ -13,10 +13,12 @@ import UserService from '../services/user';
import handler from 'serve-handler'; import handler from 'serve-handler';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { EnvModel } from '../data/env'; import { EnvModel } from '../data/env';
import { errors } from 'celebrate';
export default ({ app }: { app: Application }) => { export default ({ app }: { app: Application }) => {
app.enable('trust proxy'); app.enable('trust proxy');
app.use(cors()); app.use(cors());
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
app.use((req, res, next) => { app.use((req, res, next) => {
if (req.path.startsWith('/api') || req.path.startsWith('/open')) { if (req.path.startsWith('/api') || req.path.startsWith('/open')) {
@@ -133,6 +135,7 @@ export default ({ app }: { app: Application }) => {
next(err); next(err);
}); });
app.use(errors());
app.use(Sentry.Handlers.errorHandler()); app.use(Sentry.Handlers.errorHandler());
app.use( app.use(
+1 -35
View File
@@ -7,16 +7,12 @@ import EnvService from '../services/env';
import _ from 'lodash'; import _ from 'lodash';
import { DependenceModel } from '../data/dependence'; import { DependenceModel } from '../data/dependence';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import SystemService from '../services/system';
import ScheduleService from '../services/schedule';
import config from '../config'; import config from '../config';
export default async () => { export default async () => {
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
const dependenceService = Container.get(DependenceService); const dependenceService = Container.get(DependenceService);
const systemService = Container.get(SystemService);
const scheduleService = Container.get(ScheduleService);
// 初始化更新所有任务状态为空闲 // 初始化更新所有任务状态为空闲
await CrontabModel.update( await CrontabModel.update(
@@ -25,7 +21,7 @@ export default async () => {
); );
// 初始化时安装所有处于安装中,安装成功,安装失败的依赖 // 初始化时安装所有处于安装中,安装成功,安装失败的依赖
DependenceModel.findAll({ where: {} }).then(async (docs) => { DependenceModel.findAll({ where: {}, raw: true }).then(async (docs) => {
const groups = _.groupBy(docs, 'type'); const groups = _.groupBy(docs, 'type');
for (const key in groups) { for (const key in groups) {
if (Object.prototype.hasOwnProperty.call(groups, key)) { if (Object.prototype.hasOwnProperty.call(groups, key)) {
@@ -119,34 +115,4 @@ export default async () => {
// 初始化保存一次ck和定时任务数据 // 初始化保存一次ck和定时任务数据
await cronService.autosave_crontab(); await cronService.autosave_crontab();
await envService.set_envs(); await envService.set_envs();
// 运行删除日志任务
const data = await systemService.getLogRemoveFrequency();
if (data && data.info && data.info.frequency) {
const cron = {
id: data.id,
name: '删除日志',
command: `ql rmlog ${data.info.frequency}`,
};
await scheduleService.createIntervalTask(cron, {
days: data.info.frequency,
runImmediately: true,
});
}
}; };
function randomSchedule(from: number, to: number) {
const result: any[] = [];
const arr = [...Array(from).keys()];
let count = arr.length;
for (let i = 0; i < to; i++) {
const index = ~~(Math.random() * count) + i;
if (result.includes(arr[index])) {
continue;
}
result[i] = arr[index];
arr[index] = arr[i];
count--;
}
return result;
}
+14
View File
@@ -1,5 +1,6 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import os from 'os';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import Logger from './logger'; import Logger from './logger';
import { fileExist } from '../config/util'; import { fileExist } from '../config/util';
@@ -9,11 +10,14 @@ const dataPath = path.join(rootPath, 'data/');
const configPath = path.join(dataPath, 'config/'); const configPath = path.join(dataPath, 'config/');
const scriptPath = path.join(dataPath, 'scripts/'); const scriptPath = path.join(dataPath, 'scripts/');
const logPath = path.join(dataPath, 'log/'); const logPath = path.join(dataPath, 'log/');
const uploadPath = path.join(dataPath, 'upload/');
const samplePath = path.join(rootPath, 'sample/'); const samplePath = path.join(rootPath, 'sample/');
const confFile = path.join(configPath, 'config.sh'); const confFile = path.join(configPath, 'config.sh');
const authConfigFile = path.join(configPath, 'auth.json'); const authConfigFile = path.join(configPath, 'auth.json');
const sampleConfigFile = path.join(samplePath, 'config.sample.sh'); const sampleConfigFile = path.join(samplePath, 'config.sample.sh');
const sampleAuthFile = path.join(samplePath, 'auth.sample.json'); const sampleAuthFile = path.join(samplePath, 'auth.sample.json');
const homedir = os.homedir();
const sshPath = path.resolve(homedir, '.ssh');
export default async () => { export default async () => {
const authFileExist = await fileExist(authConfigFile); const authFileExist = await fileExist(authConfigFile);
@@ -21,6 +25,8 @@ export default async () => {
const scriptDirExist = await fileExist(scriptPath); const scriptDirExist = await fileExist(scriptPath);
const logDirExist = await fileExist(logPath); const logDirExist = await fileExist(logPath);
const configDirExist = await fileExist(configPath); const configDirExist = await fileExist(configPath);
const uploadDirExist = await fileExist(uploadPath);
const sshDirExist = await fileExist(sshPath);
if (!configDirExist) { if (!configDirExist) {
fs.mkdirSync(configPath); fs.mkdirSync(configPath);
@@ -42,6 +48,14 @@ export default async () => {
fs.mkdirSync(logPath); fs.mkdirSync(logPath);
} }
if (!uploadDirExist) {
fs.mkdirSync(uploadPath);
}
if (!sshDirExist) {
fs.mkdirSync(sshPath);
}
dotenv.config({ path: confFile }); dotenv.config({ path: confFile });
Logger.info('✌️ Init file down'); Logger.info('✌️ Init file down');
+31
View File
@@ -0,0 +1,31 @@
import { Container } from 'typedi';
import _ from 'lodash';
import SystemService from '../services/system';
import ScheduleService from '../services/schedule';
import SubscriptionService from '../services/subscription';
export default async () => {
const systemService = Container.get(SystemService);
const scheduleService = Container.get(ScheduleService);
const subscriptionService = Container.get(SubscriptionService);
// 运行删除日志任务
const data = await systemService.getLogRemoveFrequency();
if (data && data.info && data.info.frequency) {
const cron = {
id: data.id,
name: '删除日志',
command: `ql rmlog ${data.info.frequency}`,
};
await scheduleService.createIntervalTask(cron, {
days: data.info.frequency,
runImmediately: true,
});
}
// 运行所有订阅
const subs = await subscriptionService.list();
for (const sub of subs) {
await subscriptionService.handleTask(sub);
}
};
+2 -6
View File
@@ -10,7 +10,7 @@ app.get('/api/public/panel/log', (req, res) => {
'pm2 logs panel --lines 500 --nostream --timestamp', 'pm2 logs panel --lines 500 --nostream --timestamp',
(err, stdout, stderr) => { (err, stdout, stderr) => {
if (err || stderr) { if (err || stderr) {
return res.send({ code: 400, data: err || stderr }); return res.send({ code: 400, message: (err && err.message) || stderr });
} }
return res.send({ code: 200, data: stdout }); return res.send({ code: 200, data: stdout });
}, },
@@ -22,11 +22,7 @@ app
await require('./loaders/sentry').default({ expressApp: app }); await require('./loaders/sentry').default({ expressApp: app });
await require('./loaders/db').default(); await require('./loaders/db').default();
Logger.info(` Logger.debug(`✌️ Back server launched on port ${config.publicPort}`);
################################################
🛡️ Public listening on port: ${config.publicPort} 🛡️
################################################
`);
}) })
.on('error', (err) => { .on('error', (err) => {
Logger.error(err); Logger.error(err);
+21 -17
View File
@@ -56,13 +56,13 @@ export default class DependenceService {
return await this.getDb({ id: payload.id }); return await this.getDb({ id: payload.id });
} }
public async remove(ids: number[]) { public async remove(ids: number[], force = false): Promise<Dependence[]> {
await DependenceModel.update( await DependenceModel.update(
{ status: DependenceStatus.removing, log: [] }, { status: DependenceStatus.removing, log: [] },
{ where: { id: ids } }, { where: { id: ids } },
); );
const docs = await DependenceModel.findAll({ where: { id: ids } }); const docs = await DependenceModel.findAll({ where: { id: ids } });
this.installOrUninstallDependencies(docs, false); this.installOrUninstallDependencies(docs, false, force);
return docs; return docs;
} }
@@ -105,7 +105,7 @@ export default class DependenceService {
); );
const docs = await DependenceModel.findAll({ where: { id: ids } }); const docs = await DependenceModel.findAll({ where: { id: ids } });
await this.installOrUninstallDependencies(docs); this.installOrUninstallDependencies(docs);
return docs; return docs;
} }
@@ -128,12 +128,16 @@ export default class DependenceService {
public installOrUninstallDependencies( public installOrUninstallDependencies(
dependencies: Dependence[], dependencies: Dependence[],
isInstall: boolean = true, isInstall: boolean = true,
force: boolean = false,
) { ) {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
if (dependencies.length === 0) { if (dependencies.length === 0) {
resolve(null); resolve(null);
return; return;
} }
const socketMessageType = !force
? 'installDependence'
: 'uninstallDependence';
const depNames = dependencies.map((x) => x.name).join(' '); const depNames = dependencies.map((x) => x.name).join(' ');
const depRunCommand = ( const depRunCommand = (
isInstall isInstall
@@ -142,24 +146,26 @@ export default class DependenceService {
)[dependencies[0].type as any]; )[dependencies[0].type as any];
const actionText = isInstall ? '安装' : '删除'; const actionText = isInstall ? '安装' : '删除';
const depIds = dependencies.map((x) => x.id) as number[]; const depIds = dependencies.map((x) => x.id) as number[];
const cp = spawn(`${depRunCommand} ${depNames}`, { shell: '/bin/bash' });
const startTime = Date.now(); const startTime = Date.now();
this.sockService.sendMessage({ this.sockService.sendMessage({
type: 'installDependence', type: socketMessageType,
message: `开始${actionText}依赖 ${depNames},开始时间 ${new Date( message: `开始${actionText}依赖 ${depNames},开始时间 ${new Date(
startTime, startTime,
).toLocaleString()}`, ).toLocaleString()}\n\n`,
references: depIds, references: depIds,
}); });
await this.updateLog( await this.updateLog(
depIds, depIds,
`开始${actionText}依赖 ${depNames},开始时间 ${new Date( `开始${actionText}依赖 ${depNames},开始时间 ${new Date(
startTime, startTime,
).toLocaleString()}\n`, ).toLocaleString()}\n\n`,
); );
const cp = spawn(`${depRunCommand} ${depNames}`, { shell: '/bin/bash' });
cp.stdout.on('data', async (data) => { cp.stdout.on('data', async (data) => {
this.sockService.sendMessage({ this.sockService.sendMessage({
type: 'installDependence', type: socketMessageType,
message: data.toString(), message: data.toString(),
references: depIds, references: depIds,
}); });
@@ -168,7 +174,7 @@ export default class DependenceService {
cp.stderr.on('data', async (data) => { cp.stderr.on('data', async (data) => {
this.sockService.sendMessage({ this.sockService.sendMessage({
type: 'installDependence', type: socketMessageType,
message: data.toString(), message: data.toString(),
references: depIds, references: depIds,
}); });
@@ -177,7 +183,7 @@ export default class DependenceService {
cp.on('error', async (err) => { cp.on('error', async (err) => {
this.sockService.sendMessage({ this.sockService.sendMessage({
type: 'installDependence', type: socketMessageType,
message: JSON.stringify(err), message: JSON.stringify(err),
references: depIds, references: depIds,
}); });
@@ -191,15 +197,15 @@ export default class DependenceService {
const resultText = isSucceed ? '成功' : '失败'; const resultText = isSucceed ? '成功' : '失败';
this.sockService.sendMessage({ this.sockService.sendMessage({
type: 'installDependence', type: socketMessageType,
message: `依赖${actionText}${resultText},结束时间 ${new Date( message: `\n依赖${actionText}${resultText},结束时间 ${new Date(
endTime, endTime,
).toLocaleString()},耗时 ${(endTime - startTime) / 1000}`, ).toLocaleString()},耗时 ${(endTime - startTime) / 1000}`,
references: depIds, references: depIds,
}); });
await this.updateLog( await this.updateLog(
depIds, depIds,
`依赖${actionText}${resultText},结束时间 ${new Date( `\n依赖${actionText}${resultText},结束时间 ${new Date(
endTime, endTime,
).toLocaleString()},耗时 ${(endTime - startTime) / 1000}`, ).toLocaleString()},耗时 ${(endTime - startTime) / 1000}`,
); );
@@ -216,11 +222,9 @@ export default class DependenceService {
} }
await DependenceModel.update({ status }, { where: { id: depIds } }); await DependenceModel.update({ status }, { where: { id: depIds } });
// 如果删除依赖成功3秒后删除数据库记录 // 如果删除依赖成功或者强制删除
if (isSucceed && !isInstall) { if ((isSucceed || force) && !isInstall) {
setTimeout(() => {
this.removeDb(depIds); this.removeDb(depIds);
}, 5000);
} }
resolve(null); resolve(null);
+5 -7
View File
@@ -168,13 +168,11 @@ export default class EnvService {
.filter((x) => x.status !== EnvStatus.disabled) .filter((x) => x.status !== EnvStatus.disabled)
.map('value') .map('value')
.join('&') .join('&')
.replace(/ /g, ''); .replace(/(\\)[^\n]/g, '\\\\')
if (/"/.test(value)) { .replace(/(\\$)/, '\\\\')
value = `'${value}'`; .replace(/"/g, '\\"')
} else { .trim();
value = `"${value}"`; env_string += `export ${key}="${value}"\n`;
}
env_string += `export ${key}=${value}\n`;
} }
} }
} }
+109 -72
View File
@@ -1,14 +1,35 @@
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import nodeSchedule from 'node-schedule'; import nodeSchedule from 'node-schedule';
import { Crontab } from '../data/cron'; import { ChildProcessWithoutNullStreams, exec, spawn } from 'child_process';
import { exec } from 'child_process';
import { import {
ToadScheduler, ToadScheduler,
SimpleIntervalJob, LongIntervalJob,
Task, AsyncTask,
SimpleIntervalSchedule, SimpleIntervalSchedule,
} from 'toad-scheduler'; } from 'toad-scheduler';
import dayjs from 'dayjs';
interface ScheduleTaskType {
id: number;
command: string;
name?: string;
schedule?: string;
}
export interface TaskCallbacks {
onStart?: (
cp: ChildProcessWithoutNullStreams,
startTime: dayjs.Dayjs,
) => Promise<void>;
onEnd?: (
cp: ChildProcessWithoutNullStreams,
endTime: dayjs.Dayjs,
diff: number,
) => Promise<void>;
onLog?: (message: string) => Promise<void>;
onError?: (message: string) => Promise<void>;
}
@Service() @Service()
export default class ScheduleService { export default class ScheduleService {
@@ -20,7 +41,72 @@ export default class ScheduleService {
constructor(@Inject('logger') private logger: winston.Logger) {} constructor(@Inject('logger') private logger: winston.Logger) {}
async createCronTask({ id = 0, command, name, schedule = '' }: Crontab) { async runTask(command: string, callbacks: TaskCallbacks = {}) {
return new Promise(async (resolve, reject) => {
try {
const startTime = dayjs();
const cp = spawn(command, { shell: '/bin/bash' });
// TODO:
callbacks.onStart?.(cp, startTime);
cp.stdout.on('data', async (data) => {
await callbacks.onLog?.(data.toString());
});
cp.stderr.on('data', async (data) => {
this.logger.error(
'执行任务 %s 失败,时间:%s, 错误信息:%j',
command,
new Date().toLocaleString(),
data.toString(),
);
await callbacks.onError?.(data.toString());
});
cp.on('error', async (err) => {
this.logger.error(
'创建任务 %s 失败,时间:%s, 错误信息:%j',
command,
new Date().toLocaleString(),
err,
);
await callbacks.onError?.(JSON.stringify(err));
});
cp.on('exit', async (code, signal) => {
this.logger.info(
`${command} pid: ${cp.pid} exit ${code} signal ${signal}`,
);
});
cp.on('close', async (code) => {
const endTime = dayjs();
this.logger.info(`${command} pid: ${cp.pid} closed ${code}`);
await callbacks.onEnd?.(
cp,
endTime,
endTime.diff(startTime, 'seconds'),
);
resolve(null);
});
} catch (error) {
await this.logger.error(
'执行任务%s失败,时间:%s, 错误信息:%j',
command,
new Date().toLocaleString(),
error,
);
await callbacks.onError?.(JSON.stringify(error));
resolve(null);
}
});
}
async createCronTask(
{ id = 0, command, name, schedule = '' }: ScheduleTaskType,
callbacks?: TaskCallbacks,
) {
const _id = this.formatId(id); const _id = this.formatId(id);
this.logger.info( this.logger.info(
'[创建cron任务],任务ID: %scron: %s,任务名: %s,执行命令: %s', '[创建cron任务],任务ID: %scron: %s,任务名: %s,执行命令: %s',
@@ -32,53 +118,23 @@ export default class ScheduleService {
this.scheduleStacks.set( this.scheduleStacks.set(
_id, _id,
nodeSchedule.scheduleJob(id + '', schedule, async () => { nodeSchedule.scheduleJob(_id, schedule, async () => {
try { await this.runTask(command, callbacks);
exec(
command,
{ maxBuffer: this.maxBuffer },
async (error, stdout, stderr) => {
if (error) {
await this.logger.info(
'执行任务%s失败,时间:%s, 错误信息:%j',
command,
new Date().toLocaleString(),
error,
);
}
if (stderr) {
await this.logger.info(
'执行任务%s失败,时间:%s, 错误信息:%j',
command,
new Date().toLocaleString(),
stderr,
);
}
},
);
} catch (error) {
await this.logger.info(
'执行任务%s失败,时间:%s, 错误信息:%j',
command,
new Date().toLocaleString(),
error,
);
} finally {
}
}), }),
); );
} }
async cancelCronTask({ id = 0, name }: Crontab) { async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id); const _id = this.formatId(id);
this.logger.info('[取消定时任务],任务名:%s', name); this.logger.info('[取消定时任务],任务名:%s', name);
this.scheduleStacks.has(_id) && this.scheduleStacks.get(_id)?.cancel(); this.scheduleStacks.has(_id) && this.scheduleStacks.get(_id)?.cancel();
} }
async createIntervalTask( async createIntervalTask(
{ id = 0, command, name = '' }: Crontab, { id = 0, command, name = '' }: ScheduleTaskType,
schedule: SimpleIntervalSchedule, schedule: SimpleIntervalSchedule,
runImmediately = true,
callbacks?: TaskCallbacks,
) { ) {
const _id = this.formatId(id); const _id = this.formatId(id);
this.logger.info( this.logger.info(
@@ -87,48 +143,29 @@ export default class ScheduleService {
name, name,
command, command,
); );
const task = new Task(name, async () => { const task = new AsyncTask(
try { name,
exec( async () => {
command, return new Promise(async (resolve, reject) => {
{ maxBuffer: this.maxBuffer }, await this.runTask(command, callbacks);
async (error, stdout, stderr) => { });
if (error) { },
await this.logger.info( (err) => {
this.logger.error(
'执行任务%s失败,时间:%s, 错误信息:%j', '执行任务%s失败,时间:%s, 错误信息:%j',
command, command,
new Date().toLocaleString(), new Date().toLocaleString(),
error, err,
); );
}
if (stderr) {
await this.logger.info(
'执行任务%s失败,时间:%s, 错误信息:%j',
command,
new Date().toLocaleString(),
stderr,
);
}
}, },
); );
} catch (error) {
await this.logger.info(
'执行任务%s失败,时间:%s, 错误信息:%j',
command,
new Date().toLocaleString(),
error,
);
} finally {
}
});
const job = new SimpleIntervalJob({ ...schedule }, task, _id); const job = new LongIntervalJob({ ...schedule, runImmediately }, task, _id);
this.intervalSchedule.addIntervalJob(job); this.intervalSchedule.addIntervalJob(job);
} }
async cancelIntervalTask({ id = 0, name }: Crontab) { async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id); const _id = this.formatId(id);
this.logger.info('[取消interval任务],任务ID: %s,任务名:%s', _id, name); this.logger.info('[取消interval任务],任务ID: %s,任务名:%s', _id, name);
this.intervalSchedule.removeById(_id); this.intervalSchedule.removeById(_id);
+76
View File
@@ -0,0 +1,76 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import fs from 'fs';
import os from 'os';
import path from 'path';
@Service()
export default class SshKeyService {
private homedir = os.homedir();
private sshPath = path.resolve(this.homedir, '.ssh');
private sshConfigFilePath = path.resolve(this.sshPath, 'config');
constructor(@Inject('logger') private logger: winston.Logger) {}
private generatePrivateKeyFile(alias: string, key: string): void {
try {
fs.writeFileSync(`${this.sshPath}/${alias}`, key, {
encoding: 'utf8',
mode: '400',
});
} catch (error) {
this.logger.error('生成私钥文件失败', error);
}
}
private removePrivateKeyFile(alias: string): void {
try {
fs.unlinkSync(`${this.sshPath}/${alias}`);
} catch (error) {
this.logger.error('删除私钥文件失败', error);
}
}
private generateSingleSshConfig(alias: string, host: string): string {
return `\nHost ${alias}\n Hostname ${host}\n IdentityFile ${this.sshPath}/${alias}\n StrictHostKeyChecking no`;
}
private generateSshConfig(configs: string[]) {
try {
for (const config of configs) {
fs.appendFileSync(this.sshConfigFilePath, config, {
encoding: 'utf8',
});
}
} catch (error) {
this.logger.error('写入ssh配置文件失败', error);
}
}
private removeSshConfig(config: string) {
try {
const data = fs
.readFileSync(this.sshConfigFilePath, { encoding: 'utf8' })
.replace(config, '')
.replace(/\n\n+/, '\n\n');
fs.writeFileSync(this.sshConfigFilePath, data, {
encoding: 'utf8',
});
} catch (error) {
this.logger.error(`删除ssh配置文件${config}失败`, error);
}
}
public addSSHKey(key: string, alias: string, host: string): void {
this.generatePrivateKeyFile(alias, key);
const config = this.generateSingleSshConfig(alias, host);
this.removeSshConfig(config);
this.generateSshConfig([config]);
}
public removeSSHKey(alias: string, host: string): void {
this.removePrivateKeyFile(alias);
const config = this.generateSingleSshConfig(alias, host);
this.removeSshConfig(config);
}
}
+458
View File
@@ -0,0 +1,458 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import config from '../config';
import {
Subscription,
SubscriptionModel,
SubscriptionStatus,
} from '../data/subscription';
import {
ChildProcessWithoutNullStreams,
exec,
execSync,
spawn,
} from 'child_process';
import fs from 'fs';
import cron_parser from 'cron-parser';
import {
getFileContentByName,
concurrentRun,
fileExist,
createFile,
} from '../config/util';
import { promises, existsSync } from 'fs';
import { promisify } from 'util';
import { Op } from 'sequelize';
import path from 'path';
import ScheduleService, { TaskCallbacks } from './schedule';
import { SimpleIntervalSchedule } from 'toad-scheduler';
import SockService from './sock';
import SshKeyService from './sshKey';
@Service()
export default class SubscriptionService {
constructor(
@Inject('logger') private logger: winston.Logger,
private scheduleService: ScheduleService,
private sockService: SockService,
private sshKeyService: SshKeyService,
) {}
public async list(searchText?: string): Promise<Subscription[]> {
let query = {};
if (searchText) {
const textArray = searchText.split(':');
switch (textArray[0]) {
case 'name':
case 'command':
case 'schedule':
case 'label':
const column = textArray[0] === 'label' ? 'labels' : textArray[0];
query = {
[column]: {
[Op.or]: [
{ [Op.like]: `%${textArray[1]}%` },
{ [Op.like]: `%${encodeURIComponent(textArray[1])}%` },
],
},
};
break;
default:
const reg = {
[Op.or]: [
{ [Op.like]: `%${searchText}%` },
{ [Op.like]: `%${encodeURIComponent(searchText)}%` },
],
};
query = {
[Op.or]: [
{
name: reg,
},
{
command: reg,
},
{
schedule: reg,
},
{
labels: reg,
},
],
};
break;
}
}
try {
const result = await SubscriptionModel.findAll({
where: query,
order: [['createdAt', 'DESC']],
});
return result as any;
} catch (error) {
throw error;
}
}
private formatCommand(doc: Subscription, url?: string) {
let command = 'ql ';
let _url = url || this.formatUrl(doc).url;
const { type, whitelist, blacklist, dependences, branch, extensions } = doc;
if (type === 'file') {
command += `raw "${_url}"`;
} else {
command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${
dependences || ''
}" "${branch || ''}" "${extensions || ''}"`;
}
return command;
}
private formatUrl(doc: Subscription) {
let url = doc.url;
let host = '';
if (doc.type === 'private-repo') {
if (doc.pull_type === 'ssh-key') {
host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1');
url = doc.url!.replace(host, doc.alias);
} else {
host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1');
const { username, password } = doc.pull_option as any;
url = doc.url!.replace(host, `${username}:${password}@${host}`);
}
}
return { url, host };
}
public handleTask(doc: Subscription, needCreate = true, needAddKey = true) {
const { url, host } = this.formatUrl(doc);
if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') {
if (needAddKey) {
this.sshKeyService.addSSHKey(
(doc.pull_option as any).private_key,
doc.alias,
host,
);
} else {
this.sshKeyService.removeSSHKey(doc.alias, host);
}
}
doc.command = this.formatCommand(doc, url as string);
if (doc.schedule_type === 'crontab') {
this.scheduleService.cancelCronTask(doc as any);
needCreate &&
this.scheduleService.createCronTask(
doc as any,
this.taskCallbacks(doc),
);
} else {
this.scheduleService.cancelIntervalTask(doc as any);
const { type, value } = doc.interval_schedule as any;
needCreate &&
this.scheduleService.createIntervalTask(
doc as any,
{ [type]: value } as SimpleIntervalSchedule,
true,
this.taskCallbacks(doc),
);
}
}
private async handleLogPath(
logPath: string,
data: string = '',
): Promise<string> {
const absolutePath = path.resolve(config.logPath, logPath);
const logFileExist = await fileExist(absolutePath);
if (!logFileExist) {
await createFile(absolutePath, data);
}
return absolutePath;
}
private taskCallbacks(doc: Subscription): TaskCallbacks {
return {
onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => {
// 执行sub_before
let beforeStr = '';
try {
if (doc.sub_before) {
beforeStr = execSync(doc.sub_before).toString();
}
} catch (error) {
beforeStr = JSON.stringify(error);
}
if (beforeStr) {
beforeStr += '\n';
}
const logTime = startTime.format('YYYY-MM-DD-HH-mm-ss');
const logPath = `${doc.alias}/${logTime}.log`;
await this.handleLogPath(
logPath as string,
`${beforeStr}## 开始执行... ${startTime.format(
'YYYY-MM-DD HH:mm:ss',
)}\n`,
);
await SubscriptionModel.update(
{
status: SubscriptionStatus.running,
log_path: logPath,
pid: cp.pid,
},
{ where: { id: doc.id } },
);
},
onEnd: async (cp, endTime, diff) => {
const sub = await this.getDb({ id: doc.id });
await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: undefined },
{ where: { id: sub.id } },
);
const absolutePath = await this.handleLogPath(sub.log_path as string);
fs.appendFileSync(
absolutePath,
`\n## 执行结束... ${endTime.format(
'YYYY-MM-DD HH:mm:ss',
)} 耗时 ${diff}`,
);
// 执行 sub_after
let afterStr = '';
try {
if (sub.sub_after) {
afterStr = execSync(sub.sub_after).toString();
}
} catch (error) {
afterStr = JSON.stringify(error);
}
if (afterStr) {
afterStr = `\n\n${afterStr}`;
const absolutePath = await this.handleLogPath(sub.log_path as string);
fs.appendFileSync(absolutePath, afterStr);
}
this.sockService.sendMessage({
type: 'runSubscriptionEnd',
message: '订阅执行完成',
references: [doc.id as number],
});
},
onError: async (message: string) => {
const sub = await this.getDb({ id: doc.id });
const absolutePath = await this.handleLogPath(sub.log_path as string);
fs.appendFileSync(absolutePath, `\n${message}`);
},
onLog: async (message: string) => {
const sub = await this.getDb({ id: doc.id });
const absolutePath = await this.handleLogPath(sub.log_path as string);
fs.appendFileSync(absolutePath, `\n${message}`);
},
};
}
public async create(payload: Subscription): Promise<Subscription> {
const tab = new Subscription(payload);
const doc = await this.insert(tab);
this.handleTask(doc);
return doc;
}
public async insert(payload: Subscription): Promise<Subscription> {
return await SubscriptionModel.create(payload, { returning: true });
}
public async update(payload: Subscription): Promise<Subscription> {
const newDoc = await this.updateDb(payload);
this.handleTask(newDoc);
return newDoc;
}
public async updateDb(payload: Subscription): Promise<Subscription> {
await SubscriptionModel.update(payload, { where: { id: payload.id } });
return await this.getDb({ id: payload.id });
}
public async status({
ids,
status,
pid,
log_path,
last_running_time = 0,
last_execution_time = 0,
}: {
ids: number[];
status: SubscriptionStatus;
pid: number;
log_path: string;
last_running_time: number;
last_execution_time: number;
}) {
const options: any = {
status,
pid,
log_path,
last_execution_time,
};
if (last_running_time > 0) {
options.last_running_time = last_running_time;
}
return await SubscriptionModel.update(
{ ...options },
{ where: { id: ids } },
);
}
public async remove(ids: number[]) {
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
for (const doc of docs) {
this.handleTask(doc, false, false);
}
await SubscriptionModel.destroy({ where: { id: ids } });
}
public async getDb(query: any): Promise<Subscription> {
const doc: any = await SubscriptionModel.findOne({ where: { ...query } });
return doc && (doc.get({ plain: true }) as Subscription);
}
public async run(ids: number[]) {
await SubscriptionModel.update(
{ status: SubscriptionStatus.queued },
{ where: { id: ids } },
);
concurrentRun(
ids.map((id) => async () => await this.runSingle(id)),
10,
);
}
public async stop(ids: number[]) {
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
for (const doc of docs) {
if (doc.pid) {
try {
process.kill(-doc.pid);
} catch (error) {
this.logger.silly(error);
}
}
this.handleTask(doc, false);
const command = this.formatCommand(doc);
const err = await this.killTask(command);
const absolutePath = await this.handleLogPath(doc.log_path as string);
const str = err ? `\n${err}` : '';
fs.appendFileSync(
`${absolutePath}`,
`${str}\n## 执行结束... ${new Date()
.toLocaleString('zh', { hour12: false })
.replace(' 24:', ' 00:')} `,
);
}
await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: undefined },
{ where: { id: ids } },
);
}
public async killTask(name: string) {
let taskCommand = `ps -ef | grep "${name}" | grep -v grep | awk '{print $1}'`;
const execAsync = promisify(exec);
try {
let pid = (await execAsync(taskCommand)).stdout;
if (pid) {
pid = (await execAsync(`pstree -p ${pid}`)).stdout;
} else {
return;
}
let pids = pid.match(/\(\d+/g);
const killLogs = [];
if (pids && pids.length > 0) {
// node 执行脚本时还会有10个子进程,但是ps -ef中不存在,所以截取前三个
for (const id of pids) {
const c = `kill -9 ${id.slice(1)}`;
try {
const { stdout, stderr } = await execAsync(c);
if (stderr) {
killLogs.push(stderr);
}
if (stdout) {
killLogs.push(stdout);
}
} catch (error: any) {
killLogs.push(error.message);
}
}
}
return killLogs.length > 0 ? JSON.stringify(killLogs) : '';
} catch (e) {
return JSON.stringify(e);
}
}
private async runSingle(subscriptionId: number) {
const subscription = await this.getDb({ id: subscriptionId });
if (subscription.status !== SubscriptionStatus.queued) {
return;
}
const command = this.formatCommand(subscription);
await this.scheduleService.runTask(
command,
this.taskCallbacks(subscription),
);
}
public async disabled(ids: number[]) {
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
for (const doc of docs) {
this.handleTask(doc, false);
}
await SubscriptionModel.update({ is_disabled: 1 }, { where: { id: ids } });
}
public async enabled(ids: number[]) {
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
for (const doc of docs) {
this.handleTask(doc);
}
await SubscriptionModel.update({ is_disabled: 0 }, { where: { id: ids } });
}
public async log(id: number) {
const doc = await this.getDb({ id });
if (!doc) {
return '';
}
const absolutePath = await this.handleLogPath(doc.log_path as string);
return getFileContentByName(absolutePath);
}
public async logs(id: number) {
const doc = await this.getDb({ id });
if (!doc) {
return [];
}
if (doc.log_path) {
const relativeDir = path.dirname(`${doc.log_path}`);
const dir = path.resolve(config.logPath, relativeDir);
if (existsSync(dir)) {
let files = await promises.readdir(dir);
return files
.map((x) => ({
filename: x,
directory: relativeDir.replace(config.logPath, ''),
time: fs.statSync(`${dir}/${x}`).mtime.getTime(),
}))
.sort((a, b) => b.time - a.time);
}
}
}
}
+6 -4
View File
@@ -52,12 +52,14 @@ export default class SystemService {
}); });
return { code: 200, data: { ...result, code } }; return { code: 200, data: { ...result, code } };
} else { } else {
return { code: 400, data: '通知发送失败,请检查参数' }; return { code: 400, message: '通知发送失败,请检查参数' };
} }
} }
public async updateLogRemoveFrequency(frequency: number) { public async updateLogRemoveFrequency(frequency: number) {
const oDoc = await this.getLogRemoveFrequency();
const result = await this.updateAuthDb({ const result = await this.updateAuthDb({
...oDoc,
type: AuthDataType.removeLogFrequency, type: AuthDataType.removeLogFrequency,
info: { frequency }, info: { frequency },
}); });
@@ -109,7 +111,7 @@ export default class SystemService {
} catch (error: any) { } catch (error: any) {
return { return {
code: 400, code: 400,
data: error.message, message: error.message,
}; };
} }
} }
@@ -167,9 +169,9 @@ export default class SystemService {
public async notify({ title, content }: { title: string; content: string }) { public async notify({ title, content }: { title: string; content: string }) {
const isSuccess = await this.notificationService.notify(title, content); const isSuccess = await this.notificationService.notify(title, content);
if (isSuccess) { if (isSuccess) {
return { code: 200, data: '通知发送成功' }; return { code: 200, message: '通知发送成功' };
} else { } else {
return { code: 400, data: '通知发送失败,请检查参数' }; return { code: 400, message: '通知发送失败,请检查系统设置/通知配置' };
} }
} }
} }
+7 -1
View File
@@ -221,6 +221,12 @@ export default class UserService {
return { code: 200, message: '更新成功' }; return { code: 200, message: '更新成功' };
} }
public async updateAvatar(avatar: string) {
const authInfo = this.getAuthInfo();
this.updateAuthInfo(authInfo, { avatar });
return { code: 200, data: avatar, message: '更新成功' };
}
public getUserInfo(): Promise<any> { public getUserInfo(): Promise<any> {
return new Promise((resolve) => { return new Promise((resolve) => {
fs.readFile(config.authConfigFile, 'utf8', (err, data) => { fs.readFile(config.authConfigFile, 'utf8', (err, data) => {
@@ -340,7 +346,7 @@ export default class UserService {
}); });
return { code: 200, data: { ...result, code } }; return { code: 200, data: { ...result, code } };
} else { } else {
return { code: 400, data: '通知发送失败,请检查参数' }; return { code: 400, message: '通知发送失败,请检查参数' };
} }
} }
} }
+5 -4
View File
@@ -5,7 +5,7 @@ LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
ARG QL_BRANCH=master ARG QL_BRANCH=master
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/share/pnpm:/root/.local/share/pnpm/global/5/node_modules \
LANG=zh_CN.UTF-8 \ LANG=zh_CN.UTF-8 \
SHELL=/bin/bash \ SHELL=/bin/bash \
PS1="\u@\h:\w \$ " \ PS1="\u@\h:\w \$ " \
@@ -35,12 +35,12 @@ RUN set -x \
&& rm -rf /var/cache/apk/* \ && rm -rf /var/cache/apk/* \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \ && echo "Asia/Shanghai" > /etc/timezone \
&& touch ~/.bashrc \
&& git config --global user.email "qinglong@@users.noreply.github.com" \ && git config --global user.email "qinglong@@users.noreply.github.com" \
&& git config --global user.name "qinglong" \ && git config --global user.name "qinglong" \
&& npm install -g pnpm \ && npm install -g pnpm \
&& pnpm install -g pm2 \ && pnpm setup \
&& pnpm install -g ts-node typescript tslib \ && source ~/.bashrc \
&& pnpm add -g pm2 ts-node typescript tslib \
&& git clone -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \ && git clone -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
&& cd ${QL_DIR} \ && cd ${QL_DIR} \
&& cp -f .env.example .env \ && cp -f .env.example .env \
@@ -48,6 +48,7 @@ RUN set -x \
&& chmod 777 ${QL_DIR}/docker/*.sh \ && chmod 777 ${QL_DIR}/docker/*.sh \
&& pnpm install --prod \ && pnpm install --prod \
&& rm -rf /root/.pnpm-store \ && rm -rf /root/.pnpm-store \
&& rm -rf /root/.local/share/pnpm/store \
&& rm -rf /root/.cache \ && rm -rf /root/.cache \
&& rm -rf /root/.npm \ && rm -rf /root/.npm \
&& git clone -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \ && git clone -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \
+22 -6
View File
@@ -11,7 +11,7 @@
"schedule": "npm run build:back && node static/build/schedule.js", "schedule": "npm run build:back && node static/build/schedule.js",
"public": "npm run build:back && node static/build/public.js", "public": "npm run build:back && node static/build/public.js",
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'", "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
"prepare": "umi generate tmp", "postinstall": "umi generate tmp 2>/dev/null || true",
"test": "umi-test", "test": "umi-test",
"test:coverage": "umi-test --coverage" "test:coverage": "umi-test --coverage"
}, },
@@ -26,6 +26,18 @@
"prettier --parser=typescript --write" "prettier --parser=typescript --write"
] ]
}, },
"pnpm": {
"peerDependencyRules": {
"allowedVersions": {
"react": "17",
"react-dom": "17"
},
"ignoreMissing": [
"monaco-editor",
"react-router"
]
}
},
"dependencies": { "dependencies": {
"@otplib/preset-default": "^12.0.1", "@otplib/preset-default": "^12.0.1",
"@sentry/node": "^6.18.1", "@sentry/node": "^6.18.1",
@@ -35,6 +47,7 @@
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"cron-parser": "^4.2.1", "cron-parser": "^4.2.1",
"dayjs": "^1.11.2",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"express": "^4.17.3", "express": "^4.17.3",
"express-jwt": "^6.1.1", "express-jwt": "^6.1.1",
@@ -44,8 +57,8 @@
"iconv-lite": "^0.6.3", "iconv-lite": "^0.6.3",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"multer": "^1.4.4",
"nedb": "^1.8.0", "nedb": "^1.8.0",
"node-fetch": "^3.2.1",
"node-schedule": "^2.1.0", "node-schedule": "^2.1.0",
"nodemailer": "^6.7.2", "nodemailer": "^6.7.2",
"p-queue": "7.2.0", "p-queue": "7.2.0",
@@ -53,7 +66,7 @@
"sequelize": "^6.17.0", "sequelize": "^6.17.0",
"serve-handler": "^6.1.3", "serve-handler": "^6.1.3",
"sockjs": "^0.3.24", "sockjs": "^0.3.24",
"sqlite3": "npm:@louislam/sqlite3@^6.0.1", "sqlite3": "npm:@louislam/sqlite3@^15.0.3",
"toad-scheduler": "^1.6.0", "toad-scheduler": "^1.6.0",
"typedi": "^0.10.0", "typedi": "^0.10.0",
"uuid": "^8.3.2", "uuid": "^8.3.2",
@@ -65,14 +78,15 @@
"@ant-design/pro-layout": "^6.33.1", "@ant-design/pro-layout": "^6.33.1",
"@monaco-editor/react": "^4.3.1", "@monaco-editor/react": "^4.3.1",
"@sentry/react": "^6.18.1", "@sentry/react": "^6.18.1",
"@types/body-parser": "^1.19.2",
"@types/cors": "^2.8.12", "@types/cors": "^2.8.12",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/express-jwt": "^6.0.4", "@types/express-jwt": "^6.0.4",
"@types/jsonwebtoken": "^8.5.8", "@types/jsonwebtoken": "^8.5.8",
"@types/lodash": "^4.14.179", "@types/lodash": "^4.14.179",
"@types/multer": "^1.4.7",
"@types/nedb": "^1.8.12", "@types/nedb": "^1.8.12",
"@types/node": "^17.0.21", "@types/node": "^17.0.21",
"@types/node-fetch": "^2.6.1",
"@types/node-schedule": "^1.3.2", "@types/node-schedule": "^1.3.2",
"@types/nodemailer": "^6.4.4", "@types/nodemailer": "^6.4.4",
"@types/qrcode.react": "^1.0.2", "@types/qrcode.react": "^1.0.2",
@@ -84,18 +98,20 @@
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"@umijs/plugin-antd": "^0.15.0", "@umijs/plugin-antd": "^0.15.0",
"@umijs/plugin-esbuild": "^1.4.1", "@umijs/plugin-esbuild": "^1.4.1",
"@umijs/ssr-darkreader": "^4.9.45",
"@umijs/test": "^3.5.21", "@umijs/test": "^3.5.21",
"ansi-to-react": "^6.1.6", "ansi-to-react": "^6.1.6",
"antd": "^4.18.9", "antd": "^4.20.5",
"antd-img-crop": "^4.2.3",
"codemirror": "^5.65.2", "codemirror": "^5.65.2",
"compression-webpack-plugin": "9.2.0", "compression-webpack-plugin": "9.2.0",
"concurrently": "^7.0.0", "concurrently": "^7.0.0",
"darkreader": "4.9.44",
"lint-staged": "^12.3.4", "lint-staged": "^12.3.4",
"nodemon": "^2.0.15", "nodemon": "^2.0.15",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"qiniu": "^7.4.0", "qiniu": "^7.4.0",
"qrcode.react": "^1.0.1", "qrcode.react": "^1.0.1",
"rc-tween-one": "^3.0.6",
"react": "17.x", "react": "17.x",
"react-codemirror2": "^7.2.1", "react-codemirror2": "^7.2.1",
"react-diff-viewer": "^3.1.1", "react-diff-viewer": "^3.1.1",
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
## 添加你需要重启自动执行的任意命令,比如 ql repo ## 添加你需要重启自动执行的任意命令,比如 ql repo
## 安装node依赖使用 pnpm install -g xxx xxx ## 安装node依赖使用 pnpm add -g xxx xxx
## 安装python依赖使用 pip3 install xxx ## 安装python依赖使用 pip3 install xxx
+1 -11
View File
@@ -34,16 +34,6 @@ copy_dep() {
echo -e "---> 配置文件复制完成\n" echo -e "---> 配置文件复制完成\n"
} }
reload_pm2() {
pm2 l &>/dev/null
pm2 delete panel --source-map-support --time &>/dev/null
pm2 start $dir_static/build/app.js -n panel --source-map-support --time &>/dev/null
pm2 delete schedule --source-map-support --time &>/dev/null
pm2 start $dir_static/build/schedule.js -n schedule --source-map-support --time &>/dev/null
}
pm2_log() { pm2_log() {
echo -e "---> pm2日志" echo -e "---> pm2日志"
local panelOut="/root/.pm2/logs/panel-out.log" local panelOut="/root/.pm2/logs/panel-out.log"
@@ -98,7 +88,7 @@ check_pm2() {
main() { main() {
echo -e "=====> 开始检测" echo -e "=====> 开始检测"
pnpm install -g pm2 pnpm add -g pm2
copy_dep copy_dep
check_ql check_ql
check_nginx check_nginx
+18 -2
View File
@@ -61,7 +61,7 @@ original_name=(
) )
init_env() { init_env() {
export NODE_PATH=/usr/local/bin:/usr/local/pnpm-global/5/node_modules:/usr/local/lib/node_modules export NODE_PATH=/usr/local/bin:/usr/local/pnpm-global/5/node_modules:/usr/local/lib/node_modules:/root/.local/share/pnpm/global/5/node_modules
export PYTHONUNBUFFERED=1 export PYTHONUNBUFFERED=1
} }
@@ -252,7 +252,7 @@ npm_install_sub() {
elif ! type pnpm &>/dev/null; then elif ! type pnpm &>/dev/null; then
npm install --production --registry=https://registry.npm.taobao.org || npm install --production npm install --production --registry=https://registry.npm.taobao.org || npm install --production
else else
pnpm install --production --registry=https://registry.npm.taobao.org || pnpm install --production pnpm install --loglevel error --production --registry=https://registry.npm.taobao.org || pnpm install --production --loglevel error
fi fi
unset_proxy unset_proxy
} }
@@ -364,6 +364,22 @@ random_range() {
echo $((RANDOM % ($end - $beg) + $beg)) echo $((RANDOM % ($end - $beg) + $beg))
} }
reload_pm2() {
pm2 l &>/dev/null
echo -e "启动面板服务\n"
pm2 delete panel --source-map-support --time &>/dev/null
pm2 start $dir_static/build/app.js -n panel --source-map-support --time &>/dev/null
echo -e "启动定时任务服务\n"
pm2 delete schedule --source-map-support --time &>/dev/null
pm2 start $dir_static/build/schedule.js -n schedule --source-map-support --time &>/dev/null
echo -e "启动公开服务\n"
pm2 delete public --source-map-support --time &>/dev/null
pm2 start $dir_static/build/public.js -n public --source-map-support --time &>/dev/null
}
init_env init_env
detect_termux detect_termux
detect_macos detect_macos
+16 -12
View File
@@ -84,7 +84,8 @@ run_normal() {
fi fi
fi fi
log_time=$(date "+%Y-%m-%d-%H-%M-%S") local time=$(date)
log_time=$(date -d "$time" "+%Y-%m-%d-%H-%M-%S")
log_dir_tmp="${file_param##*/}" log_dir_tmp="${file_param##*/}"
if [[ $file_param =~ "/" ]]; then if [[ $file_param =~ "/" ]]; then
if [[ $file_param == /* ]]; then if [[ $file_param == /* ]]; then
@@ -102,8 +103,8 @@ run_normal() {
[[ "$show_log" == "true" ]] && cmd="" [[ "$show_log" == "true" ]] && cmd=""
make_dir "$dir_log/$log_dir" make_dir "$dir_log/$log_dir"
local begin_time=$(date '+%Y-%m-%d %H:%M:%S') local begin_time=$(date -d "$time" "+%Y-%m-%d %H:%M:%S")
local begin_timestamp=$(date "+%s") local begin_timestamp=$(date -d "$time" "+%s")
eval echo -e "\#\# 开始执行... $begin_time\\\n" $cmd eval echo -e "\#\# 开始执行... $begin_time\\\n" $cmd
[[ -f $task_error_log_path ]] && eval cat $task_error_log_path $cmd [[ -f $task_error_log_path ]] && eval cat $task_error_log_path $cmd
@@ -153,7 +154,8 @@ run_concurrent() {
[[ ! -z $cookieStr ]] && export ${env_param}=${cookieStr} [[ ! -z $cookieStr ]] && export ${env_param}=${cookieStr}
define_program "$file_param" define_program "$file_param"
log_time=$(date "+%Y-%m-%d-%H-%M-%S") local time=$(date)
log_time=$(date -d "$time" "+%Y-%m-%d-%H-%M-%S")
log_dir_tmp="${file_param##*/}" log_dir_tmp="${file_param##*/}"
if [[ $file_param =~ "/" ]]; then if [[ $file_param =~ "/" ]]; then
if [[ $file_param == /* ]]; then if [[ $file_param == /* ]]; then
@@ -171,8 +173,8 @@ run_concurrent() {
[[ "$show_log" == "true" ]] && cmd="" [[ "$show_log" == "true" ]] && cmd=""
make_dir "$dir_log/$log_dir" make_dir "$dir_log/$log_dir"
local begin_time=$(date '+%Y-%m-%d %H:%M:%S') local begin_time=$(date -d "$time" "+%Y-%m-%d %H:%M:%S")
local begin_timestamp=$(date "+%s") local begin_timestamp=$(date -d "$time" "+%s")
eval echo -e "\#\# 开始执行... $begin_time\\\n" $cmd eval echo -e "\#\# 开始执行... $begin_time\\\n" $cmd
[[ -f $task_error_log_path ]] && eval cat $task_error_log_path $cmd [[ -f $task_error_log_path ]] && eval cat $task_error_log_path $cmd
@@ -222,7 +224,8 @@ run_designated() {
fi fi
define_program "$file_param" define_program "$file_param"
log_time=$(date "+%Y-%m-%d-%H-%M-%S") local time=$(date)
log_time=$(date -d "$time" "+%Y-%m-%d-%H-%M-%S")
log_dir_tmp="${file_param##*/}" log_dir_tmp="${file_param##*/}"
if [[ $file_param =~ "/" ]]; then if [[ $file_param =~ "/" ]]; then
if [[ $file_param == /* ]]; then if [[ $file_param == /* ]]; then
@@ -240,8 +243,8 @@ run_designated() {
[[ "$show_log" == "true" ]] && cmd="" [[ "$show_log" == "true" ]] && cmd=""
make_dir "$dir_log/$log_dir" make_dir "$dir_log/$log_dir"
local begin_time=$(date '+%Y-%m-%d %H:%M:%S') local begin_time=$(date -d "$time" "+%Y-%m-%d %H:%M:%S")
local begin_timestamp=$(date "+%s") local begin_timestamp=$(date -d "$time" "+%s")
local envs=$(eval echo "\$${env_param}") local envs=$(eval echo "\$${env_param}")
local array=($(echo $envs | sed 's/&/ /g')) local array=($(echo $envs | sed 's/&/ /g'))
@@ -285,7 +288,8 @@ run_designated() {
run_else() { run_else() {
local file_param="$1" local file_param="$1"
define_program "$file_param" define_program "$file_param"
log_time=$(date "+%Y-%m-%d-%H-%M-%S") local time=$(date)
log_time=$(date -d "$time" "+%Y-%m-%d-%H-%M-%S")
log_dir_tmp="${file_param##*/}" log_dir_tmp="${file_param##*/}"
if [[ $file_param =~ "/" ]]; then if [[ $file_param =~ "/" ]]; then
if [[ $file_param == /* ]]; then if [[ $file_param == /* ]]; then
@@ -303,8 +307,8 @@ run_else() {
[[ "$show_log" == "true" ]] && cmd="" [[ "$show_log" == "true" ]] && cmd=""
make_dir "$dir_log/$log_dir" make_dir "$dir_log/$log_dir"
local begin_time=$(date '+%Y-%m-%d %H:%M:%S') local begin_time=$(date -d "$time" "+%Y-%m-%d %H:%M:%S")
local begin_timestamp=$(date "+%s") local begin_timestamp=$(date -d "$time" "+%s")
eval echo -e "\#\# 开始执行... $begin_time\\\n" $cmd eval echo -e "\#\# 开始执行... $begin_time\\\n" $cmd
[[ -f $task_error_log_path ]] && eval cat $task_error_log_path $cmd [[ -f $task_error_log_path ]] && eval cat $task_error_log_path $cmd
+17 -21
View File
@@ -141,6 +141,7 @@ update_repo() {
local blackword="$3" local blackword="$3"
local dependence="$4" local dependence="$4"
local branch="$5" local branch="$5"
local extensions="$6"
local tmp="${url%/*}" local tmp="${url%/*}"
local authorTmp1="${tmp##*/}" local authorTmp1="${tmp##*/}"
local authorTmp2="${authorTmp1##*:}" local authorTmp2="${authorTmp1##*:}"
@@ -159,7 +160,7 @@ update_repo() {
fi fi
if [[ $exit_status -eq 0 ]]; then if [[ $exit_status -eq 0 ]]; then
echo -e "\n更新${repo_path}成功...\n" echo -e "\n更新${repo_path}成功...\n"
diff_scripts "$repo_path" "$author" "$path" "$blackword" "$dependence" diff_scripts "$repo_path" "$author" "$path" "$blackword" "$dependence" "$extensions"
else else
echo -e "\n更新${repo_path}失败,请检查网络...\n" echo -e "\n更新${repo_path}失败,请检查网络...\n"
fi fi
@@ -231,7 +232,7 @@ usage() {
echo -e "1. $cmd_update update # 更新并重启青龙" echo -e "1. $cmd_update update # 更新并重启青龙"
echo -e "2. $cmd_update extra # 运行自定义脚本" echo -e "2. $cmd_update extra # 运行自定义脚本"
echo -e "3. $cmd_update raw <fileurl> # 更新单个脚本文件" echo -e "3. $cmd_update raw <fileurl> # 更新单个脚本文件"
echo -e "4. $cmd_update repo <repourl> <path> <blacklist> <dependence> <branch> # 更新单个仓库的脚本" echo -e "4. $cmd_update repo <repourl> <path> <blacklist> <dependence> <branch> <extensions> # 更新单个仓库的脚本"
echo -e "5. $cmd_update rmlog <days> # 删除旧日志" echo -e "5. $cmd_update rmlog <days> # 删除旧日志"
echo -e "6. $cmd_update bot # 启动tg-bot" echo -e "6. $cmd_update bot # 启动tg-bot"
echo -e "7. $cmd_update check # 检测青龙环境并修复" echo -e "7. $cmd_update check # 检测青龙环境并修复"
@@ -303,6 +304,10 @@ patch_version() {
pnpm i -g ts-node typescript tslib pnpm i -g ts-node typescript tslib
fi fi
# 兼容pnpm@7
pnpm setup
source ~/.bashrc
git config --global pull.rebase false git config --global pull.rebase false
cp -f $dir_root/.env.example $dir_root/.env cp -f $dir_root/.env.example $dir_root/.env
@@ -333,16 +338,6 @@ patch_version() {
} }
reload_pm2() {
pm2 l &>/dev/null
pm2 delete panel --source-map-support --time &>/dev/null
pm2 start $dir_static/build/app.js -n panel --source-map-support --time &>/dev/null
pm2 delete schedule --source-map-support --time &>/dev/null
pm2 start $dir_static/build/schedule.js -n schedule --source-map-support --time &>/dev/null
}
## 对比脚本 ## 对比脚本
diff_scripts() { diff_scripts() {
local dir_current=$(pwd) local dir_current=$(pwd)
@@ -351,8 +346,9 @@ diff_scripts() {
local path="$3" local path="$3"
local blackword="$4" local blackword="$4"
local dependence="$5" local dependence="$5"
local extensions="$6"
gen_list_repo "$repo_path" "$author" "$path" "$blackword" "$dependence" gen_list_repo "$repo_path" "$author" "$path" "$blackword" "$dependence" "$extensions"
local list_add="$dir_list_tmp/${uniq_path}_add.list" local list_add="$dir_list_tmp/${uniq_path}_add.list"
local list_drop="$dir_list_tmp/${uniq_path}_drop.list" local list_drop="$dir_list_tmp/${uniq_path}_drop.list"
@@ -388,6 +384,9 @@ gen_list_repo() {
local cmd="find ." local cmd="find ."
local index=0 local index=0
if [[ $6 ]]; then
file_extensions="$6"
fi
for extension in $file_extensions; do for extension in $file_extensions; do
if [[ $index -eq 0 ]]; then if [[ $index -eq 0 ]]; then
cmd="${cmd} -name \"*.${extension}\"" cmd="${cmd} -name \"*.${extension}\""
@@ -468,6 +467,7 @@ main() {
local p4=$4 local p4=$4
local p5=$5 local p5=$5
local p6=$6 local p6=$6
local p7=$7
local log_time=$(date "+%Y-%m-%d-%H-%M-%S") local log_time=$(date "+%Y-%m-%d-%H-%M-%S")
local log_path="$dir_log/update/${log_time}_$p1.log" local log_path="$dir_log/update/${log_time}_$p1.log"
local begin_time=$(date '+%Y-%m-%d %H:%M:%S') local begin_time=$(date '+%Y-%m-%d %H:%M:%S')
@@ -488,11 +488,8 @@ main() {
repo) repo)
get_user_info get_user_info
get_uniq_path "$p2" "$p6" get_uniq_path "$p2" "$p6"
log_path="$dir_log/update/${log_time}_${uniq_path}.log"
echo -e "## 开始执行... $begin_time\n" >>$log_path
[[ -f $task_error_log_path ]] && cat $task_error_log_path >>$log_path
if [[ -n $p2 ]]; then if [[ -n $p2 ]]; then
update_repo "$p2" "$p3" "$p4" "$p5" "$p6" >>$log_path update_repo "$p2" "$p3" "$p4" "$p5" "$p6" "$p7"
else else
echo -e "命令输入错误...\n" echo -e "命令输入错误...\n"
usage usage
@@ -501,11 +498,8 @@ main() {
raw) raw)
get_user_info get_user_info
get_uniq_path "$p2" get_uniq_path "$p2"
log_path="$dir_log/update/${log_time}_${uniq_path}.log"
echo -e "## 开始执行... $begin_time\n" >>$log_path
[[ -f $task_error_log_path ]] && cat $task_error_log_path >>$log_path
if [[ -n $p2 ]]; then if [[ -n $p2 ]]; then
update_raw "$p2" >>$log_path update_raw "$p2"
else else
echo -e "命令输入错误...\n" echo -e "命令输入错误...\n"
usage usage
@@ -545,8 +539,10 @@ main() {
esac esac
local end_time=$(date '+%Y-%m-%d %H:%M:%S') local end_time=$(date '+%Y-%m-%d %H:%M:%S')
local diff_time=$(($(date +%s -d "$end_time") - $(date +%s -d "$begin_time"))) local diff_time=$(($(date +%s -d "$end_time") - $(date +%s -d "$begin_time")))
if [[ $p1 != "repo" ]] && [[ $p1 != "raw" ]]; then
echo -e "\n## 执行结束... $end_time 耗时 $diff_time" >>$log_path echo -e "\n## 执行结束... $end_time 耗时 $diff_time" >>$log_path
cat $log_path cat $log_path
fi
} }
main "$@" main "$@"
+7
View File
@@ -0,0 +1,7 @@
import { createFromIconfontCN } from '@ant-design/icons';
const IconFont = createFromIconfontCN({
scriptUrl: ['//at.alicdn.com/t/font_3354854_ds8pa06q1qa.js'],
});
export default IconFont;
+110
View File
@@ -0,0 +1,110 @@
import { Tag, Input } from 'antd';
import { TweenOneGroup } from 'rc-tween-one';
import { PlusOutlined } from '@ant-design/icons';
import { useEffect, useRef, useState } from 'react';
const EditableTagGroup = ({
value,
onChange,
}: {
value?: string[];
onChange?: (tags: string[]) => void;
}) => {
const [inputValue, setInputValue] = useState('');
const [inputVisible, setInputVisible] = useState(false);
const [tags, setTags] = useState<string[]>([]);
const saveInputRef = useRef<any>();
const handleClose = (removedTag: string) => {
const _tags = tags.filter((tag) => tag !== removedTag);
setTags(_tags);
onChange?.(_tags);
};
const showInput = () => {
setInputVisible(true);
};
const handleInputChange = (e) => {
setInputValue(e.target.value);
};
const handleInputConfirm = () => {
if (inputValue && !tags.includes(inputValue)) {
setTags([...tags, inputValue]);
onChange?.([...tags, inputValue]);
}
setInputVisible(false);
setInputValue('');
};
const tagChild = tags.map((tag) => {
const tagElem = (
<Tag
closable
onClose={(e) => {
e.preventDefault();
handleClose(tag);
}}
>
{tag}
</Tag>
);
return (
<span key={tag} style={{ display: 'inline-block', marginBottom: 8 }}>
{tagElem}
</span>
);
});
useEffect(() => {
if (inputVisible && saveInputRef) {
saveInputRef.current.focus();
}
}, [inputVisible]);
useEffect(() => {
if (value) {
setTags(value);
}
}, [value]);
return (
<>
<TweenOneGroup
enter={{
scale: 0.8,
opacity: 0,
type: 'from',
duration: 100,
}}
leave={{ opacity: 0, width: 0, scale: 0, duration: 200 }}
appear={false}
>
{tagChild}
</TweenOneGroup>
{inputVisible && (
<Input
ref={saveInputRef}
type="text"
size="small"
style={{ width: 78 }}
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputConfirm}
onPressEnter={handleInputConfirm}
/>
)}
{!inputVisible && (
<Tag
onClick={showInput}
style={{ borderStyle: 'dashed', cursor: 'pointer' }}
>
<PlusOutlined />
</Tag>
)}
</>
);
};
export default EditableTagGroup;
+14 -7
View File
@@ -9,6 +9,7 @@ import {
ControlOutlined, ControlOutlined,
ContainerOutlined, ContainerOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import IconFont from '@/components/iconfont';
export default { export default {
route: { route: {
@@ -34,43 +35,49 @@ export default {
{ {
path: '/crontab', path: '/crontab',
name: '定时任务', name: '定时任务',
icon: <FieldTimeOutlined />, icon: <IconFont type="ql-icon-crontab" />,
component: '@/pages/crontab/index', component: '@/pages/crontab/index',
}, },
{
path: '/subscription',
name: '订阅管理',
icon: <IconFont type="ql-icon-subs" />,
component: '@/pages/subscription/index',
},
{ {
path: '/env', path: '/env',
name: '环境变量', name: '环境变量',
icon: <RadiusSettingOutlined />, icon: <IconFont type="ql-icon-env" />,
component: '@/pages/env/index', component: '@/pages/env/index',
}, },
{ {
path: '/config', path: '/config',
name: '配置文件', name: '配置文件',
icon: <ControlOutlined />, icon: <IconFont type="ql-icon-config" />,
component: '@/pages/config/index', component: '@/pages/config/index',
}, },
{ {
path: '/script', path: '/script',
name: '脚本管理', name: '脚本管理',
icon: <FormOutlined />, icon: <IconFont type="ql-icon-script" />,
component: '@/pages/script/index', component: '@/pages/script/index',
}, },
{ {
path: '/dependence', path: '/dependence',
name: '依赖管理', name: '依赖管理',
icon: <ContainerOutlined />, icon: <IconFont type="ql-icon-dependence" />,
component: '@/pages/dependence/index', component: '@/pages/dependence/index',
}, },
{ {
path: '/diff', path: '/diff',
name: '对比工具', name: '对比工具',
icon: <DiffOutlined />, icon: <IconFont type="ql-icon-diff" />,
component: '@/pages/diff/index', component: '@/pages/diff/index',
}, },
{ {
path: '/log', path: '/log',
name: '任务日志', name: '任务日志',
icon: <FolderOutlined />, icon: <IconFont type="ql-icon-log" />,
component: '@/pages/log/index', component: '@/pages/log/index',
}, },
{ {
+12 -6
View File
@@ -1,4 +1,4 @@
@import '~antd/lib/style/themes/default.less'; @import '~antd/es/style/themes/default.less';
@import '~@/styles/variable.less'; @import '~@/styles/variable.less';
@import '~codemirror/lib/codemirror.css'; @import '~codemirror/lib/codemirror.css';
@@ -9,6 +9,12 @@
url('../assets/fonts/SourceCodePro-Regular.ttf') format('truetype'); url('../assets/fonts/SourceCodePro-Regular.ttf') format('truetype');
} }
.ant-modal-body {
max-height: calc(85vh - 110px);
max-height: calc(85vh - var(--vh-offset, 110px));
overflow-y: auto;
}
.log-modal { .log-modal {
.ant-modal { .ant-modal {
padding-bottom: 0 !important; padding-bottom: 0 !important;
@@ -49,7 +55,7 @@
.ant-pro-page-container-children-content { .ant-pro-page-container-children-content {
overflow: auto; overflow: auto;
height: 100%; height: 100%;
background-color: #fff; background-color: @component-background;
padding: 16px; padding: 16px;
} }
} }
@@ -166,7 +172,7 @@
} }
.Resizer { .Resizer {
background: #fff; background: @component-background;
opacity: 0.2; opacity: 0.2;
z-index: 1; z-index: 1;
-moz-box-sizing: border-box; -moz-box-sizing: border-box;
@@ -278,7 +284,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
color: rgba(0, 0, 0, 0.85); color: @text-color;
.side-menu-collapse-button:hover { .side-menu-collapse-button:hover {
color: #1890ff; color: #1890ff;
@@ -295,11 +301,11 @@
text-align: left; text-align: left;
outline: none; outline: none;
padding: 4px 0; padding: 4px 0;
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid fade(@component-background, 0.12);
border-radius: 4px; border-radius: 4px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
overflow: auto; overflow: auto;
background-color: #fff; background-color: @component-background;
} }
[data-dark='true'] .side-menu-user-drop-menu { [data-dark='true'] .side-menu-user-drop-menu {
+52 -38
View File
@@ -1,11 +1,6 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import ProLayout, { PageLoading } from '@ant-design/pro-layout'; import ProLayout, { PageLoading } from '@ant-design/pro-layout';
import { import * as DarkReader from '@umijs/ssr-darkreader';
enable as enableDarkMode,
disable as disableDarkMode,
auto as followSystemColorScheme,
setFetchMethod,
} from 'darkreader';
import defaultProps from './defaultProps'; import defaultProps from './defaultProps';
import { Link, history } from 'umi'; import { Link, history } from 'umi';
import { import {
@@ -28,13 +23,20 @@ import { init } from '../utils/init';
export default function (props: any) { export default function (props: any) {
const ctx = useCtx(); const ctx = useCtx();
const theme = useTheme(); const { theme, reloadTheme } = useTheme();
const [user, setUser] = useState<any>(); const [user, setUser] = useState<any>({});
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [systemInfo, setSystemInfo] = useState<{ isInitialized: boolean }>(); const [systemInfo, setSystemInfo] = useState<{ isInitialized: boolean }>();
const ws = useRef<any>(null); const ws = useRef<any>(null);
const [socketMessage, setSocketMessage] = useState<any>(); const [socketMessage, setSocketMessage] = useState<any>();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const {
enable: enableDarkMode,
disable: disableDarkMode,
exportGeneratedCSS: collectCSS,
setFetchMethod,
auto: followSystemColorScheme,
} = DarkReader || {};
const logout = () => { const logout = () => {
request.post(`${config.apiPrefix}user/logout`).then(() => { request.post(`${config.apiPrefix}user/logout`).then(() => {
@@ -84,20 +86,8 @@ export default function (props: any) {
}); });
}; };
const reloadUser = () => { const reloadUser = (needLoading = false) => {
getUser(false); getUser(needLoading);
};
const setTheme = () => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const storageTheme = localStorage.getItem('qinglong_dark_theme');
const isDark =
(media.matches && storageTheme !== 'light') || storageTheme === 'dark';
if (isDark) {
document.body.setAttribute('data-dark', 'true');
} else {
document.body.setAttribute('data-dark', 'false');
}
}; };
useEffect(() => { useEffect(() => {
@@ -113,15 +103,25 @@ export default function (props: any) {
}, [systemInfo]); }, [systemInfo]);
useEffect(() => { useEffect(() => {
setTheme(); if (theme === 'vs-dark') {
}, [theme.theme]); document.body.setAttribute('data-dark', 'true');
} else {
document.body.setAttribute('data-dark', 'false');
}
}, [theme]);
useEffect(() => { useEffect(() => {
vhCheck(); vhCheck();
init(); init();
const _theme = localStorage.getItem('qinglong_dark_theme') || 'auto'; const _theme = localStorage.getItem('qinglong_dark_theme') || 'auto';
setFetchMethod(window.fetch); if (typeof window === 'undefined') return;
if (typeof window.matchMedia === 'undefined') return;
if (!DarkReader) {
return () => null;
}
setFetchMethod(fetch);
if (_theme === 'dark') { if (_theme === 'dark') {
enableDarkMode({}); enableDarkMode({});
} else if (_theme === 'light') { } else if (_theme === 'light') {
@@ -129,6 +129,10 @@ export default function (props: any) {
} else { } else {
followSystemColorScheme({}); followSystemColorScheme({});
} }
return () => {
disableDarkMode();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -196,10 +200,10 @@ export default function (props: any) {
return React.Children.map(props.children, (child) => { return React.Children.map(props.children, (child) => {
return React.cloneElement(child, { return React.cloneElement(child, {
...ctx, ...ctx,
...theme, theme,
user, user,
reloadUser, reloadUser,
reloadTheme: setTheme, reloadTheme,
ws: ws.current, ws: ws.current,
}); });
}); });
@@ -213,11 +217,11 @@ export default function (props: any) {
const isQQBrowser = navigator.userAgent.includes('QQBrowser'); const isQQBrowser = navigator.userAgent.includes('QQBrowser');
const menu = ( const menu = (
<Menu className="side-menu-user-drop-menu"> <Menu
<Menu.Item key="1" icon={<LogoutOutlined />} onClick={logout}> className="side-menu-user-drop-menu"
退 items={[{ label: '退出登录', key: 'logout', icon: <LogoutOutlined /> }]}
</Menu.Item> onClick={logout}
</Menu> />
); );
return loading ? ( return loading ? (
<PageLoading /> <PageLoading />
@@ -274,8 +278,13 @@ export default function (props: any) {
ctx.isPhone && ( ctx.isPhone && (
<Dropdown overlay={menu} placement="bottomRight" trigger={['click']}> <Dropdown overlay={menu} placement="bottomRight" trigger={['click']}>
<span className="side-menu-user-wrapper"> <span className="side-menu-user-wrapper">
<Avatar shape="square" size="small" icon={<UserOutlined />} /> <Avatar
<span style={{ marginLeft: 5 }}>admin</span> shape="square"
size="small"
icon={<UserOutlined />}
src={`/api/static/${user.avatar}`}
/>
<span style={{ marginLeft: 5 }}>{user.username}</span>
</span> </span>
</Dropdown> </Dropdown>
) )
@@ -291,8 +300,13 @@ export default function (props: any) {
{!collapsed && !ctx.isPhone && ( {!collapsed && !ctx.isPhone && (
<Dropdown overlay={menu} placement="topLeft" trigger={['hover']}> <Dropdown overlay={menu} placement="topLeft" trigger={['hover']}>
<span className="side-menu-user-wrapper"> <span className="side-menu-user-wrapper">
<Avatar shape="square" size="small" icon={<UserOutlined />} /> <Avatar
<span style={{ marginLeft: 5 }}>admin</span> shape="square"
size="small"
icon={<UserOutlined />}
src={`/api/static/${user.avatar}`}
/>
<span style={{ marginLeft: 5 }}>{user.username}</span>
</span> </span>
</Dropdown> </Dropdown>
)} )}
@@ -309,10 +323,10 @@ export default function (props: any) {
{React.Children.map(props.children, (child) => { {React.Children.map(props.children, (child) => {
return React.cloneElement(child, { return React.cloneElement(child, {
...ctx, ...ctx,
...theme, theme,
user, user,
reloadUser, reloadUser,
reloadTheme: setTheme, reloadTheme,
socketMessage, socketMessage,
}); });
})} })}
+236 -22
View File
@@ -10,6 +10,7 @@ import {
List, List,
Divider, Divider,
Typography, Typography,
Tooltip,
} from 'antd'; } from 'antd';
import { import {
ClockCircleOutlined, ClockCircleOutlined,
@@ -17,6 +18,8 @@ import {
FieldTimeOutlined, FieldTimeOutlined,
Loading3QuartersOutlined, Loading3QuartersOutlined,
FileOutlined, FileOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { CrontabStatus } from './index'; import { CrontabStatus } from './index';
import { diffTime } from '@/utils/date'; import { diffTime } from '@/utils/date';
@@ -24,6 +27,7 @@ import { request } from '@/utils/http';
import config from '@/utils/config'; import config from '@/utils/config';
import CronLogModal from './logModal'; import CronLogModal from './logModal';
import Editor from '@monaco-editor/react'; import Editor from '@monaco-editor/react';
import IconFont from '@/components/iconfont';
const { Text } = Typography; const { Text } = Typography;
@@ -37,6 +41,12 @@ const tabList = [
tab: '脚本', tab: '脚本',
}, },
]; ];
const LangMap: any = {
'.py': 'python',
'.js': 'javascript',
'.sh': 'shell',
'.ts': 'typescript',
};
interface LogItem { interface LogItem {
directory: string; directory: string;
@@ -68,6 +78,7 @@ const CronDetailModal = ({
const [scriptInfo, setScriptInfo] = useState<any>({}); const [scriptInfo, setScriptInfo] = useState<any>({});
const [logUrl, setLogUrl] = useState(''); const [logUrl, setLogUrl] = useState('');
const [validTabs, setValidTabs] = useState(tabList); const [validTabs, setValidTabs] = useState(tabList);
const [currentCron, setCurrentCron] = useState<any>({});
const contentList: any = { const contentList: any = {
log: ( log: (
@@ -82,9 +93,9 @@ const CronDetailModal = ({
)} )}
/> />
), ),
script: ( script: scriptInfo.filename && (
<Editor <Editor
language="shell" language={LangMap[scriptInfo.filename.slice(-3)] || ''}
theme={theme} theme={theme}
value={value} value={value}
options={{ options={{
@@ -103,7 +114,7 @@ const CronDetailModal = ({
}; };
const onClickItem = (item: LogItem) => { const onClickItem = (item: LogItem) => {
localStorage.setItem('logCron', cron.id); localStorage.setItem('logCron', currentCron.id);
setLogUrl(`${config.apiPrefix}logs/${item.directory}/${item.filename}`); setLogUrl(`${config.apiPrefix}logs/${item.directory}/${item.filename}`);
request request
.get(`${config.apiPrefix}logs/${item.directory}/${item.filename}`) .get(`${config.apiPrefix}logs/${item.directory}/${item.filename}`)
@@ -196,8 +207,150 @@ const CronDetailModal = ({
}); });
}; };
const runCron = () => {
Modal.confirm({
title: '确认运行',
content: (
<>
{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{currentCron.name}
</Text>{' '}
</>
),
onOk() {
request
.put(`${config.apiPrefix}crons/run`, { data: [currentCron.id] })
.then((data: any) => {
if (data.code === 200) {
setCurrentCron({ ...currentCron, status: CrontabStatus.running });
setTimeout(() => {
getLogs();
}, 1000);
} else {
message.error(data);
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
const stopCron = () => {
Modal.confirm({
title: '确认停止',
content: (
<>
{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{currentCron.name}
</Text>{' '}
</>
),
onOk() {
request
.put(`${config.apiPrefix}crons/stop`, { data: [currentCron.id] })
.then((data: any) => {
if (data.code === 200) {
setCurrentCron({ ...currentCron, status: CrontabStatus.idle });
} else {
message.error(data);
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
const enabledOrDisabledCron = () => {
Modal.confirm({
title: `确认${currentCron.isDisabled === 1 ? '启用' : '禁用'}`,
content: (
<>
{currentCron.isDisabled === 1 ? '启用' : '禁用'}
{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{currentCron.name}
</Text>{' '}
</>
),
onOk() {
request
.put(
`${config.apiPrefix}crons/${
currentCron.isDisabled === 1 ? 'enable' : 'disable'
}`,
{
data: [currentCron.id],
},
)
.then((data: any) => {
if (data.code === 200) {
setCurrentCron({
...currentCron,
isDisabled: currentCron.isDisabled === 1 ? 0 : 1,
});
} else {
message.error(data);
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
const pinOrUnPinCron = () => {
Modal.confirm({
title: `确认${currentCron.isPinned === 1 ? '取消置顶' : '置顶'}`,
content: (
<>
{currentCron.isPinned === 1 ? '取消置顶' : '置顶'}
{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{currentCron.name}
</Text>{' '}
</>
),
onOk() {
request
.put(
`${config.apiPrefix}crons/${
currentCron.isPinned === 1 ? 'unpin' : 'pin'
}`,
{
data: [currentCron.id],
},
)
.then((data: any) => {
if (data.code === 200) {
setCurrentCron({
...currentCron,
isPinned: currentCron.isPinned === 1 ? 0 : 1,
});
} else {
message.error(data);
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
useEffect(() => { useEffect(() => {
if (cron && cron.id) { if (cron && cron.id) {
setCurrentCron(cron);
getLogs(); getLogs();
getScript(); getScript();
} }
@@ -206,19 +359,76 @@ const CronDetailModal = ({
return ( return (
<Modal <Modal
title={ title={
<> <div className="crontab-title-wrapper">
<span>{cron.name}</span> <div>
{cron.labels?.length > 0 && cron.labels[0] !== '' && ( <span>{currentCron.name}</span>
{currentCron.labels?.length > 0 && currentCron.labels[0] !== '' && (
<Divider type="vertical"></Divider> <Divider type="vertical"></Divider>
)} )}
{cron.labels?.length > 0 && {currentCron.labels?.length > 0 &&
cron.labels[0] !== '' && currentCron.labels[0] !== '' &&
cron.labels?.map((label: string, i: number) => ( currentCron.labels?.map((label: string, i: number) => (
<Tag color="blue" style={{ marginRight: 5 }}> <Tag color="blue" style={{ marginRight: 5 }}>
{label} {label}
</Tag> </Tag>
))} ))}
</> </div>
<div className="operations">
<Tooltip
title={
currentCron.status === CrontabStatus.idle ? '运行' : '停止'
}
>
<Button
type="link"
icon={
currentCron.status === CrontabStatus.idle ? (
<PlayCircleOutlined />
) : (
<PauseCircleOutlined />
)
}
size="small"
onClick={
currentCron.status === CrontabStatus.idle ? runCron : stopCron
}
/>
</Tooltip>
<Tooltip title={currentCron.isDisabled === 1 ? '启用' : '禁用'}>
<Button
type="link"
icon={
<IconFont
type={
currentCron.isDisabled === 1
? 'ql-icon-enable'
: 'ql-icon-disable'
}
/>
}
size="small"
onClick={enabledOrDisabledCron}
/>
</Tooltip>
<Tooltip title={currentCron.isPinned === 1 ? '取消置顶' : '置顶'}>
<Button
type="link"
icon={
<IconFont
type={
currentCron.isPinned === 1
? 'ql-icon-untop'
: 'ql-icon-top'
}
/>
}
size="small"
onClick={pinOrUnPinCron}
/>
</Tooltip>
</div>
</div>
} }
centered centered
visible={visible} visible={visible}
@@ -232,21 +442,22 @@ const CronDetailModal = ({
<Card> <Card>
<div className="cron-detail-info-item"> <div className="cron-detail-info-item">
<div className="cron-detail-info-title"></div> <div className="cron-detail-info-title"></div>
<div className="cron-detail-info-value">{cron.command}</div> <div className="cron-detail-info-value">{currentCron.command}</div>
</div> </div>
</Card> </Card>
<Card style={{ marginTop: 10 }}> <Card style={{ marginTop: 10 }}>
<div className="cron-detail-info-item"> <div className="cron-detail-info-item">
<div className="cron-detail-info-title"></div> <div className="cron-detail-info-title"></div>
<div className="cron-detail-info-value"> <div className="cron-detail-info-value">
{(!cron.isDisabled || cron.status !== CrontabStatus.idle) && ( {(!currentCron.isDisabled ||
currentCron.status !== CrontabStatus.idle) && (
<> <>
{cron.status === CrontabStatus.idle && ( {currentCron.status === CrontabStatus.idle && (
<Tag icon={<ClockCircleOutlined />} color="default"> <Tag icon={<ClockCircleOutlined />} color="default">
</Tag> </Tag>
)} )}
{cron.status === CrontabStatus.running && ( {currentCron.status === CrontabStatus.running && (
<Tag <Tag
icon={<Loading3QuartersOutlined spin />} icon={<Loading3QuartersOutlined spin />}
color="processing" color="processing"
@@ -254,14 +465,15 @@ const CronDetailModal = ({
</Tag> </Tag>
)} )}
{cron.status === CrontabStatus.queued && ( {currentCron.status === CrontabStatus.queued && (
<Tag icon={<FieldTimeOutlined />} color="default"> <Tag icon={<FieldTimeOutlined />} color="default">
</Tag> </Tag>
)} )}
</> </>
)} )}
{cron.isDisabled === 1 && cron.status === CrontabStatus.idle && ( {currentCron.isDisabled === 1 &&
currentCron.status === CrontabStatus.idle && (
<Tag icon={<CloseCircleOutlined />} color="error"> <Tag icon={<CloseCircleOutlined />} color="error">
</Tag> </Tag>
@@ -270,13 +482,13 @@ const CronDetailModal = ({
</div> </div>
<div className="cron-detail-info-item"> <div className="cron-detail-info-item">
<div className="cron-detail-info-title"></div> <div className="cron-detail-info-title"></div>
<div className="cron-detail-info-value">{cron.schedule}</div> <div className="cron-detail-info-value">{currentCron.schedule}</div>
</div> </div>
<div className="cron-detail-info-item"> <div className="cron-detail-info-item">
<div className="cron-detail-info-title"></div> <div className="cron-detail-info-title"></div>
<div className="cron-detail-info-value"> <div className="cron-detail-info-value">
{cron.last_execution_time {currentCron.last_execution_time
? new Date(cron.last_execution_time * 1000) ? new Date(currentCron.last_execution_time * 1000)
.toLocaleString(language, { .toLocaleString(language, {
hour12: false, hour12: false,
}) })
@@ -287,14 +499,16 @@ const CronDetailModal = ({
<div className="cron-detail-info-item"> <div className="cron-detail-info-item">
<div className="cron-detail-info-title"></div> <div className="cron-detail-info-title"></div>
<div className="cron-detail-info-value"> <div className="cron-detail-info-value">
{cron.last_running_time ? diffTime(cron.last_running_time) : '-'} {currentCron.last_running_time
? diffTime(currentCron.last_running_time)
: '-'}
</div> </div>
</div> </div>
<div className="cron-detail-info-item"> <div className="cron-detail-info-item">
<div className="cron-detail-info-title"></div> <div className="cron-detail-info-title"></div>
<div className="cron-detail-info-value"> <div className="cron-detail-info-value">
{cron.nextRunTime && {currentCron.nextRunTime &&
cron.nextRunTime currentCron.nextRunTime
.toLocaleString(language, { .toLocaleString(language, {
hour12: false, hour12: false,
}) })
+16
View File
@@ -77,6 +77,22 @@
margin-top: 12px; margin-top: 12px;
} }
} }
.crontab-title-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
margin-right: 32px;
.operations {
display: flex;
align-items: center;
.ant-btn:not(:first-child) {
margin-left: 8px;
}
}
}
} }
.log-item { .log-item {
+27 -52
View File
@@ -73,7 +73,7 @@ enum OperationPath {
const Crontab = ({ headerStyle, isPhone, theme }: any) => { const Crontab = ({ headerStyle, isPhone, theme }: any) => {
const columns: any = [ const columns: any = [
{ {
title: '任务名', title: '名',
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
width: 150, width: 150,
@@ -127,7 +127,7 @@ const Crontab = ({ headerStyle, isPhone, theme }: any) => {
}, },
}, },
{ {
title: '任务', title: '命令',
dataIndex: 'command', dataIndex: 'command',
key: 'command', key: 'command',
width: 250, width: 250,
@@ -152,7 +152,7 @@ const Crontab = ({ headerStyle, isPhone, theme }: any) => {
}, },
}, },
{ {
title: '任务定时', title: '定时规则',
dataIndex: 'schedule', dataIndex: 'schedule',
key: 'schedule', key: 'schedule',
width: 110, width: 110,
@@ -200,18 +200,9 @@ const Crontab = ({ headerStyle, isPhone, theme }: any) => {
}, },
}, },
render: (text: string, record: any) => { render: (text: string, record: any) => {
const language = navigator.language || navigator.languages[0]; return record.last_running_time
return (
<span
style={{
display: 'block',
}}
>
{record.last_running_time
? diffTime(record.last_running_time) ? diffTime(record.last_running_time)
: '-'} : '-';
</span>
);
}, },
}, },
{ {
@@ -225,19 +216,11 @@ const Crontab = ({ headerStyle, isPhone, theme }: any) => {
}, },
render: (text: string, record: any) => { render: (text: string, record: any) => {
const language = navigator.language || navigator.languages[0]; const language = navigator.language || navigator.languages[0];
return ( return record.nextRunTime
<span
style={{
display: 'block',
}}
>
{record.nextRunTime
.toLocaleString(language, { .toLocaleString(language, {
hour12: false, hour12: false,
}) })
.replace(' 24:', ' 00:')} .replace(' 24:', ' 00:');
</span>
);
}, },
}, },
@@ -627,6 +610,24 @@ const Crontab = ({ headerStyle, isPhone, theme }: any) => {
}); });
}; };
const getMenuItems = (record: any) => {
return [
{ label: '编辑', key: 'edit', icon: <EditOutlined /> },
{
label: record.isDisabled === 1 ? '启用' : '禁用',
key: 'enableOrDisable',
icon:
record.isDisabled === 1 ? <CheckCircleOutlined /> : <StopOutlined />,
},
{ label: '删除', key: 'delete', icon: <DeleteOutlined /> },
{
label: record.isPinned === 1 ? '取消置顶' : '置顶',
key: 'pinOrUnPin',
icon: record.isPinned === 1 ? <StopOutlined /> : <PushpinOutlined />,
},
];
};
const MoreBtn: React.FC<{ const MoreBtn: React.FC<{
record: any; record: any;
index: number; index: number;
@@ -637,38 +638,12 @@ const Crontab = ({ headerStyle, isPhone, theme }: any) => {
trigger={['click']} trigger={['click']}
overlay={ overlay={
<Menu <Menu
items={getMenuItems(record)}
onClick={({ key, domEvent }) => { onClick={({ key, domEvent }) => {
domEvent.stopPropagation(); domEvent.stopPropagation();
action(key, record, index); action(key, record, index);
}} }}
> />
<Menu.Item key="edit" icon={<EditOutlined />}>
</Menu.Item>
<Menu.Item
key="enableOrDisable"
icon={
record.isDisabled === 1 ? (
<CheckCircleOutlined />
) : (
<StopOutlined />
)
}
>
{record.isDisabled === 1 ? '启用' : '禁用'}
</Menu.Item>
<Menu.Item key="delete" icon={<DeleteOutlined />}>
</Menu.Item>
<Menu.Item
key="pinOrUnPin"
icon={
record.isPinned === 1 ? <StopOutlined /> : <PushpinOutlined />
}
>
{record.isPinned === 1 ? '取消置顶' : '置顶'}
</Menu.Item>
</Menu>
} }
> >
<a onClick={(e) => e.stopPropagation()}> <a onClick={(e) => e.stopPropagation()}>
+1 -3
View File
@@ -95,7 +95,7 @@ const CronLogModal = ({
<> <>
{(executing || loading) && <Loading3QuartersOutlined spin />} {(executing || loading) && <Loading3QuartersOutlined spin />}
{!executing && !loading && <CheckCircleOutlined />} {!executing && !loading && <CheckCircleOutlined />}
<span style={{ marginLeft: 5 }}>-{cron && cron.name}</span>{' '} <span style={{ marginLeft: 5 }}>{cron && cron.name}</span>
</> </>
); );
}; };
@@ -123,8 +123,6 @@ const CronLogModal = ({
centered centered
className="log-modal" className="log-modal"
bodyStyle={{ bodyStyle={{
overflowY: 'auto',
maxHeight: 'calc(80vh - var(--vh-offset, 0px))',
minHeight: '300px', minHeight: '300px',
}} }}
forceRender forceRender
+8 -11
View File
@@ -3,6 +3,7 @@ import { Modal, message, Input, Form, Button } from 'antd';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
import config from '@/utils/config'; import config from '@/utils/config';
import cronParse from 'cron-parser'; import cronParse from 'cron-parser';
import EditableTagGroup from '@/components/tag';
const CronModal = ({ const CronModal = ({
cron, cron,
@@ -20,11 +21,6 @@ const CronModal = ({
setLoading(true); setLoading(true);
const method = cron ? 'put' : 'post'; const method = cron ? 'put' : 'post';
const payload = { ...values }; const payload = { ...values };
if (typeof payload.labels === 'string') {
payload.labels = values.labels.split(/,|/);
} else if (!payload.labels) {
payload.labels = [];
}
if (cron) { if (cron) {
payload.id = cron.id; payload.id = cron.id;
} }
@@ -53,6 +49,8 @@ const CronModal = ({
title={cron ? '编辑任务' : '新建任务'} title={cron ? '编辑任务' : '新建任务'}
visible={visible} visible={visible}
forceRender forceRender
centered
maskClosable={false}
onOk={() => { onOk={() => {
form form
.validateFields() .validateFields()
@@ -93,7 +91,7 @@ const CronModal = ({
{ required: true }, { required: true },
{ {
validator: (rule, value) => { validator: (rule, value) => {
if (cronParse.parseExpression(value).hasNext()) { if (!value || cronParse.parseExpression(value).hasNext()) {
return Promise.resolve(); return Promise.resolve();
} else { } else {
return Promise.reject('Cron表达式格式有误'); return Promise.reject('Cron表达式格式有误');
@@ -105,7 +103,7 @@ const CronModal = ({
<Input placeholder="秒(可选) 分 时 天 月 周" /> <Input placeholder="秒(可选) 分 时 天 月 周" />
</Form.Item> </Form.Item>
<Form.Item name="labels" label="标签"> <Form.Item name="labels" label="标签">
<Input placeholder="请输入任务标签" /> <EditableTagGroup />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
@@ -128,9 +126,6 @@ const CronLabelModal = ({
form form
.validateFields() .validateFields()
.then(async (values) => { .then(async (values) => {
if (typeof values.labels === 'string') {
values.labels = values.labels.split(/,|/);
}
setLoading(true); setLoading(true);
const payload = { ids, labels: values.labels }; const payload = { ids, labels: values.labels };
const { code, data } = await request[action]( const { code, data } = await request[action](
@@ -173,13 +168,15 @@ const CronLabelModal = ({
title="批量修改标签" title="批量修改标签"
visible={visible} visible={visible}
footer={buttons} footer={buttons}
centered
maskClosable={false}
forceRender forceRender
onCancel={() => handleCancel(false)} onCancel={() => handleCancel(false)}
confirmLoading={loading} confirmLoading={loading}
> >
<Form form={form} layout="vertical" name="form_in_label_modal"> <Form form={form} layout="vertical" name="form_in_label_modal">
<Form.Item name="labels" label="标签"> <Form.Item name="labels" label="标签">
<Input placeholder="请输入任务标签" /> <EditableTagGroup />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
+44 -16
View File
@@ -16,7 +16,7 @@ import {
DeleteOutlined, DeleteOutlined,
SyncOutlined, SyncOutlined,
CheckCircleOutlined, CheckCircleOutlined,
StopOutlined, DeleteFilled,
BugOutlined, BugOutlined,
FileTextOutlined, FileTextOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@@ -120,6 +120,15 @@ const Dependence = ({ headerStyle, isPhone, socketMessage }: any) => {
const isPc = !isPhone; const isPc = !isPhone;
return ( return (
<Space size="middle"> <Space size="middle">
<Tooltip title={isPc ? '日志' : ''}>
<a
onClick={() => {
setLogDependence({ ...record, timestamp: Date.now() });
}}
>
<FileTextOutlined />
</a>
</Tooltip>
{record.status !== Status. && {record.status !== Status. &&
record.status !== Status. && ( record.status !== Status. && (
<> <>
@@ -133,17 +142,13 @@ const Dependence = ({ headerStyle, isPhone, socketMessage }: any) => {
<DeleteOutlined /> <DeleteOutlined />
</a> </a>
</Tooltip> </Tooltip>
</> <Tooltip title={isPc ? '强制删除' : ''}>
)} <a onClick={() => deleteDependence(record, index, true)}>
<Tooltip title={isPc ? '日志' : ''}> <DeleteFilled />
<a
onClick={() => {
setLogDependence({ ...record, timestamp: Date.now() });
}}
>
<FileTextOutlined />
</a> </a>
</Tooltip> </Tooltip>
</>
)}
</Space> </Space>
); );
}, },
@@ -182,7 +187,11 @@ const Dependence = ({ headerStyle, isPhone, socketMessage }: any) => {
setIsModalVisible(true); setIsModalVisible(true);
}; };
const deleteDependence = (record: any, index: number) => { const deleteDependence = (
record: any,
index: number,
force: boolean = false,
) => {
Modal.confirm({ Modal.confirm({
title: '确认删除', title: '确认删除',
content: ( content: (
@@ -196,10 +205,19 @@ const Dependence = ({ headerStyle, isPhone, socketMessage }: any) => {
), ),
onOk() { onOk() {
request request
.delete(`${config.apiPrefix}dependencies`, { data: [record.id] }) .delete(`${config.apiPrefix}dependencies${force ? '/force' : ''}`, {
data: [record.id],
})
.then((data: any) => { .then((data: any) => {
if (data.code === 200) { if (data.code === 200) {
handleDependence(data.data[0]); if (force) {
const i = value.findIndex((x) => x.id === data.data[0].id);
if (i !== -1) {
const result = [...value];
result.splice(i, 1);
setValue(result);
}
}
} else { } else {
message.error(data); message.error(data);
} }
@@ -275,13 +293,16 @@ const Dependence = ({ headerStyle, isPhone, socketMessage }: any) => {
onChange: onSelectChange, onChange: onSelectChange,
}; };
const delDependencies = () => { const delDependencies = (force: boolean) => {
const forceUrl = force ? '/force' : '';
Modal.confirm({ Modal.confirm({
title: '确认删除', title: '确认删除',
content: <></>, content: <></>,
onOk() { onOk() {
request request
.delete(`${config.apiPrefix}dependencies`, { data: selectedRowIds }) .delete(`${config.apiPrefix}dependencies${forceUrl}`, {
data: selectedRowIds,
})
.then((data: any) => { .then((data: any) => {
if (data.code === 200) { if (data.code === 200) {
setSelectedRowIds([]); setSelectedRowIds([]);
@@ -377,10 +398,17 @@ const Dependence = ({ headerStyle, isPhone, socketMessage }: any) => {
<Button <Button
type="primary" type="primary"
style={{ marginBottom: 5, marginLeft: 8 }} style={{ marginBottom: 5, marginLeft: 8 }}
onClick={delDependencies} onClick={() => delDependencies(false)}
> >
</Button> </Button>
<Button
type="primary"
style={{ marginBottom: 5, marginLeft: 8 }}
onClick={() => delDependencies(true)}
>
</Button>
<span style={{ marginLeft: 8 }}> <span style={{ marginLeft: 8 }}>
<a>{selectedRowIds?.length}</a> <a>{selectedRowIds?.length}</a>
+2 -1
View File
@@ -7,6 +7,7 @@ import {
CheckCircleOutlined, CheckCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { PageLoading } from '@ant-design/pro-layout'; import { PageLoading } from '@ant-design/pro-layout';
import Ansi from 'ansi-to-react';
const DependenceLogModal = ({ const DependenceLogModal = ({
dependence, dependence,
@@ -140,7 +141,7 @@ const DependenceLogModal = ({
: {} : {}
} }
> >
{value} <Ansi>{value}</Ansi>
</pre> </pre>
)} )}
</Modal> </Modal>
+2
View File
@@ -71,6 +71,8 @@ const DependenceModal = ({
title={dependence ? '编辑依赖' : '新建依赖'} title={dependence ? '编辑依赖' : '新建依赖'}
visible={visible} visible={visible}
forceRender forceRender
centered
maskClosable={false}
onOk={() => { onOk={() => {
form form
.validateFields() .validateFields()
+2
View File
@@ -41,6 +41,8 @@ const EditNameModal = ({
title="修改环境变量名称" title="修改环境变量名称"
visible={visible} visible={visible}
forceRender forceRender
centered
maskClosable={false}
onOk={() => { onOk={() => {
form form
.validateFields() .validateFields()
+2
View File
@@ -61,6 +61,8 @@ const EnvModal = ({
title={env ? '编辑变量' : '新建变量'} title={env ? '编辑变量' : '新建变量'}
visible={visible} visible={visible}
forceRender forceRender
centered
maskClosable={false}
onOk={() => { onOk={() => {
form form
.validateFields() .validateFields()
+1 -1
View File
@@ -22,7 +22,7 @@ const Error = ({ user, theme }: any) => {
}; };
useEffect(() => { useEffect(() => {
if (user) { if (user && user.username) {
history.push('/crontab'); history.push('/crontab');
} }
}, [user]); }, [user]);
+3 -2
View File
@@ -1,10 +1,11 @@
@import '~antd/es/style/themes/default.less';
@import '~@/styles/variable.less'; @import '~@/styles/variable.less';
.left-tree { .left-tree {
&-container { &-container {
overflow: hidden; overflow: hidden;
position: relative; position: relative;
background-color: #fff; background-color: @component-background;
height: calc(100vh - 128px); height: calc(100vh - 128px);
height: calc(100vh - var(--vh-offset, 0px) - 128px); height: calc(100vh - var(--vh-offset, 0px) - 128px);
display: flex; display: flex;
@@ -25,6 +26,6 @@
:global { :global {
.Pane.vertical.Pane1 { .Pane.vertical.Pane1 {
padding-right: 5px; padding-right: 5px;
border-right: 1px dashed #f0f0f0; border-right: 1px dashed @text-color;
} }
} }
+6 -22
View File
@@ -52,32 +52,16 @@ const Log = ({ headerStyle, isPhone, theme }: any) => {
request request
.get(`${config.apiPrefix}logs`) .get(`${config.apiPrefix}logs`)
.then((data) => { .then((data) => {
const result = formatData(data.dirs) as any; setData(data.data);
setData(result); setFilterData(data.data);
setFilterData(result);
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
const formatData = (tree: any[]) => {
return tree.map((x) => {
x.title = x.name;
x.value = x.name;
x.disabled = x.isDir;
x.key = x.name;
x.children = x.files.map((y: string) => ({
title: y,
value: `${x.name}/${y}`,
key: `${x.name}/${y}`,
parent: x.name,
isLeaf: true,
}));
return x;
});
};
const getLog = (node: any) => { const getLog = (node: any) => {
request.get(`${config.apiPrefix}logs/${node.value}`).then((data) => { request
.get(`${config.apiPrefix}logs/${node.value}?path=${node.parent || ''}`)
.then((data) => {
setValue(data.data); setValue(data.data);
}); });
}; };
@@ -88,7 +72,7 @@ const Log = ({ headerStyle, isPhone, theme }: any) => {
} }
setValue('加载中...'); setValue('加载中...');
setSelect(value); setSelect(value);
setTitle(node.parent || node.value); setTitle(node.key);
getLog(node); getLog(node);
}; };
+2 -2
View File
@@ -18,7 +18,7 @@ import { MobileOutlined } from '@ant-design/icons';
const FormItem = Form.Item; const FormItem = Form.Item;
const { Countdown } = Statistic; const { Countdown } = Statistic;
const Login = () => { const Login = ({ reloadUser }: any) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [waitTime, setWaitTime] = useState<any>(); const [waitTime, setWaitTime] = useState<any>();
const { theme } = useTheme(); const { theme } = useTheme();
@@ -100,8 +100,8 @@ const Login = () => {
<div>{retries > 0 ? `失败${retries}` : '成功'}</div> <div>{retries > 0 ? `失败${retries}` : '成功'}</div>
</> </>
), ),
duration: 5,
}); });
reloadUser(true);
history.push('/crontab'); history.push('/crontab');
} else if (data.code === 100) { } else if (data.code === 100) {
message.warn(data.message); message.warn(data.message);
+2
View File
@@ -56,6 +56,8 @@ const EditScriptNameModal = ({
title="新建文件" title="新建文件"
visible={visible} visible={visible}
forceRender forceRender
centered
maskClosable={false}
onOk={() => { onOk={() => {
form form
.validateFields() .validateFields()
+3 -2
View File
@@ -1,10 +1,11 @@
@import '~antd/es/style/themes/default.less';
@import '~@/styles/variable.less'; @import '~@/styles/variable.less';
.left-tree { .left-tree {
&-container { &-container {
overflow: hidden; overflow: hidden;
position: relative; position: relative;
background-color: #fff; background-color: @component-background;
height: calc(100vh - 128px); height: calc(100vh - 128px);
height: calc(100vh - var(--vh-offset, 0px) - 128px); height: calc(100vh - var(--vh-offset, 0px) - 128px);
display: flex; display: flex;
@@ -25,6 +26,6 @@
:global { :global {
.Pane.vertical.Pane1 { .Pane.vertical.Pane1 {
padding-right: 5px; padding-right: 5px;
border-right: 1px dashed #f0f0f0; border-right: 1px dashed @text-color;
} }
} }
+62 -31
View File
@@ -93,7 +93,7 @@ const Script = ({ headerStyle, isPhone, theme, socketMessage }: any) => {
const getScripts = () => { const getScripts = () => {
setLoading(true); setLoading(true);
request request
.get(`${config.apiPrefix}scripts/files`) .get(`${config.apiPrefix}scripts`)
.then((data) => { .then((data) => {
setData(data.data); setData(data.data);
setFilterData(data.data); setFilterData(data.data);
@@ -135,7 +135,7 @@ const Script = ({ headerStyle, isPhone, theme, socketMessage }: any) => {
const newMode = value ? LangMap[value.slice(-3)] : ''; const newMode = value ? LangMap[value.slice(-3)] : '';
setMode(isPhone && newMode === 'typescript' ? 'javascript' : newMode); setMode(isPhone && newMode === 'typescript' ? 'javascript' : newMode);
setSelect(node.key); setSelect(node.key);
setTitle(node.parent || node.value); setTitle(node.key);
setCurrentNode(node); setCurrentNode(node);
getDetail(node); getDetail(node);
}; };
@@ -367,37 +367,68 @@ const Script = ({ headerStyle, isPhone, theme, socketMessage }: any) => {
} }
}, []); }, []);
const action = (key: string | number) => {
switch (key) {
case 'save':
saveFile();
break;
case 'exit':
cancelEdit();
break;
default:
break;
}
};
const menuAction = (key: string | number) => {
switch (key) {
case 'save':
addFile();
break;
case 'edit':
editFile();
break;
case 'delete':
deleteFile();
break;
default:
break;
}
};
const menu = isEditing ? ( const menu = isEditing ? (
<Menu> <Menu
<Menu.Item key="save" icon={<PlusOutlined />} onClick={saveFile}> items={[
{ label: '保存', key: 'save', icon: <PlusOutlined /> },
</Menu.Item> { label: '退出编辑', key: 'exit', icon: <EditOutlined /> },
<Menu.Item key="exit" icon={<EditOutlined />} onClick={cancelEdit}> ]}
退 onClick={({ key, domEvent }) => {
</Menu.Item> domEvent.stopPropagation();
</Menu> action(key);
}}
/>
) : ( ) : (
<Menu> <Menu
<Menu.Item key="add" icon={<PlusOutlined />} onClick={addFile}> items={[
{ label: '新建', key: 'add', icon: <PlusOutlined /> },
</Menu.Item> {
<Menu.Item label: '编辑',
key="edit" key: 'edit',
icon={<EditOutlined />} icon: <EditOutlined />,
onClick={editFile} disabled: !select,
disabled={!select} },
> {
label: '删除',
</Menu.Item> key: 'delete',
<Menu.Item icon: <DeleteOutlined />,
key="delete" disabled: !select,
icon={<DeleteOutlined />} },
onClick={deleteFile} ]}
disabled={!select} onClick={({ key, domEvent }) => {
> domEvent.stopPropagation();
menuAction(key);
</Menu.Item> }}
</Menu> />
); );
return ( return (
+2
View File
@@ -43,6 +43,8 @@ const SaveModal = ({
title="保存文件" title="保存文件"
visible={visible} visible={visible}
forceRender forceRender
centered
maskClosable={false}
onOk={() => { onOk={() => {
form form
.validateFields() .validateFields()
+1
View File
@@ -43,6 +43,7 @@ const SettingModal = ({
title="运行设置" title="运行设置"
visible={visible} visible={visible}
forceRender forceRender
centered
onCancel={() => handleCancel()} onCancel={() => handleCancel()}
> >
<Form <Form
+2
View File
@@ -43,6 +43,8 @@ const AppModal = ({
title={app ? '编辑应用' : '新建应用'} title={app ? '编辑应用' : '新建应用'}
visible={visible} visible={visible}
forceRender forceRender
centered
maskClosable={false}
onOk={() => { onOk={() => {
form form
.validateFields() .validateFields()
+26 -23
View File
@@ -17,12 +17,7 @@ import {
import config from '@/utils/config'; import config from '@/utils/config';
import { PageContainer } from '@ant-design/pro-layout'; import { PageContainer } from '@ant-design/pro-layout';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
import { import * as DarkReader from '@umijs/ssr-darkreader';
enable as enableDarkMode,
disable as disableDarkMode,
auto as followSystemColorScheme,
setFetchMethod,
} from 'darkreader';
import AppModal from './appModal'; import AppModal from './appModal';
import { import {
EditOutlined, EditOutlined,
@@ -116,8 +111,7 @@ const Setting = ({
]; ];
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const defaultDarken = localStorage.getItem('qinglong_dark_theme') || 'auto'; const defaultTheme = localStorage.getItem('qinglong_dark_theme') || 'auto';
const [theme, setTheme] = useState(defaultDarken);
const [dataSource, setDataSource] = useState<any[]>([]); const [dataSource, setDataSource] = useState<any[]>([]);
const [isModalVisible, setIsModalVisible] = useState(false); const [isModalVisible, setIsModalVisible] = useState(false);
const [editedApp, setEditedApp] = useState<any>(); const [editedApp, setEditedApp] = useState<any>();
@@ -126,10 +120,27 @@ const Setting = ({
const [notificationInfo, setNotificationInfo] = useState<any>(); const [notificationInfo, setNotificationInfo] = useState<any>();
const [logRemoveFrequency, setLogRemoveFrequency] = useState<number>(); const [logRemoveFrequency, setLogRemoveFrequency] = useState<number>();
const [form] = Form.useForm(); const [form] = Form.useForm();
const {
enable: enableDarkMode,
disable: disableDarkMode,
exportGeneratedCSS: collectCSS,
setFetchMethod,
auto: followSystemColorScheme,
} = DarkReader || {};
const themeChange = (e: any) => { const themeChange = (e: any) => {
setTheme(e.target.value); const _theme = e.target.value;
localStorage.setItem('qinglong_dark_theme', e.target.value); localStorage.setItem('qinglong_dark_theme', e.target.value);
setFetchMethod(fetch);
if (_theme === 'dark') {
enableDarkMode({});
} else if (_theme === 'light') {
disableDarkMode();
} else {
followSystemColorScheme({});
}
reloadTheme();
}; };
const getApps = () => { const getApps = () => {
@@ -300,18 +311,6 @@ const Setting = ({
}); });
}; };
useEffect(() => {
setFetchMethod(window.fetch);
if (theme === 'dark') {
enableDarkMode({});
} else if (theme === 'light') {
disableDarkMode();
} else {
followSystemColorScheme({});
}
reloadTheme(theme);
}, [theme]);
return ( return (
<PageContainer <PageContainer
className="ql-container-wrapper" className="ql-container-wrapper"
@@ -357,11 +356,15 @@ const Setting = ({
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab="其他设置" key="other"> <Tabs.TabPane tab="其他设置" key="other">
<Form layout="vertical" form={form}> <Form layout="vertical" form={form}>
<Form.Item label="主题设置" name="theme" initialValue={theme}> <Form.Item
label="主题设置"
name="theme"
initialValue={defaultTheme}
>
<Radio.Group <Radio.Group
options={optionsWithDisabled} options={optionsWithDisabled}
onChange={themeChange} onChange={themeChange}
value={theme} value={defaultTheme}
optionType="button" optionType="button"
buttonStyle="solid" buttonStyle="solid"
/> />
+2 -2
View File
@@ -21,7 +21,7 @@ const columns = [
align: 'center' as const, align: 'center' as const,
width: 50, width: 50,
render: (text: string, record: any, index: number) => { render: (text: string, record: any, index: number) => {
return <span style={{ cursor: 'text' }}>{index + 1} </span>; return index + 1;
}, },
}, },
{ {
@@ -30,7 +30,7 @@ const columns = [
key: 'timestamp', key: 'timestamp',
align: 'center' as const, align: 'center' as const,
render: (text: string, record: any) => { render: (text: string, record: any) => {
return <span>{new Date(record.timestamp).toLocaleString()}</span>; return new Date(record.timestamp).toLocaleString();
}, },
}, },
{ {
+43 -1
View File
@@ -1,10 +1,13 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Typography, Input, Form, Button, message } from 'antd'; import { Typography, Input, Form, Button, message, Avatar, Upload } from 'antd';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
import config from '@/utils/config'; import config from '@/utils/config';
import { history } from 'umi'; import { history } from 'umi';
import QRCode from 'qrcode.react'; import QRCode from 'qrcode.react';
import { PageLoading } from '@ant-design/pro-layout'; import { PageLoading } from '@ant-design/pro-layout';
import { UploadOutlined, UserOutlined } from '@ant-design/icons';
import ImgCrop from 'antd-img-crop';
import 'antd/es/slider/style';
const { Title, Link } = Typography; const { Title, Link } = Typography;
@@ -14,6 +17,7 @@ const SecuritySettings = ({ user, userChange }: any) => {
const [twoFactoring, setTwoFactoring] = useState(false); const [twoFactoring, setTwoFactoring] = useState(false);
const [twoFactorInfo, setTwoFactorInfo] = useState<any>(); const [twoFactorInfo, setTwoFactorInfo] = useState<any>();
const [code, setCode] = useState<string>(); const [code, setCode] = useState<string>();
const [avatar, setAvatar] = useState<string>();
const handleOk = (values: any) => { const handleOk = (values: any) => {
request request
@@ -86,8 +90,16 @@ const SecuritySettings = ({ user, userChange }: any) => {
}); });
}; };
const onChange = (e) => {
if (e.file && e.file.response) {
setAvatar(`/api/static/${e.file.response.data}`);
userChange();
}
};
useEffect(() => { useEffect(() => {
setTwoFactorActivated(user && user.twoFactorActivated); setTwoFactorActivated(user && user.twoFactorActivated);
setAvatar(user.avatar && `/api/static/${user.avatar}`);
}, [user]); }, [user]);
return twoFactoring ? ( return twoFactoring ? (
@@ -210,6 +222,36 @@ const SecuritySettings = ({ user, userChange }: any) => {
> >
{twoFactorActivated ? '禁用' : '启用'} {twoFactorActivated ? '禁用' : '启用'}
</Button> </Button>
<div
style={{
fontSize: 18,
borderBottom: '1px solid #f0f0f0',
marginBottom: 8,
paddingBottom: 4,
marginTop: 16,
}}
>
</div>
<Avatar size={128} shape="square" icon={<UserOutlined />} src={avatar} />
<ImgCrop rotate>
<Upload
method="put"
showUploadList={false}
maxCount={1}
action="/api/user/avatar"
onChange={onChange}
name="avatar"
headers={{
Authorization: `Bearer ${localStorage.getItem(config.authKey)}`,
}}
>
<Button icon={<UploadOutlined />} style={{ marginLeft: 8 }}>
</Button>
</Upload>
</ImgCrop>
</> </>
); );
}; };
View File
+598
View File
@@ -0,0 +1,598 @@
import React, { useState, useEffect } from 'react';
import {
Button,
message,
Modal,
Table,
Tag,
Space,
Dropdown,
Menu,
Typography,
Input,
Tooltip,
} from 'antd';
import {
ClockCircleOutlined,
Loading3QuartersOutlined,
CloseCircleOutlined,
EllipsisOutlined,
CheckCircleOutlined,
EditOutlined,
StopOutlined,
DeleteOutlined,
FileTextOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import config from '@/utils/config';
import { PageContainer } from '@ant-design/pro-layout';
import { request } from '@/utils/http';
import SubscriptionModal from './modal';
import { getTableScroll } from '@/utils/index';
import { history } from 'umi';
import './index.less';
import SubscriptionLogModal from './logModal';
const { Text, Paragraph } = Typography;
const { Search } = Input;
export enum SubscriptionStatus {
'running',
'idle',
'disabled',
'queued',
}
export enum IntervalSchedule {
'days' = '天',
'hours' = '时',
'minutes' = '分',
'seconds' = '秒',
}
export enum SubscriptionType {
'private-repo' = '私有仓库',
'public-repo' = '公开仓库',
'file' = '单文件',
}
const Subscription = ({ headerStyle, isPhone, socketMessage }: any) => {
const columns: any = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 150,
align: 'center' as const,
sorter: {
compare: (a: any, b: any) => a.name.localeCompare(b.name),
multiple: 2,
},
},
{
title: '链接',
dataIndex: 'url',
key: 'url',
align: 'center' as const,
sorter: {
compare: (a: any, b: any) => a.name.localeCompare(b.name),
multiple: 2,
},
render: (text: string, record: any) => {
return (
<Paragraph
style={{
wordBreak: 'break-all',
marginBottom: 0,
textAlign: 'left',
}}
ellipsis={{ tooltip: text, rows: 2 }}
>
{text}
</Paragraph>
);
},
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 130,
align: 'center' as const,
render: (text: string, record: any) => {
return (SubscriptionType as any)[record.type];
},
},
{
title: '分支',
dataIndex: 'branch',
key: 'branch',
width: 130,
align: 'center' as const,
render: (text: string, record: any) => {
return record.branch || '-';
},
},
{
title: '定时规则',
width: 180,
align: 'center' as const,
render: (text: string, record: any) => {
if (record.schedule_type === 'interval') {
const { type, value } = record.interval_schedule;
return `${value}${(IntervalSchedule as any)[type]}`;
}
return record.schedule;
},
},
{
title: '状态',
key: 'status',
dataIndex: 'status',
align: 'center' as const,
width: 110,
filters: [
{
text: '运行中',
value: 0,
},
{
text: '空闲中',
value: 1,
},
{
text: '已禁用',
value: 2,
},
],
onFilter: (value: number, record: any) => {
if (record.is_disabled && record.status !== 0) {
return value === 2;
} else {
return record.status === value;
}
},
render: (text: string, record: any) => (
<>
{(!record.is_disabled ||
record.status !== SubscriptionStatus.idle) && (
<>
{record.status === SubscriptionStatus.idle && (
<Tag icon={<ClockCircleOutlined />} color="default">
</Tag>
)}
{record.status === SubscriptionStatus.running && (
<Tag
icon={<Loading3QuartersOutlined spin />}
color="processing"
>
</Tag>
)}
</>
)}
{record.is_disabled === 1 &&
record.status === SubscriptionStatus.idle && (
<Tag icon={<CloseCircleOutlined />} color="error">
</Tag>
)}
</>
),
},
{
title: '操作',
key: 'action',
align: 'center' as const,
width: 130,
render: (text: string, record: any, index: number) => {
const isPc = !isPhone;
return (
<Space size="middle">
{record.status === SubscriptionStatus.idle && (
<Tooltip title={isPc ? '运行' : ''}>
<a
onClick={(e) => {
e.stopPropagation();
runSubscription(record, index);
}}
>
<PlayCircleOutlined />
</a>
</Tooltip>
)}
{record.status !== SubscriptionStatus.idle && (
<Tooltip title={isPc ? '停止' : ''}>
<a
onClick={(e) => {
e.stopPropagation();
stopSubsciption(record, index);
}}
>
<PauseCircleOutlined />
</a>
</Tooltip>
)}
<Tooltip title={isPc ? '日志' : ''}>
<a
onClick={(e) => {
e.stopPropagation();
setLogSubscription({ ...record, timestamp: Date.now() });
}}
>
<FileTextOutlined />
</a>
</Tooltip>
<MoreBtn key="more" record={record} index={index} />
</Space>
);
},
},
];
const [value, setValue] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [isModalVisible, setIsModalVisible] = useState(false);
const [editedSubscription, setEditedSubscription] = useState();
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [tableScrollHeight, setTableScrollHeight] = useState<number>();
const [searchValue, setSearchValue] = useState('');
const [isLogModalVisible, setIsLogModalVisible] = useState(false);
const [logSubscription, setLogSubscription] = useState<any>();
const runSubscription = (record: any, index: number) => {
Modal.confirm({
title: '确认运行',
content: (
<>
{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{record.name}
</Text>{' '}
</>
),
onOk() {
request
.put(`${config.apiPrefix}subscriptions/run`, { data: [record.id] })
.then((data: any) => {
if (data.code === 200) {
const result = [...value];
const i = result.findIndex((x) => x.id === record.id);
result.splice(i, 1, {
...record,
status: SubscriptionStatus.running,
});
setValue(result);
} else {
message.error(data);
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
const stopSubsciption = (record: any, index: number) => {
Modal.confirm({
title: '确认停止',
content: (
<>
{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{record.name}
</Text>{' '}
</>
),
onOk() {
request
.put(`${config.apiPrefix}subscriptions/stop`, { data: [record.id] })
.then((data: any) => {
if (data.code === 200) {
const result = [...value];
const i = result.findIndex((x) => x.id === record.id);
result.splice(i, 1, {
...record,
pid: null,
status: SubscriptionStatus.idle,
});
setValue(result);
} else {
message.error(data);
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
const getSubscriptions = () => {
setLoading(true);
request
.get(`${config.apiPrefix}subscriptions?searchValue=${searchText}`)
.then((data: any) => {
setValue(data.data);
setCurrentPage(1);
})
.finally(() => setLoading(false));
};
const addSubscription = () => {
setEditedSubscription(null as any);
setIsModalVisible(true);
};
const editSubscription = (record: any, index: number) => {
setEditedSubscription(record);
setIsModalVisible(true);
};
const delSubscription = (record: any, index: number) => {
Modal.confirm({
title: '确认删除',
content: (
<>
{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{record.name}
</Text>{' '}
</>
),
onOk() {
request
.delete(`${config.apiPrefix}subscriptions`, { data: [record.id] })
.then((data: any) => {
if (data.code === 200) {
message.success('删除成功');
const result = [...value];
const i = result.findIndex((x) => x.id === record.id);
result.splice(i, 1);
setValue(result);
} else {
message.error(data);
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
const enabledOrDisabledSubscription = (record: any, index: number) => {
Modal.confirm({
title: `确认${record.is_disabled === 1 ? '启用' : '禁用'}`,
content: (
<>
{record.is_disabled === 1 ? '启用' : '禁用'}
{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{record.name}
</Text>{' '}
</>
),
onOk() {
request
.put(
`${config.apiPrefix}subscriptions/${
record.is_disabled === 1 ? 'enable' : 'disable'
}`,
{
data: [record.id],
},
)
.then((data: any) => {
if (data.code === 200) {
const newStatus = record.is_disabled === 1 ? 0 : 1;
const result = [...value];
const i = result.findIndex((x) => x.id === record.id);
result.splice(i, 1, {
...record,
is_disabled: newStatus,
});
setValue(result);
} else {
message.error(data);
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
const MoreBtn: React.FC<{
record: any;
index: number;
}> = ({ record, index }) => (
<Dropdown
arrow={{ pointAtCenter: true }}
placement="bottomRight"
trigger={['click']}
overlay={
<Menu
items={[
{ label: '编辑', key: 'edit', icon: <EditOutlined /> },
{
label: record.is_disabled === 1 ? '启用' : '禁用',
key: 'enableOrDisable',
icon:
record.is_disabled === 1 ? (
<CheckCircleOutlined />
) : (
<StopOutlined />
),
},
{ label: '删除', key: 'delete', icon: <DeleteOutlined /> },
]}
onClick={({ key, domEvent }) => {
domEvent.stopPropagation();
action(key, record, index);
}}
/>
}
>
<a onClick={(e) => e.stopPropagation()}>
<EllipsisOutlined />
</a>
</Dropdown>
);
const action = (key: string | number, record: any, index: number) => {
switch (key) {
case 'edit':
editSubscription(record, index);
break;
case 'enableOrDisable':
enabledOrDisabledSubscription(record, index);
break;
case 'delete':
delSubscription(record, index);
break;
default:
break;
}
};
const handleCancel = (subscription?: any) => {
setIsModalVisible(false);
if (subscription) {
handleSubscriptions(subscription);
}
};
const onSearch = (value: string) => {
setSearchText(value.trim());
};
const handleSubscriptions = (subscription: any) => {
const index = value.findIndex((x) => x.id === subscription.id);
const result = [...value];
if (index === -1) {
result.unshift(subscription);
} else {
result.splice(index, 1, {
...subscription,
});
}
setValue(result);
};
const onPageChange = (page: number, pageSize: number | undefined) => {
setCurrentPage(page);
setPageSize(pageSize as number);
localStorage.setItem('pageSize', pageSize + '');
};
const getRowClassName = (record: any, index: number) => {
return record.isPinned
? 'pinned-subscription subscription'
: 'subscription';
};
useEffect(() => {
if (!socketMessage) return;
const { type, message, references } = socketMessage;
if (type === 'runSubscriptionEnd' && references.length > 0) {
const result = [...value];
for (let i = 0; i < references.length; i++) {
const index = value.findIndex((x) => x.id === references[i]);
result.splice(index, 1, {
...result[index],
status: SubscriptionStatus.idle,
});
}
setValue(result);
}
}, [socketMessage]);
useEffect(() => {
if (logSubscription) {
localStorage.setItem('logSubscription', logSubscription.id);
setIsLogModalVisible(true);
}
}, [logSubscription]);
useEffect(() => {
getSubscriptions();
}, [searchText]);
useEffect(() => {
setPageSize(parseInt(localStorage.getItem('pageSize') || '20'));
setTimeout(() => {
setTableScrollHeight(getTableScroll());
});
}, []);
return (
<PageContainer
className="ql-container-wrapper subscriptiontab-wrapper"
title="订阅管理"
extra={[
<Search
placeholder="请输入名称或者关键词"
style={{ width: 'auto' }}
enterButton
allowClear
loading={loading}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onSearch={onSearch}
/>,
<Button key="2" type="primary" onClick={() => addSubscription()}>
</Button>,
]}
header={{
style: headerStyle,
}}
>
<Table
columns={columns}
pagination={{
current: currentPage,
onChange: onPageChange,
pageSize: pageSize,
showSizeChanger: true,
simple: isPhone,
defaultPageSize: 20,
showTotal: (total: number, range: number[]) =>
`${range[0]}-${range[1]} 条/总共 ${total}`,
pageSizeOptions: [20, 100, 500, 1000] as any,
}}
dataSource={value}
rowKey="id"
size="middle"
scroll={{ x: 1000, y: tableScrollHeight }}
loading={loading}
rowClassName={getRowClassName}
/>
<SubscriptionModal
visible={isModalVisible}
handleCancel={handleCancel}
subscription={editedSubscription}
/>
<SubscriptionLogModal
visible={isLogModalVisible}
handleCancel={() => {
setIsLogModalVisible(false);
}}
subscription={logSubscription}
/>
</PageContainer>
);
};
export default Subscription;
+132
View File
@@ -0,0 +1,132 @@
import React, { useEffect, useState } from 'react';
import { Modal, message, Input, Form, Statistic, Button } from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import {
Loading3QuartersOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import { PageLoading } from '@ant-design/pro-layout';
const SubscriptionLogModal = ({
subscription,
handleCancel,
visible,
data,
logUrl,
}: {
subscription?: any;
visible: boolean;
handleCancel: () => void;
data?: string;
logUrl?: string;
}) => {
const [value, setValue] = useState<string>('启动中...');
const [loading, setLoading] = useState<any>(true);
const [executing, setExecuting] = useState<any>(true);
const [isPhone, setIsPhone] = useState(false);
const getCronLog = (isFirst?: boolean) => {
if (isFirst) {
setLoading(true);
}
request
.get(
logUrl
? logUrl
: `${config.apiPrefix}subscriptions/${subscription.id}/log`,
)
.then((data: any) => {
if (
localStorage.getItem('logSubscription') === String(subscription.id)
) {
const log = data.data as string;
setValue(log || '暂无日志');
setExecuting(log && !log.includes('执行结束'));
if (log && !log.includes('执行结束')) {
setTimeout(() => {
getCronLog();
}, 2000);
}
}
})
.finally(() => {
if (isFirst) {
setLoading(false);
}
});
};
const cancel = () => {
localStorage.removeItem('logSubscription');
handleCancel();
};
const titleElement = () => {
return (
<>
{(executing || loading) && <Loading3QuartersOutlined spin />}
{!executing && !loading && <CheckCircleOutlined />}
<span style={{ marginLeft: 5 }}>
{subscription && subscription.name}
</span>
</>
);
};
useEffect(() => {
if (subscription && subscription.id && visible) {
getCronLog(true);
}
}, [subscription, visible]);
useEffect(() => {
if (data) {
setValue(data);
}
}, [data]);
useEffect(() => {
setIsPhone(document.body.clientWidth < 768);
}, []);
return (
<Modal
title={titleElement()}
visible={visible}
centered
className="log-modal"
bodyStyle={{
minHeight: '300px',
}}
forceRender
onOk={() => cancel()}
onCancel={() => cancel()}
footer={[
<Button type="primary" onClick={() => cancel()}>
</Button>,
]}
>
{loading ? (
<PageLoading />
) : (
<pre
style={
isPhone
? {
fontFamily: 'Source Code Pro',
width: 375,
zoom: 0.83,
}
: {}
}
>
{value}
</pre>
)}
</Modal>
);
};
export default SubscriptionLogModal;
+453
View File
@@ -0,0 +1,453 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Modal, message, InputNumber, Form, Radio, Select, Input } from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import cron_parser from 'cron-parser';
const { Option } = Select;
const repoUrlRegx = /[^\/\:]+\/[^\/]+(?=\.git)/;
const fileUrlRegx = /[^\/\:]+\/[^\/]+$/;
const SubscriptionModal = ({
subscription,
handleCancel,
visible,
}: {
subscription?: any;
visible: boolean;
handleCancel: (needUpdate?: boolean) => void;
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [type, setType] = useState('public-repo');
const [scheduleType, setScheduleType] = useState('crontab');
const [pullType, setPullType] = useState<'ssh-key' | 'user-pwd'>('ssh-key');
const handleOk = async (values: any) => {
setLoading(true);
const method = subscription ? 'put' : 'post';
const payload = { ...values };
if (subscription) {
payload.id = subscription.id;
}
try {
const { code, data } = await request[method](
`${config.apiPrefix}subscriptions`,
{
data: payload,
},
);
if (code === 200) {
message.success(subscription ? '更新订阅成功' : '新建订阅成功');
} else {
message.error(data);
}
setLoading(false);
handleCancel(data);
} catch (error: any) {
setLoading(false);
}
};
const typeChange = (e) => {
setType(e.target.value);
const _url = form.getFieldValue('url');
const _branch = form.getFieldValue('branch');
form.setFieldsValue({
alias: formatAlias(_url, _branch, e.target.value),
});
if (_url) {
form.validateFields(['url']);
}
};
const scheduleTypeChange = (e) => {
setScheduleType(e.target.value);
form.setFieldsValue({ schedule: '' });
};
const pullTypeChange = (e) => {
setPullType(e.target.value);
};
const onUrlChange = (e) => {
const _branch = form.getFieldValue('branch');
form.setFieldsValue({
alias: formatAlias(e.target.value, _branch),
});
};
const onBranchChange = (e) => {
const _url = form.getFieldValue('url');
form.setFieldsValue({
alias: formatAlias(_url, e.target.value),
});
};
const formatAlias = (_url: string, _branch: string, _type = type) => {
let _alias = '';
const _regx = _type === 'file' ? fileUrlRegx : repoUrlRegx;
if (_regx.test(_url)) {
_alias = _url.match(_regx)![0].replaceAll('/', '_').replaceAll('.', '_');
}
if (_branch) {
_alias = _alias + '_' + _branch;
}
return _alias;
};
const IntervalSelect = ({
value,
onChange,
}: {
value?: any;
onChange?: (param: any) => void;
}) => {
const [intervalType, setIntervalType] = useState('days');
const [intervalNumber, setIntervalNumber] = useState<number>();
const intervalTypeChange = (type: string) => {
setIntervalType(type);
onChange?.({ type, value: intervalNumber });
};
const numberChange = (value: number) => {
setIntervalNumber(value);
onChange?.({ type: intervalType, value });
};
useEffect(() => {
if (value) {
setIntervalType(value.type);
setIntervalNumber(value.value);
}
}, [value]);
return (
<Input.Group compact>
<InputNumber
addonBefore="每"
precision={0}
min={1}
value={intervalNumber}
style={{ width: 'calc(100% - 58px)' }}
onChange={numberChange}
/>
<Select value={intervalType} onChange={intervalTypeChange}>
<Option value="days"></Option>
<Option value="hours"></Option>
<Option value="minutes"></Option>
<Option value="seconds"></Option>
</Select>
</Input.Group>
);
};
const PullOptions = ({
value,
onChange,
type,
}: {
value?: any;
type: 'ssh-key' | 'user-pwd';
onChange?: (param: any) => void;
}) => {
return type === 'ssh-key' ? (
<Form.Item
name={['pull_option', 'private_key']}
label="私钥"
rules={[{ required: true }]}
>
<Input.TextArea
rows={4}
autoSize={{ minRows: 1, maxRows: 6 }}
placeholder="请输入私钥"
/>
</Form.Item>
) : (
<>
<Form.Item
name={['pull_option', 'username']}
label="用户名"
rules={[{ required: true }]}
>
<Input placeholder="请输入认证用户名" />
</Form.Item>
<Form.Item
name={['pull_option', 'password']}
tooltip="Github已不支持密码认证,请使用Token方式"
label="密码/Token"
rules={[{ required: true }]}
>
<Input placeholder="请输入密码或者Token" />
</Form.Item>
</>
);
};
const onPaste = useCallback((e: any) => {
const text = e.clipboardData.getData('text');
if (!subscription && text.includes('ql ')) {
const [
,
type,
url,
whitelist,
blacklist,
dependences,
branch,
extensions,
] = text.split(' ').map((x) => x.trim());
form.setFieldsValue({
type:
type === 'raw'
? 'file'
: url.startsWith('http')
? 'public-repo'
: 'private-repo',
url,
whitelist,
blacklist,
dependences,
branch,
extensions,
});
}
}, []);
useEffect(() => {
if (visible) {
window.addEventListener('paste', onPaste);
} else {
window.removeEventListener('paste', onPaste);
}
}, [visible]);
useEffect(() => {
form.setFieldsValue(subscription || {});
setType((subscription && subscription.type) || 'public-repo');
setScheduleType((subscription && subscription.schedule_type) || 'crontab');
setPullType((subscription && subscription.pull_type) || 'ssh-key');
if (!subscription) {
form.resetFields();
}
}, [subscription, visible]);
const isFirefox = navigator.userAgent.includes('Firefox');
const isSafari =
navigator.userAgent.includes('Safari') &&
!navigator.userAgent.includes('Chrome');
const isQQBrowser = navigator.userAgent.includes('QQBrowser');
return (
<Modal
title={
subscription ? (
'编辑订阅'
) : (
<span>
<small
style={{
color: '#999',
fontWeight: 400,
fontSize: isFirefox ? 9 : 12,
marginLeft: 2,
zoom: isSafari ? 0.66 : 0.8,
letterSpacing: isQQBrowser ? -2 : 0,
}}
>
repo/raw命令
</small>
</span>
)
}
visible={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} name="form_in_modal" layout="vertical">
<Form.Item name="name" label="名称">
<Input placeholder="请输入订阅名" />
</Form.Item>
<Form.Item
name="type"
label="类型"
rules={[{ required: true }]}
initialValue={'public-repo'}
>
<Radio.Group onChange={typeChange}>
<Radio value="public-repo"></Radio>
<Radio value="private-repo"></Radio>
<Radio value="file"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item
name="url"
label="链接"
rules={[
{ required: true },
{ pattern: type === 'file' ? fileUrlRegx : repoUrlRegx },
]}
>
<Input.TextArea
rows={4}
autoSize={true}
placeholder="请输入订阅链接"
onPaste={onUrlChange}
onChange={onUrlChange}
/>
</Form.Item>
{type !== 'file' && (
<Form.Item name="branch" label="分支">
<Input
placeholder="请输入分支"
onPaste={onBranchChange}
onChange={onBranchChange}
/>
</Form.Item>
)}
<Form.Item
name="alias"
label="唯一值"
rules={[{ required: true, message: '' }]}
tooltip="唯一值用于日志目录和私钥别名"
>
<Input placeholder="自动生成" disabled />
</Form.Item>
{type === 'private-repo' && (
<>
<Form.Item
name="pull_type"
label="拉取方式"
initialValue={'ssh-key'}
rules={[{ required: true }]}
>
<Radio.Group onChange={pullTypeChange}>
<Radio value="ssh-key"></Radio>
<Radio value="user-pwd">/Token</Radio>
</Radio.Group>
</Form.Item>
<PullOptions type={pullType} />
</>
)}
<Form.Item
name="schedule_type"
label="定时类型"
initialValue={'crontab'}
rules={[{ required: true }]}
>
<Radio.Group onChange={scheduleTypeChange}>
<Radio value="crontab">crontab</Radio>
<Radio value="interval">interval</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
name={scheduleType === 'crontab' ? 'schedule' : 'interval_schedule'}
label="定时规则"
rules={[
{ required: true },
{
validator: (rule, value) => {
if (
scheduleType === 'interval' ||
!value ||
cron_parser.parseExpression(value).hasNext()
) {
return Promise.resolve();
} else {
return Promise.reject('Subscription表达式格式有误');
}
},
},
]}
>
{scheduleType === 'interval' ? (
<IntervalSelect />
) : (
<Input placeholder="秒(可选) 分 时 天 月 周" />
)}
</Form.Item>
{type !== 'file' && (
<>
<Form.Item
name="whitelist"
label="白名单"
tooltip="多个关键词竖线分割,支持正则表达式"
>
<Input.TextArea
rows={4}
autoSize={true}
placeholder="请输入脚本筛选白名单关键词,多个关键词竖线分割"
/>
</Form.Item>
<Form.Item
name="blacklist"
label="黑名单"
tooltip="多个关键词竖线分割,支持正则表达式"
>
<Input.TextArea
rows={4}
autoSize={true}
placeholder="请输入脚本筛选黑名单关键词,多个关键词竖线分割"
/>
</Form.Item>
<Form.Item
name="dependences"
label="依赖文件"
tooltip="多个关键词竖线分割,支持正则表达式"
>
<Input.TextArea
rows={4}
autoSize={true}
placeholder="请输入脚本依赖文件关键词,多个关键词竖线分割"
/>
</Form.Item>
<Form.Item
name="extensions"
label="文件后缀"
tooltip="仓库需要拉取的文件后缀,多个后缀空格分隔,默认使用配置文件中的RepoFileExtensions"
>
<Input placeholder="请输入文件后缀" />
</Form.Item>
<Form.Item
name="sub_before"
label="执行前"
tooltip="运行订阅前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js"
>
<Input.TextArea
rows={4}
autoSize={true}
placeholder="请输入运行订阅前要执行的命令"
/>
</Form.Item>
<Form.Item
name="sub_after"
label="执行后"
tooltip="运行订阅后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js"
>
<Input.TextArea
rows={4}
autoSize={true}
placeholder="请输入运行订阅后要执行的命令"
/>
</Form.Item>
</>
)}
</Form>
</Modal>
);
};
export default SubscriptionModal;
+6
View File
@@ -43,6 +43,10 @@ export default {
name: '环境变量', name: '环境变量',
value: 'envs', value: 'envs',
}, },
{
name: '订阅管理',
value: 'subscriptions',
},
{ {
name: '配置文件', name: '配置文件',
value: 'configs', value: 'configs',
@@ -67,6 +71,7 @@ export default {
scopesMap: { scopesMap: {
crons: '定时任务', crons: '定时任务',
envs: '环境变量', envs: '环境变量',
subscriptions: '订阅管理',
configs: '配置文件', configs: '配置文件',
scripts: '脚本管理', scripts: '脚本管理',
logs: '任务日志', logs: '任务日志',
@@ -214,6 +219,7 @@ export default {
'/initialization': '初始化', '/initialization': '初始化',
'/cron': '定时任务', '/cron': '定时任务',
'/env': '环境变量', '/env': '环境变量',
'/subscription': '订阅管理',
'/config': '配置文件', '/config': '配置文件',
'/script': '脚本管理', '/script': '脚本管理',
'/diff': '对比工具', '/diff': '对比工具',
+10 -2
View File
@@ -38,7 +38,15 @@ export const useCtx = () => {
}; };
export const useTheme = () => { export const useTheme = () => {
const [theme, setTheme] = useState<string>(''); const [theme, setTheme] = useState<'vs' | 'vs-dark'>();
const reloadTheme = () => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const storageTheme = localStorage.getItem('qinglong_dark_theme');
const isDark =
(media.matches && storageTheme !== 'light') || storageTheme === 'dark';
setTheme(isDark ? 'vs-dark' : 'vs');
};
useEffect(() => { useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)'); const media = window.matchMedia('(prefers-color-scheme: dark)');
@@ -63,5 +71,5 @@ export const useTheme = () => {
} }
}, []); }, []);
return { theme }; return { theme, reloadTheme };
}; };
+1 -1
View File
@@ -11,7 +11,7 @@ const time = Date.now();
const errorHandler = function (error: any) { const errorHandler = function (error: any) {
if (error.response) { if (error.response) {
const msg = error.data const msg = error.data
? error.data.message || error.data ? error.data.message || error.message || error.data
: error.response.statusText; : error.response.statusText;
const responseStatus = error.response.status; const responseStatus = error.response.status;
if ([502, 504].includes(responseStatus)) { if ([502, 504].includes(responseStatus)) {
+8 -1
View File
@@ -10,12 +10,19 @@ export function init() {
integrations: [new Integrations.BrowserTracing()], integrations: [new Integrations.BrowserTracing()],
release: version, release: version,
tracesSampleRate: 1.0, tracesSampleRate: 1.0,
beforeBreadcrumb(breadcrumb, hint?) {
if (breadcrumb.data && breadcrumb.data.url) {
const url = breadcrumb.data.url.replace(/token=.*/, '');
breadcrumb.data.url = url;
}
return breadcrumb;
},
}); });
// monaco 编辑器配置cdn和locale // monaco 编辑器配置cdn和locale
loader.config({ loader.config({
paths: { paths: {
vs: 'https://cdn.staticfile.org/monaco-editor/0.32.1/min/vs', vs: 'https://cdn.staticfile.org/monaco-editor/0.33.0/min/vs',
}, },
'vs/nls': { 'vs/nls': {
availableLanguages: { availableLanguages: {
+11 -9
View File
@@ -1,10 +1,12 @@
export const version = '2.12.1'; export const version = '2.13.0';
export const changeLogLink = 'https://t.me/jiao_long/289'; export const changeLogLink = 'https://t.me/jiao_long/303';
export const changeLog = `2.12.1 版本说明 export const changeLog = `2.13.0 版本说明
1. 修复定时任务详情获取日志 1. 新增订阅管理模块,支持公开仓库/私有仓库等
2. 修复系统通知pushdeer 2. 支持修改头像
3. 修复python pushDeer推送,感谢 https://github.com/chen310 PR 3. 修复特殊字符环境变量异常
3. nodejs telegram推送改为json方式,感谢 https://github.com/kan3Git PR 4. 修改定时任务添加标签
4. 修复日志目录拼接规则 5. 修复添加任务默认会展示一个空标签
5. 其他bug修复 6. 修复用户名显示
7. 修复依赖安装
8. 其他bug修复
`; `;
+1 -1
View File
@@ -13,7 +13,7 @@
"@/*": ["src/*"], "@/*": ["src/*"],
"@@/*": ["src/.umi/*"] "@@/*": ["src/.umi/*"]
}, },
"lib": ["dom", "es2017", "esnext.asynciterable"], "lib": ["dom", "es2021", "esnext.asynciterable"],
"typeRoots": [ "typeRoots": [
"./node_modules/@types", "./node_modules/@types",
"./back/types", "./back/types",