├── .editorconfig ├── .env.example ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── PULL_REQUEST_TEMPLATE.md ├── config.yml └── workflows │ └── build_docker_image.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .umirc.ts ├── LICENSE ├── README-en.md ├── README.md ├── SECURITY.md ├── back ├── api │ ├── config.ts │ ├── cron.ts │ ├── dependence.ts │ ├── env.ts │ ├── health.ts │ ├── index.ts │ ├── log.ts │ ├── open.ts │ ├── script.ts │ ├── subscription.ts │ ├── system.ts │ ├── update.ts │ └── user.ts ├── app.ts ├── config │ ├── const.ts │ ├── http.ts │ ├── index.ts │ ├── serverEnv.ts │ ├── share.ts │ ├── subscription.ts │ └── util.ts ├── data │ ├── cron.ts │ ├── cronView.ts │ ├── dependence.ts │ ├── env.ts │ ├── index.ts │ ├── notify.ts │ ├── open.ts │ ├── sock.ts │ ├── subscription.ts │ └── system.ts ├── interface │ └── schedule.ts ├── loaders │ ├── app.ts │ ├── bootAfter.ts │ ├── db.ts │ ├── depInjector.ts │ ├── deps.ts │ ├── express.ts │ ├── initData.ts │ ├── initFile.ts │ ├── initTask.ts │ ├── logger.ts │ ├── server.ts │ └── sock.ts ├── middlewares │ └── monitoring.ts ├── protos │ ├── api.proto │ ├── api.ts │ ├── cron.proto │ ├── cron.ts │ ├── health.proto │ └── health.ts ├── schedule │ ├── addCron.ts │ ├── api.ts │ ├── client.ts │ ├── data.ts │ ├── delCron.ts │ └── health.ts ├── services │ ├── config.ts │ ├── cron.ts │ ├── cronView.ts │ ├── dependence.ts │ ├── env.ts │ ├── grpc.ts │ ├── health.ts │ ├── http.ts │ ├── log.ts │ ├── metrics.ts │ ├── notify.ts │ ├── open.ts │ ├── schedule.ts │ ├── script.ts │ ├── sock.ts │ ├── sshKey.ts │ ├── subscription.ts │ ├── system.ts │ └── user.ts ├── shared │ ├── interface.ts │ ├── pLimit.ts │ ├── runCron.ts │ ├── store.ts │ └── utils.ts ├── token.ts ├── tsconfig.json ├── types │ └── express.d.ts └── validation │ └── schedule.ts ├── docker ├── 310.Dockerfile ├── Dockerfile ├── docker-compose.yml ├── docker-entrypoint.sh ├── front.conf └── nginx.conf ├── ecosystem.config.js ├── nodemon.json ├── package.json ├── pnpm-lock.yaml ├── sample ├── auth.sample.json ├── config.sample.sh ├── extra.sample.sh ├── notify.js ├── notify.py ├── notify.py.save ├── ql_sample.js ├── ql_sample.py ├── task.sample.sh └── tool.ts ├── shell ├── api.sh ├── bot.sh ├── check.sh ├── env.sh ├── otask.sh ├── preload │ ├── client.js │ ├── client.py │ ├── sitecustomize.js │ └── sitecustomize.py ├── pub.sh ├── rmlog.sh ├── share.sh ├── task.sh └── update.sh ├── src ├── app.ts ├── assets │ └── fonts │ │ ├── SourceCodePro-Regular.otf.woff │ │ ├── SourceCodePro-Regular.ttf │ │ ├── SourceCodePro-Regular.ttf.woff2 │ │ ├── log.ttf │ │ ├── log.woff │ │ └── log.woff2 ├── components │ ├── copy.tsx │ ├── iconfont.tsx │ ├── index.less │ ├── name.tsx │ ├── tag.tsx │ └── terminal.tsx ├── hooks │ ├── useFilterTreeData.ts │ ├── useScrollHeight.ts │ └── useTableScrollHeight.ts ├── layouts │ ├── defaultProps.tsx │ ├── index.less │ └── index.tsx ├── loading.tsx ├── locales │ ├── en-US.json │ └── zh-CN.json ├── pages │ ├── 404.tsx │ ├── config │ │ ├── index.less │ │ └── index.tsx │ ├── crontab │ │ ├── const.ts │ │ ├── detail.tsx │ │ ├── index.less │ │ ├── index.tsx │ │ ├── logModal.tsx │ │ ├── modal.tsx │ │ ├── type.ts │ │ ├── viewCreateModal.tsx │ │ └── viewManageModal.tsx │ ├── dependence │ │ ├── index.less │ │ ├── index.tsx │ │ ├── logModal.tsx │ │ ├── modal.tsx │ │ └── type.ts │ ├── diff │ │ ├── index.less │ │ └── index.tsx │ ├── env │ │ ├── editNameModal.tsx │ │ ├── index.less │ │ ├── index.tsx │ │ └── modal.tsx │ ├── error │ │ ├── index.less │ │ └── index.tsx │ ├── initialization │ │ ├── index.less │ │ └── index.tsx │ ├── log │ │ ├── index.module.less │ │ └── index.tsx │ ├── login │ │ ├── index.less │ │ └── index.tsx │ ├── script │ │ ├── components │ │ │ └── UnsupportedFilePreview │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ ├── editModal.tsx │ │ ├── editNameModal.tsx │ │ ├── index.module.less │ │ ├── index.tsx │ │ ├── renameModal.tsx │ │ ├── saveModal.tsx │ │ └── setting.tsx │ ├── setting │ │ ├── about.tsx │ │ ├── appModal.tsx │ │ ├── checkUpdate.tsx │ │ ├── dependence.tsx │ │ ├── index.less │ │ ├── index.tsx │ │ ├── loginLog.tsx │ │ ├── notification.tsx │ │ ├── other.tsx │ │ ├── progress.tsx │ │ ├── security.tsx │ │ └── systemLog.tsx │ └── subscription │ │ ├── index.less │ │ ├── index.tsx │ │ ├── logModal.tsx │ │ └── modal.tsx ├── styles │ └── variable.less └── utils │ ├── codemirror │ └── systemLog.ts │ ├── config.ts │ ├── const.ts │ ├── date.ts │ ├── hooks.ts │ ├── http.tsx │ ├── index.ts │ ├── init.ts │ ├── monaco │ └── index.ts │ ├── type.ts │ └── websocket.ts ├── tsconfig.json ├── typings.d.ts └── version.yaml /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | GRPC_PORT=5500 2 | BACK_PORT=5600 3 | PORT=5700 4 | 5 | LOG_LEVEL='info' 6 | 7 | JWT_SECRET= 8 | JWT_EXPIRES_IN= 9 | 10 | QINIU_AK= 11 | QINIU_SK= 12 | QINIU_SCOPE= 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41E Bug report" 2 | description: Create a report to help us improve 3 | body: 4 | - type: input 5 | id: version 6 | attributes: 7 | label: Qinglong version 8 | validations: 9 | required: true 10 | - type: textarea 11 | id: steps-to-reproduce 12 | attributes: 13 | label: Steps to reproduce 14 | description: | 15 | What do we need to do after opening your repro in order to make the bug happen? Clear and concise reproduction instructions are important for us to be able to triage your issue in a timely manner. Note that you can use [Markdown](https://guides.github.com/features/mastering-markdown/) to format lists and code. 16 | placeholder: Steps to reproduce 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: expected 21 | attributes: 22 | label: What is expected? 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: actually-happening 27 | attributes: 28 | label: What is actually happening? 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: system-info 33 | attributes: 34 | label: System Info 35 | description: Output of `npx envinfo --system --binaries --browsers` 36 | render: shell 37 | placeholder: System, Binaries, Browsers 38 | - type: textarea 39 | id: additional-comments 40 | attributes: 41 | label: Any additional comments? 42 | description: e.g. some background/context of how you ran into this bug. 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Telegram Chat 4 | url: https://t.me/jiao_long 5 | about: Ask questions and discuss with other Qinglong users in real time. 6 | - name: Questions & Discussions 7 | url: https://github.com/whyour/qinglong/discussions/new?category=q-a 8 | about: Use GitHub discussions for message-board style questions and discussions. 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F680 New feature proposal" 2 | description: Suggest an idea for this project 3 | labels: [":sparkles: feature request"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for your interest in the project and taking the time to fill out this feature report! 9 | - type: textarea 10 | id: feature-description 11 | attributes: 12 | label: Clear and concise description of the problem 13 | description: "Explain your use case, context, and rationale behind this feature request. More importantly, what is the **end user experience** you are trying to build that led to the need for this feature?" 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: suggested-solution 18 | attributes: 19 | label: Suggested solution 20 | description: "In module [xy] we could provide following implementation..." 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: alternative 25 | attributes: 26 | label: Alternative 27 | description: Clear and concise description of any alternative solutions or features you've considered. 28 | - type: textarea 29 | id: additional-context 30 | attributes: 31 | label: Additional context 32 | description: Any other context or screenshots about the feature request here. 33 | - type: checkboxes 34 | id: checkboxes 35 | attributes: 36 | label: Validations 37 | description: Before submitting the issue, please make sure you do the following 38 | options: 39 | - label: Check that there isn't already an issue that request the same feature to avoid creating a duplicate. 40 | required: true -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR Type 2 | What kind of change does this PR introduce? 3 | 4 | 5 | 6 | - [ ] Bugfix 7 | - [ ] Feature 8 | - [ ] Code style update (formatting, local variables) 9 | - [ ] Refactoring (no functional changes, no api changes) 10 | - [ ] Other... Please describe: 11 | 12 | 13 | ## What is the current behavior? 14 | 15 | 16 | Issue Number: N/A 17 | 18 | 19 | ## What is the new behavior? 20 | 21 | 22 | ## Does this PR introduce a breaking change? 23 | 24 | - [ ] Yes 25 | - [ ] No 26 | 27 | 28 | 29 | 30 | 31 | ## Other information -------------------------------------------------------------------------------- /.github/config.yml: -------------------------------------------------------------------------------- 1 | # Comment to be posted to on PRs from first time contributors in your repository 2 | newPRWelcomeComment: | 3 | 💖 Thanks for opening this pull request! 💖 4 | Please be patient and we will get back to you as soon as we can. 5 | # Comment to be posted to on pull requests merged by a first time user 6 | firstPRMergeComment: > 7 | Congrats on merging your first pull request! 🎉🎉🎉 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /npm-debug.log* 6 | /yarn-error.log 7 | /yarn.lock 8 | /package-lock.json 9 | 10 | # production 11 | /static 12 | /data 13 | 14 | # misc 15 | .DS_Store 16 | 17 | # umi 18 | /src/.umi 19 | /src/.umi-production 20 | /src/.umi-test 21 | /.env.local 22 | .env 23 | .history 24 | .version.ts 25 | /.tmp 26 | __pycache__ 27 | /shell/preload/env.* 28 | /shell/preload/notify.* 29 | /shell/preload/*-notify.json 30 | /shell/preload/__ql_notify__.* 31 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.md 2 | **/*.svg 3 | **/*.ejs 4 | **/*.html 5 | /.umi 6 | /.umi-production 7 | /.umi-test 8 | /.history 9 | /.tmp 10 | /node_modules 11 | npm-debug.log* 12 | yarn-error.log 13 | yarn.lock 14 | package-lock.json 15 | /static 16 | /data 17 | DS_Store 18 | /src/.umi 19 | /src/.umi-production 20 | /src/.umi-test 21 | .env.local 22 | .env 23 | version.ts 24 | /.tmp -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 80, 5 | "overrides": [ 6 | { 7 | "files": ".prettierrc", 8 | "options": { "parser": "json" } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.umirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@umijs/max'; 2 | const CompressionPlugin = require('compression-webpack-plugin'); 3 | 4 | const baseUrl = process.env.QlBaseUrl || '/'; 5 | export default defineConfig({ 6 | hash: true, 7 | jsMinifier: 'terser', 8 | antd: {}, 9 | locale: { 10 | antd: true, 11 | title: true, 12 | baseNavigator: true, 13 | }, 14 | outputPath: 'static/dist', 15 | fastRefresh: true, 16 | favicons: [`https://qn.whyour.cn/favicon.svg`], 17 | publicPath: process.env.NODE_ENV === 'production' ? './' : '/', 18 | proxy: { 19 | [`${baseUrl}api`]: { 20 | target: 'http://127.0.0.1:5600/', 21 | changeOrigin: true, 22 | ws: true, 23 | pathRewrite: { [`^${baseUrl}api`]: '/api' }, 24 | }, 25 | }, 26 | chainWebpack: ((config: any) => { 27 | config.plugin('compression-webpack-plugin').use( 28 | new CompressionPlugin({ 29 | algorithm: 'gzip', 30 | test: new RegExp('\\.(js|css)$'), 31 | threshold: 10240, 32 | minRatio: 0.6, 33 | }), 34 | ); 35 | }) as any, 36 | headScripts: [`./api/env.js`], 37 | copy: [ 38 | { 39 | from: 'node_modules/monaco-editor/min/vs', 40 | to: 'static/dist/monaco-editor/min/vs', 41 | }, 42 | ], 43 | npmClient: 'pnpm', 44 | }); 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |

青龙

5 | 6 | 简体中文 | [English](./README-en.md) 7 | 8 | 支持 Python3、JavaScript、Shell、Typescript 的定时任务管理平台 9 | 10 | Timed task management platform supporting Python3, JavaScript, Shell, Typescript 11 | 12 | [![npm version][npm-version-image]][npm-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] 13 | 14 | [npm-version-image]: https://img.shields.io/npm/v/@whyour/qinglong?style=flat 15 | [npm-version-url]: https://www.npmjs.com/package/@whyour/qinglong?activeTab=readme 16 | [docker-pulls-image]: https://img.shields.io/docker/pulls/whyour/qinglong?style=flat 17 | [docker-pulls-url]: https://hub.docker.com/r/whyour/qinglong 18 | [docker-stars-image]: https://img.shields.io/docker/stars/whyour/qinglong?style=flat 19 | [docker-stars-url]: https://hub.docker.com/r/whyour/qinglong 20 | [docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat 21 | [docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong 22 | 23 | [Demo](http://demo.ninesix.cc:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong) 24 | 25 | [演示](http://demo.ninesix.cc:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg) 26 |
27 | 28 | ![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png) 29 | 30 | ## 功能 31 | 32 | - 支持多种脚本语言(python3、javaScript、shell、typescript) 33 | - 支持在线管理脚本、环境变量、配置文件 34 | - 支持在线查看任务日志 35 | - 支持秒级任务设置 36 | - 支持系统级通知 37 | - 支持暗黑模式 38 | - 支持手机端操作 39 | 40 | ## 版本 41 | 42 | ### docker 43 | 44 | `latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像 45 | 46 | ```bash 47 | docker pull whyour/qinglong:latest 48 | docker pull whyour/qinglong:debian 49 | ``` 50 | 51 | ### npm 52 | 53 | npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm` 54 | 55 | ```bash 56 | npm i @whyour/qinglong 57 | ``` 58 | 59 | ## 部署 60 | 61 | [查看文档](https://qinglong.online/guide/getting-started/installation-guide) 62 | 63 | ## 内置 API 64 | 65 | [查看文档](https://qinglong.online/guide/user-guide/built-in-api) 66 | 67 | ## 内置命令 68 | 69 | [查看文档](https://qinglong.online/guide/user-guide/basic-explanation) 70 | 71 | ## 开发 72 | 73 | ```bash 74 | git clone https://github.com/whyour/qinglong.git 75 | cd qinglong 76 | cp .env.example .env 77 | # 推荐使用 pnpm https://pnpm.io/zh/installation 78 | npm install -g pnpm@8.3.1 79 | pnpm install 80 | pnpm start 81 | ``` 82 | 83 | 打开你的浏览器,访问 84 | 85 | ## 链接 86 | 87 | - [nevinee](https://gitee.com/evine) 88 | - [crontab-ui](https://github.com/alseambusher/crontab-ui) 89 | - [Ant Design](https://ant.design) 90 | - [Ant Design Pro](https://pro.ant.design/) 91 | - [Umijs](https://umijs.org) 92 | - [darkreader](https://github.com/darkreader/darkreader) 93 | - [admin-server](https://github.com/sunpu007/admin-server) 94 | 95 | ## 名称来源 96 | 97 | 青龙,又名苍龙,在中国传统文化中是四象之一、[天之四灵](https://zh.wikipedia.org/wiki/%E5%A4%A9%E4%B9%8B%E5%9B%9B%E7%81%B5)之一,根据五行学说,它是代表东方的灵兽,为青色的龙,五行属木,代表的季节是春季,八卦主震。苍龙与应龙一样,都是身具羽翼。《张果星宗》称“又有辅翼,方为真龙”。 98 | 99 | 《后汉书·律历志下》记载:日周于天,一寒一暑,四时备成,万物毕改,摄提迁次,青龙移辰,谓之岁。 100 | 101 | 在中国[二十八宿](https://zh.wikipedia.org/wiki/%E4%BA%8C%E5%8D%81%E5%85%AB%E5%AE%BF)中,青龙是东方七宿(角、亢、氐、房、心、尾、箕)的总称。 在早期星宿信仰中,祂是最尊贵的天神。 但被道教信仰吸纳入其神系后,神格大跌,道教将其称为“孟章”,在不同的道经中有“帝君”、“圣将”、“神将”和“捕鬼将”等称呼,与白虎监兵神君一起,是道教的护卫天神。 102 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Reporting a Vulnerability 2 | 3 | To report a vulnerability, please open a private vulnerability report at . 4 | 5 | While the discovery of new vulnerabilities is rare, we also recommend always using the latest versions of Qinglong to ensure your application remains as secure as possible. 6 | -------------------------------------------------------------------------------- /back/api/config.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response, NextFunction } from 'express'; 2 | import { Container } from 'typedi'; 3 | import { Logger } from 'winston'; 4 | import config from '../config'; 5 | import * as fs from 'fs/promises'; 6 | import { celebrate, Joi } from 'celebrate'; 7 | import { join } from 'path'; 8 | import { SAMPLE_FILES } from '../config/const'; 9 | import ConfigService from '../services/config'; 10 | import { writeFileWithLock } from '../shared/utils'; 11 | const route = Router(); 12 | 13 | export default (app: Router) => { 14 | app.use('/configs', route); 15 | 16 | route.get( 17 | '/sample', 18 | async (req: Request, res: Response, next: NextFunction) => { 19 | try { 20 | res.send({ 21 | code: 200, 22 | data: SAMPLE_FILES, 23 | }); 24 | } catch (e) { 25 | return next(e); 26 | } 27 | }, 28 | ); 29 | 30 | route.get( 31 | '/files', 32 | async (req: Request, res: Response, next: NextFunction) => { 33 | const logger: Logger = Container.get('logger'); 34 | try { 35 | const fileList = await fs.readdir(config.configPath, 'utf-8'); 36 | res.send({ 37 | code: 200, 38 | data: fileList 39 | .filter((x) => !config.blackFileList.includes(x)) 40 | .map((x) => { 41 | return { title: x, value: x }; 42 | }), 43 | }); 44 | } catch (e) { 45 | return next(e); 46 | } 47 | }, 48 | ); 49 | 50 | route.get( 51 | '/detail', 52 | async (req: Request, res: Response, next: NextFunction) => { 53 | try { 54 | const configService = Container.get(ConfigService); 55 | await configService.getFile(req.query.path as string, res); 56 | } catch (e) { 57 | return next(e); 58 | } 59 | }, 60 | ); 61 | 62 | route.post( 63 | '/save', 64 | celebrate({ 65 | body: Joi.object({ 66 | name: Joi.string().required(), 67 | content: Joi.string().allow('').optional(), 68 | }), 69 | }), 70 | async (req: Request, res: Response, next: NextFunction) => { 71 | const logger: Logger = Container.get('logger'); 72 | try { 73 | const { name, content } = req.body; 74 | if (config.blackFileList.includes(name)) { 75 | res.send({ code: 403, message: '文件无法访问' }); 76 | } 77 | let path = join(config.configPath, name); 78 | if (name.startsWith('data/scripts/')) { 79 | path = join(config.rootPath, name); 80 | } 81 | await writeFileWithLock(path, content); 82 | res.send({ code: 200, message: '保存成功' }); 83 | } catch (e) { 84 | return next(e); 85 | } 86 | }, 87 | ); 88 | 89 | route.get( 90 | '/:file', 91 | async (req: Request, res: Response, next: NextFunction) => { 92 | try { 93 | const configService = Container.get(ConfigService); 94 | await configService.getFile(req.params.file, res); 95 | } catch (e) { 96 | return next(e); 97 | } 98 | }, 99 | ); 100 | }; 101 | -------------------------------------------------------------------------------- /back/api/health.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import Logger from '../loaders/logger'; 3 | import { HealthService } from '../services/health'; 4 | import Container from 'typedi'; 5 | const route = Router(); 6 | 7 | export default (app: Router) => { 8 | app.use('/', route); 9 | 10 | route.get('/health', async (req, res) => { 11 | try { 12 | const healthService = Container.get(HealthService); 13 | const health = await healthService.check(); 14 | res.status(200).send({ 15 | code: 200, 16 | data: health, 17 | }); 18 | } catch (err: any) { 19 | Logger.error('Health check failed:', err); 20 | res.status(500).send({ 21 | code: 500, 22 | message: 'Health check failed', 23 | error: err.message, 24 | }); 25 | } 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /back/api/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import user from './user'; 3 | import env from './env'; 4 | import config from './config'; 5 | import log from './log'; 6 | import cron from './cron'; 7 | import script from './script'; 8 | import open from './open'; 9 | import dependence from './dependence'; 10 | import system from './system'; 11 | import subscription from './subscription'; 12 | import update from './update'; 13 | import health from './health'; 14 | 15 | export default () => { 16 | const app = Router(); 17 | user(app); 18 | env(app); 19 | config(app); 20 | log(app); 21 | cron(app); 22 | script(app); 23 | open(app); 24 | dependence(app); 25 | system(app); 26 | subscription(app); 27 | update(app); 28 | health(app); 29 | 30 | return app; 31 | }; 32 | -------------------------------------------------------------------------------- /back/api/open.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response, NextFunction } from 'express'; 2 | import { Container } from 'typedi'; 3 | import OpenService from '../services/open'; 4 | import { Logger } from 'winston'; 5 | import { celebrate, Joi } from 'celebrate'; 6 | const route = Router(); 7 | 8 | export default (app: Router) => { 9 | app.use('/', route); 10 | route.get( 11 | '/apps', 12 | async (req: Request, res: Response, next: NextFunction) => { 13 | const logger: Logger = Container.get('logger'); 14 | try { 15 | const openService = Container.get(OpenService); 16 | const data = await openService.list(); 17 | return res.send({ code: 200, data }); 18 | } catch (e) { 19 | return next(e); 20 | } 21 | }, 22 | ); 23 | 24 | route.post( 25 | '/apps', 26 | celebrate({ 27 | body: Joi.object({ 28 | name: Joi.string().optional().allow('').disallow('system'), 29 | scopes: Joi.array().items(Joi.string().required()), 30 | }), 31 | }), 32 | async (req: Request, res: Response, next: NextFunction) => { 33 | const logger: Logger = Container.get('logger'); 34 | try { 35 | const openService = Container.get(OpenService); 36 | const data = await openService.create(req.body); 37 | return res.send({ code: 200, data }); 38 | } catch (e) { 39 | return next(e); 40 | } 41 | }, 42 | ); 43 | 44 | route.put( 45 | '/apps', 46 | celebrate({ 47 | body: Joi.object({ 48 | name: Joi.string().optional().allow(''), 49 | scopes: Joi.array().items(Joi.string()), 50 | id: Joi.number().required(), 51 | }), 52 | }), 53 | async (req: Request, res: Response, next: NextFunction) => { 54 | const logger: Logger = Container.get('logger'); 55 | try { 56 | const openService = Container.get(OpenService); 57 | const data = await openService.update(req.body); 58 | return res.send({ code: 200, data }); 59 | } catch (e) { 60 | return next(e); 61 | } 62 | }, 63 | ); 64 | 65 | route.delete( 66 | '/apps', 67 | celebrate({ 68 | body: Joi.array().items(Joi.number().required()), 69 | }), 70 | async (req: Request, res: Response, next: NextFunction) => { 71 | const logger: Logger = Container.get('logger'); 72 | try { 73 | const openService = Container.get(OpenService); 74 | const data = await openService.remove(req.body); 75 | return res.send({ code: 200, data }); 76 | } catch (e) { 77 | return next(e); 78 | } 79 | }, 80 | ); 81 | 82 | route.put( 83 | '/apps/:id/reset-secret', 84 | celebrate({ 85 | params: Joi.object({ 86 | id: Joi.number().required(), 87 | }), 88 | }), 89 | async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { 90 | const logger: Logger = Container.get('logger'); 91 | try { 92 | const openService = Container.get(OpenService); 93 | const data = await openService.resetSecret(req.params.id); 94 | return res.send({ code: 200, data }); 95 | } catch (e) { 96 | return next(e); 97 | } 98 | }, 99 | ); 100 | 101 | route.get( 102 | '/auth/token', 103 | celebrate({ 104 | query: { 105 | client_id: Joi.string().required(), 106 | client_secret: Joi.string().required(), 107 | }, 108 | }), 109 | async (req: Request, res: Response, next: NextFunction) => { 110 | const logger: Logger = Container.get('logger'); 111 | try { 112 | const openService = Container.get(OpenService); 113 | const result = await openService.authToken(req.query as any); 114 | return res.send(result); 115 | } catch (e) { 116 | return next(e); 117 | } 118 | }, 119 | ); 120 | }; 121 | -------------------------------------------------------------------------------- /back/api/update.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response, Router } from 'express'; 2 | import Container from 'typedi'; 3 | import Logger from '../loaders/logger'; 4 | import SystemService from '../services/system'; 5 | const route = Router(); 6 | 7 | export default (app: Router) => { 8 | app.use('/update', route); 9 | 10 | route.put( 11 | '/reload', 12 | async (req: Request, res: Response, next: NextFunction) => { 13 | try { 14 | const systemService = Container.get(SystemService); 15 | const result = await systemService.reloadSystem(); 16 | res.send(result); 17 | } catch (e) { 18 | Logger.error('🔥 error: %o', e); 19 | return next(e); 20 | } 21 | }, 22 | ); 23 | 24 | route.put( 25 | '/system', 26 | async (req: Request, res: Response, next: NextFunction) => { 27 | try { 28 | const systemService = Container.get(SystemService); 29 | const result = await systemService.reloadSystem('system'); 30 | res.send(result); 31 | } catch (e) { 32 | Logger.error('🔥 error: %o', e); 33 | return next(e); 34 | } 35 | }, 36 | ); 37 | 38 | route.put( 39 | '/data', 40 | async (req: Request, res: Response, next: NextFunction) => { 41 | try { 42 | const systemService = Container.get(SystemService); 43 | const result = await systemService.reloadSystem('data'); 44 | res.send(result); 45 | } catch (e) { 46 | Logger.error('🔥 error: %o', e); 47 | return next(e); 48 | } 49 | }, 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /back/app.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import compression from 'compression'; 3 | import cors from 'cors'; 4 | import express from 'express'; 5 | import helmet from 'helmet'; 6 | import { Container } from 'typedi'; 7 | import config from './config'; 8 | import Logger from './loaders/logger'; 9 | import { monitoringMiddleware } from './middlewares/monitoring'; 10 | import { type GrpcServerService } from './services/grpc'; 11 | import { type HttpServerService } from './services/http'; 12 | 13 | class Application { 14 | private app: express.Application; 15 | private httpServerService?: HttpServerService; 16 | private grpcServerService?: GrpcServerService; 17 | private isShuttingDown = false; 18 | 19 | constructor() { 20 | this.app = express(); 21 | } 22 | 23 | async start() { 24 | try { 25 | await this.initializeDatabase(); 26 | await this.initServer(); 27 | this.setupMiddlewares(); 28 | await this.initializeServices(); 29 | this.setupGracefulShutdown(); 30 | 31 | process.send?.('ready'); 32 | } catch (error) { 33 | Logger.error('Failed to start application:', error); 34 | process.exit(1); 35 | } 36 | } 37 | 38 | async initServer() { 39 | const { HttpServerService } = await import('./services/http'); 40 | const { GrpcServerService } = await import('./services/grpc'); 41 | this.httpServerService = Container.get(HttpServerService); 42 | this.grpcServerService = Container.get(GrpcServerService); 43 | } 44 | 45 | private async initializeDatabase() { 46 | await require('./loaders/db').default(); 47 | } 48 | 49 | private setupMiddlewares() { 50 | this.app.use(helmet()); 51 | this.app.use(cors(config.cors)); 52 | this.app.use(compression()); 53 | this.app.use(monitoringMiddleware); 54 | } 55 | 56 | private async initializeServices() { 57 | await this.grpcServerService?.initialize(); 58 | 59 | await require('./loaders/app').default({ app: this.app }); 60 | 61 | const server = await this.httpServerService?.initialize( 62 | this.app, 63 | config.port, 64 | ); 65 | 66 | await require('./loaders/server').default({ server }); 67 | } 68 | 69 | private setupGracefulShutdown() { 70 | const shutdown = async () => { 71 | if (this.isShuttingDown) return; 72 | this.isShuttingDown = true; 73 | 74 | Logger.info('Shutting down services...'); 75 | try { 76 | await Promise.all([ 77 | this.grpcServerService?.shutdown(), 78 | this.httpServerService?.shutdown(), 79 | ]); 80 | process.exit(0); 81 | } catch (error) { 82 | Logger.error('Error during shutdown:', error); 83 | process.exit(1); 84 | } 85 | }; 86 | 87 | process.on('SIGTERM', shutdown); 88 | process.on('SIGINT', shutdown); 89 | } 90 | } 91 | 92 | const app = new Application(); 93 | app.start().catch((error) => { 94 | Logger.error('Application failed to start:', error); 95 | process.exit(1); 96 | }); 97 | -------------------------------------------------------------------------------- /back/config/const.ts: -------------------------------------------------------------------------------- 1 | export const LOG_END_SYMBOL = '     '; 2 | 3 | export const TASK_COMMAND = 'task'; 4 | export const QL_COMMAND = 'ql'; 5 | 6 | export const TASK_PREFIX = `${TASK_COMMAND} `; 7 | export const QL_PREFIX = `${QL_COMMAND} `; 8 | 9 | export const SAMPLE_FILES = [ 10 | { 11 | title: 'config.sample.sh', 12 | value: 'sample/config.sample.sh', 13 | target: 'config.sh', 14 | }, 15 | { 16 | title: 'notify.js', 17 | value: 'sample/notify.js', 18 | target: 'data/scripts/sendNotify.js', 19 | }, 20 | { 21 | title: 'notify.py', 22 | value: 'sample/notify.py', 23 | target: 'data/scripts/notify.py', 24 | }, 25 | ]; 26 | 27 | export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME; 28 | -------------------------------------------------------------------------------- /back/config/http.ts: -------------------------------------------------------------------------------- 1 | import { request as undiciRequest, Dispatcher } from 'undici'; 2 | 3 | type RequestBaseOptions = { 4 | dispatcher?: Dispatcher; 5 | json?: Record; 6 | form?: string; 7 | headers?: Record; 8 | } & Omit, 'origin' | 'path' | 'method'>; 9 | 10 | type RequestOptionsWithOptions = RequestBaseOptions & 11 | Partial>; 12 | 13 | type ResponseTypeMap = { 14 | json: Record; 15 | text: string; 16 | }; 17 | 18 | type ResponseTypeKey = keyof ResponseTypeMap; 19 | 20 | async function request( 21 | url: string, 22 | options?: RequestOptionsWithOptions, 23 | ): Promise> { 24 | const { json, form, body, headers = {}, ...rest } = options || {}; 25 | const finalHeaders = { ...headers } as Record; 26 | let finalBody = body; 27 | 28 | if (json) { 29 | finalHeaders['content-type'] = 'application/json'; 30 | finalBody = JSON.stringify(json); 31 | } else if (form) { 32 | finalBody = form; 33 | delete finalHeaders['content-type']; 34 | } 35 | 36 | const res = await undiciRequest(url, { 37 | method: 'POST', 38 | headers: finalHeaders, 39 | body: finalBody, 40 | ...rest, 41 | }); 42 | 43 | return res; 44 | } 45 | 46 | async function post( 47 | url: string, 48 | options?: RequestBaseOptions & { responseType?: T }, 49 | ): Promise { 50 | const resp = await request(url, { ...options, method: 'POST' }); 51 | 52 | const rawText = await resp.body.text(); 53 | 54 | if (options?.responseType === 'text') { 55 | return rawText as ResponseTypeMap[T]; 56 | } 57 | 58 | try { 59 | return JSON.parse(rawText) as ResponseTypeMap[T]; 60 | } catch { 61 | return rawText as ResponseTypeMap[T]; 62 | } 63 | } 64 | 65 | export const httpClient = { 66 | post, 67 | request, 68 | }; 69 | -------------------------------------------------------------------------------- /back/config/serverEnv.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import pick from 'lodash/pick'; 3 | 4 | let pickedEnv: Record; 5 | 6 | function getPickedEnv() { 7 | if (pickedEnv) return pickedEnv; 8 | const picked = pick(process.env, ['QlBaseUrl', 'DeployEnv', 'QL_DIR']); 9 | if (picked.QlBaseUrl) { 10 | if (!picked.QlBaseUrl.startsWith('/')) { 11 | picked.QlBaseUrl = `/${picked.QlBaseUrl}`; 12 | } 13 | if (!picked.QlBaseUrl.endsWith('/')) { 14 | picked.QlBaseUrl = `${picked.QlBaseUrl}/`; 15 | } 16 | } 17 | pickedEnv = picked as Record; 18 | return picked; 19 | } 20 | 21 | export function serveEnv(_req: Request, res: Response) { 22 | res.type('.js'); 23 | res.send( 24 | Object.entries(getPickedEnv()) 25 | .map(([k, v]) => `window.__ENV__${k}=${JSON.stringify(v)};`) 26 | .join('\n'), 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /back/config/share.ts: -------------------------------------------------------------------------------- 1 | export function createRandomString(min: number, max: number): string { 2 | const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; 3 | const english = [ 4 | 'a', 5 | 'b', 6 | 'c', 7 | 'd', 8 | 'e', 9 | 'f', 10 | 'g', 11 | 'h', 12 | 'i', 13 | 'j', 14 | 'k', 15 | 'l', 16 | 'm', 17 | 'n', 18 | 'o', 19 | 'p', 20 | 'q', 21 | 'r', 22 | 's', 23 | 't', 24 | 'u', 25 | 'v', 26 | 'w', 27 | 'x', 28 | 'y', 29 | 'z', 30 | ]; 31 | const ENGLISH = [ 32 | 'A', 33 | 'B', 34 | 'C', 35 | 'D', 36 | 'E', 37 | 'F', 38 | 'G', 39 | 'H', 40 | 'I', 41 | 'J', 42 | 'K', 43 | 'L', 44 | 'M', 45 | 'N', 46 | 'O', 47 | 'P', 48 | 'Q', 49 | 'R', 50 | 'S', 51 | 'T', 52 | 'U', 53 | 'V', 54 | 'W', 55 | 'X', 56 | 'Y', 57 | 'Z', 58 | ]; 59 | const special = ['-', '_']; 60 | const config = num.concat(english).concat(ENGLISH).concat(special); 61 | 62 | const arr = []; 63 | arr.push(getOne(num)); 64 | arr.push(getOne(english)); 65 | arr.push(getOne(ENGLISH)); 66 | arr.push(getOne(special)); 67 | 68 | const len = min + Math.floor(Math.random() * (max - min + 1)); 69 | 70 | for (let i = 4; i < len; i++) { 71 | arr.push(config[Math.floor(Math.random() * config.length)]); 72 | } 73 | 74 | const newArr = []; 75 | for (let j = 0; j < len; j++) { 76 | newArr.push(arr.splice(Math.random() * arr.length, 1)[0]); 77 | } 78 | 79 | function getOne(arr: any[]) { 80 | return arr[Math.floor(Math.random() * arr.length)]; 81 | } 82 | 83 | return newArr.join(''); 84 | } -------------------------------------------------------------------------------- /back/config/subscription.ts: -------------------------------------------------------------------------------- 1 | import { Subscription } from '../data/subscription'; 2 | import isNil from 'lodash/isNil'; 3 | 4 | export function formatUrl(doc: Subscription) { 5 | let url = doc.url; 6 | let host = ''; 7 | if (doc.type === 'private-repo') { 8 | if (doc.pull_type === 'ssh-key') { 9 | host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1'); 10 | url = doc.url!.replace(host, doc.alias); 11 | } else { 12 | host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1'); 13 | const { username, password } = doc.pull_option as any; 14 | url = doc.url!.replace(host, `${username}:${password}@${host}`); 15 | } 16 | } 17 | return { url, host }; 18 | } 19 | 20 | export function formatCommand(doc: Subscription, url?: string) { 21 | let command = `SUB_ID=${doc.id} ql `; 22 | let _url = url || formatUrl(doc).url; 23 | const { 24 | type, 25 | whitelist, 26 | blacklist, 27 | dependences, 28 | branch, 29 | extensions, 30 | proxy, 31 | autoAddCron, 32 | autoDelCron, 33 | } = doc; 34 | if (type === 'file') { 35 | command += `raw "${_url}" "${proxy || ''}" "${ 36 | isNil(autoAddCron) ? true : Boolean(autoAddCron) 37 | }" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`; 38 | } else { 39 | command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${ 40 | dependences || '' 41 | }" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${ 42 | isNil(autoAddCron) ? true : Boolean(autoAddCron) 43 | }" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`; 44 | } 45 | return command; 46 | } 47 | -------------------------------------------------------------------------------- /back/data/cron.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from '.'; 2 | import { DataTypes, Model, ModelDefined } from 'sequelize'; 3 | 4 | export class Crontab { 5 | name?: string; 6 | command: string; 7 | schedule?: string; 8 | timestamp?: string; 9 | saved?: boolean; 10 | id?: number; 11 | status?: CrontabStatus; 12 | isSystem?: 1 | 0; 13 | pid?: number; 14 | isDisabled?: 1 | 0; 15 | log_path?: string; 16 | isPinned?: 1 | 0; 17 | labels?: string[]; 18 | last_running_time?: number; 19 | last_execution_time?: number; 20 | sub_id?: number; 21 | extra_schedules?: Array<{ schedule: string }>; 22 | task_before?: string; 23 | task_after?: string; 24 | 25 | constructor(options: Crontab) { 26 | this.name = options.name; 27 | this.command = options.command.trim(); 28 | this.schedule = options.schedule; 29 | this.saved = options.saved; 30 | this.id = options.id; 31 | this.status = 32 | typeof options.status === 'number' && CrontabStatus[options.status] 33 | ? options.status 34 | : CrontabStatus.idle; 35 | this.timestamp = new Date().toString(); 36 | this.isSystem = options.isSystem || 0; 37 | this.pid = options.pid; 38 | this.isDisabled = options.isDisabled || 0; 39 | this.log_path = options.log_path || ''; 40 | this.isPinned = options.isPinned || 0; 41 | this.labels = options.labels || []; 42 | this.last_running_time = options.last_running_time || 0; 43 | this.last_execution_time = options.last_execution_time || 0; 44 | this.sub_id = options.sub_id; 45 | this.extra_schedules = options.extra_schedules; 46 | this.task_before = options.task_before; 47 | this.task_after = options.task_after; 48 | } 49 | } 50 | 51 | export enum CrontabStatus { 52 | 'running' = 0, 53 | 'queued' = 0.5, 54 | 'idle' = 1, 55 | 'disabled', 56 | } 57 | 58 | export interface CronInstance extends Model, Crontab { } 59 | export const CrontabModel = sequelize.define('Crontab', { 60 | name: { 61 | unique: 'compositeIndex', 62 | type: DataTypes.STRING, 63 | }, 64 | command: { 65 | unique: 'compositeIndex', 66 | type: DataTypes.STRING, 67 | }, 68 | schedule: { 69 | unique: 'compositeIndex', 70 | type: DataTypes.STRING, 71 | }, 72 | timestamp: DataTypes.STRING, 73 | saved: DataTypes.BOOLEAN, 74 | status: DataTypes.NUMBER, 75 | isSystem: DataTypes.NUMBER, 76 | pid: DataTypes.NUMBER, 77 | isDisabled: DataTypes.NUMBER, 78 | isPinned: DataTypes.NUMBER, 79 | log_path: DataTypes.STRING, 80 | labels: DataTypes.JSON, 81 | last_running_time: DataTypes.NUMBER, 82 | last_execution_time: DataTypes.NUMBER, 83 | sub_id: { type: DataTypes.NUMBER, allowNull: true }, 84 | extra_schedules: DataTypes.JSON, 85 | task_before: DataTypes.STRING, 86 | task_after: DataTypes.STRING, 87 | }); 88 | -------------------------------------------------------------------------------- /back/data/cronView.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from '.'; 2 | import { DataTypes, Model } from 'sequelize'; 3 | 4 | export enum CronViewType { 5 | '系统' = 1, 6 | '个人', 7 | } 8 | 9 | interface SortType { 10 | type: 'ASC' | 'DESC'; 11 | value: string; 12 | } 13 | 14 | interface FilterType { 15 | property: string; 16 | operation: string; 17 | value: string; 18 | } 19 | 20 | export class CrontabView { 21 | name?: string; 22 | id?: number; 23 | position?: number; 24 | isDisabled?: 1 | 0; 25 | filters?: FilterType[]; 26 | sorts?: SortType[]; 27 | filterRelation?: 'and' | 'or'; 28 | type?: CronViewType; 29 | 30 | constructor(options: CrontabView) { 31 | this.name = options.name; 32 | this.id = options.id; 33 | this.position = options.position; 34 | this.isDisabled = options.isDisabled || 0; 35 | this.filters = options.filters; 36 | this.sorts = options.sorts; 37 | this.filterRelation = options.filterRelation; 38 | this.type = options.type || CronViewType.个人; 39 | } 40 | } 41 | 42 | export interface CronViewInstance 43 | extends Model, 44 | CrontabView {} 45 | export const CrontabViewModel = sequelize.define( 46 | 'CrontabView', 47 | { 48 | name: { 49 | unique: 'name', 50 | type: DataTypes.STRING, 51 | }, 52 | position: DataTypes.NUMBER, 53 | isDisabled: DataTypes.NUMBER, 54 | filters: DataTypes.JSON, 55 | sorts: DataTypes.JSON, 56 | filterRelation: { 57 | type: DataTypes.STRING, 58 | allowNull: true, 59 | }, 60 | type: DataTypes.NUMBER, 61 | }, 62 | ); 63 | -------------------------------------------------------------------------------- /back/data/dependence.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from '.'; 2 | import { DataTypes, Model, ModelDefined } from 'sequelize'; 3 | 4 | export class Dependence { 5 | timestamp?: string; 6 | id?: number; 7 | status: DependenceStatus; 8 | type: DependenceTypes; 9 | name: string; 10 | log?: string[]; 11 | remark?: string; 12 | 13 | constructor(options: Dependence) { 14 | this.id = options.id; 15 | this.status = 16 | typeof options.status === 'number' && DependenceStatus[options.status] 17 | ? options.status 18 | : DependenceStatus.queued; 19 | this.type = options.type || DependenceTypes.nodejs; 20 | this.timestamp = new Date().toString(); 21 | this.name = options.name.trim(); 22 | this.log = options.log || []; 23 | this.remark = options.remark || ''; 24 | } 25 | } 26 | 27 | export enum DependenceStatus { 28 | 'installing', 29 | 'installed', 30 | 'installFailed', 31 | 'removing', 32 | 'removed', 33 | 'removeFailed', 34 | 'queued', 35 | 'cancelled', 36 | } 37 | 38 | export enum DependenceTypes { 39 | 'nodejs', 40 | 'python3', 41 | 'linux', 42 | } 43 | 44 | export enum versionDependenceCommandTypes { 45 | '@', 46 | '==', 47 | '=', 48 | } 49 | 50 | export interface DependenceInstance 51 | extends Model, 52 | Dependence {} 53 | export const DependenceModel = sequelize.define( 54 | 'Dependence', 55 | { 56 | name: DataTypes.STRING, 57 | type: DataTypes.NUMBER, 58 | timestamp: DataTypes.STRING, 59 | status: DataTypes.NUMBER, 60 | log: DataTypes.JSON, 61 | remark: DataTypes.STRING, 62 | }, 63 | ); 64 | -------------------------------------------------------------------------------- /back/data/env.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from '.'; 2 | import { DataTypes, Model, ModelDefined } from 'sequelize'; 3 | 4 | export class Env { 5 | value?: string; 6 | timestamp?: string; 7 | id?: number; 8 | status?: EnvStatus; 9 | position?: number; 10 | name?: string; 11 | remarks?: string; 12 | 13 | constructor(options: Env) { 14 | this.value = options.value; 15 | this.id = options.id; 16 | this.status = 17 | typeof options.status === 'number' && EnvStatus[options.status] 18 | ? options.status 19 | : EnvStatus.normal; 20 | this.timestamp = new Date().toString(); 21 | this.position = options.position; 22 | this.name = options.name; 23 | this.remarks = options.remarks || ''; 24 | } 25 | } 26 | 27 | export enum EnvStatus { 28 | 'normal', 29 | 'disabled', 30 | } 31 | 32 | export const maxPosition = 9000000000000000; 33 | export const initPosition = 4500000000000000; 34 | export const stepPosition = 10000000000; 35 | export const minPosition = 100; 36 | 37 | export interface EnvInstance extends Model, Env {} 38 | export const EnvModel = sequelize.define('Env', { 39 | value: { type: DataTypes.STRING, unique: 'compositeIndex' }, 40 | timestamp: DataTypes.STRING, 41 | status: DataTypes.NUMBER, 42 | position: DataTypes.NUMBER, 43 | name: { type: DataTypes.STRING, unique: 'compositeIndex' }, 44 | remarks: DataTypes.STRING, 45 | }); 46 | -------------------------------------------------------------------------------- /back/data/index.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize, Transaction } from 'sequelize'; 2 | import config from '../config/index'; 3 | import { join } from 'path'; 4 | 5 | export const sequelize = new Sequelize({ 6 | dialect: 'sqlite', 7 | storage: join(config.dbPath, 'database.sqlite'), 8 | logging: false, 9 | retry: { 10 | max: 10, 11 | match: ['SQLITE_BUSY: database is locked'], 12 | }, 13 | pool: { 14 | max: 5, 15 | min: 2, 16 | idle: 30000, 17 | acquire: 30000, 18 | evict: 10000, 19 | }, 20 | transactionType: Transaction.TYPES.IMMEDIATE, 21 | }); 22 | 23 | export type ResponseType = { code: number; data?: T; message?: string }; 24 | -------------------------------------------------------------------------------- /back/data/open.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from '.'; 2 | import { DataTypes, Model, ModelDefined } from 'sequelize'; 3 | 4 | export class App { 5 | name: string; 6 | scopes: AppScope[]; 7 | client_id: string; 8 | client_secret: string; 9 | tokens?: AppToken[]; 10 | id?: number; 11 | 12 | constructor(options: App) { 13 | this.name = options.name; 14 | this.scopes = options.scopes; 15 | this.client_id = options.client_id; 16 | this.client_secret = options.client_secret; 17 | this.id = options.id; 18 | } 19 | } 20 | 21 | export interface AppToken { 22 | value: string; 23 | type?: 'Bearer'; 24 | expiration: number; 25 | } 26 | 27 | export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system'; 28 | 29 | export interface AppInstance extends Model, App {} 30 | export const AppModel = sequelize.define('App', { 31 | name: { type: DataTypes.STRING, unique: 'name' }, 32 | scopes: DataTypes.JSON, 33 | client_id: DataTypes.STRING, 34 | client_secret: DataTypes.STRING, 35 | tokens: DataTypes.JSON, 36 | }); 37 | -------------------------------------------------------------------------------- /back/data/sock.ts: -------------------------------------------------------------------------------- 1 | export class SockMessage { 2 | message?: string; 3 | type?: SockMessageType; 4 | references?: number[]; 5 | 6 | constructor(options: SockMessage) { 7 | this.type = options.type; 8 | this.message = options.message; 9 | this.references = options.references; 10 | } 11 | } 12 | 13 | export type SockMessageType = 14 | | 'ping' 15 | | 'installDependence' 16 | | 'uninstallDependence' 17 | | 'updateSystemVersion' 18 | | 'manuallyRunScript' 19 | | 'runSubscriptionEnd' 20 | | 'reloadSystem' 21 | | 'updateNodeMirror' 22 | | 'updateLinuxMirror'; 23 | -------------------------------------------------------------------------------- /back/data/subscription.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from '.'; 2 | import { DataTypes, Model, ModelDefined } from 'sequelize'; 3 | import { SimpleIntervalSchedule } from 'toad-scheduler'; 4 | 5 | type SimpleIntervalScheduleUnit = keyof SimpleIntervalSchedule; 6 | export class Subscription { 7 | id?: number; 8 | name?: string; 9 | type?: 'public-repo' | 'private-repo' | 'file'; 10 | schedule_type?: 'crontab' | 'interval'; 11 | schedule?: string; 12 | interval_schedule?: { type: SimpleIntervalScheduleUnit; value: number }; 13 | url?: string; 14 | whitelist?: string; 15 | blacklist?: string; 16 | dependences?: string; 17 | branch?: string; 18 | status?: SubscriptionStatus; 19 | pull_type?: 'ssh-key' | 'user-pwd'; 20 | pull_option?: 21 | | { private_key: string } 22 | | { username: string; password: string }; 23 | pid?: number; 24 | is_disabled?: 1 | 0; 25 | log_path?: string; 26 | alias: string; 27 | command?: string; 28 | extensions?: string; 29 | sub_before?: string; 30 | sub_after?: string; 31 | proxy?: string; 32 | autoAddCron?: 1 | 0; 33 | autoDelCron?: 1 | 0; 34 | 35 | constructor(options: Subscription) { 36 | this.id = options.id; 37 | this.name = options.name || options.alias; 38 | this.type = options.type; 39 | this.schedule = options.schedule; 40 | this.status = this.status = 41 | typeof options.status === 'number' && SubscriptionStatus[options.status] 42 | ? options.status 43 | : SubscriptionStatus.idle; 44 | this.url = options.url; 45 | this.whitelist = options.whitelist; 46 | this.blacklist = options.blacklist; 47 | this.dependences = options.dependences; 48 | this.branch = options.branch; 49 | this.pull_type = options.pull_type; 50 | this.pull_option = options.pull_option; 51 | this.pid = options.pid; 52 | this.is_disabled = options.is_disabled; 53 | this.log_path = options.log_path; 54 | this.schedule_type = options.schedule_type; 55 | this.alias = options.alias; 56 | this.interval_schedule = options.interval_schedule; 57 | this.extensions = options.extensions; 58 | this.sub_before = options.sub_before; 59 | this.sub_after = options.sub_after; 60 | this.proxy = options.proxy; 61 | this.autoAddCron = options.autoAddCron ? 1 : 0; 62 | this.autoDelCron = options.autoDelCron ? 1 : 0; 63 | } 64 | } 65 | 66 | export enum SubscriptionStatus { 67 | 'running', 68 | 'idle', 69 | 'disabled', 70 | 'queued', 71 | } 72 | 73 | export interface SubscriptionInstance 74 | extends Model, 75 | Subscription {} 76 | export const SubscriptionModel = sequelize.define( 77 | 'Subscription', 78 | { 79 | name: { 80 | unique: 'compositeIndex', 81 | type: DataTypes.STRING, 82 | }, 83 | url: { 84 | unique: 'compositeIndex', 85 | type: DataTypes.STRING, 86 | }, 87 | schedule: { 88 | unique: 'compositeIndex', 89 | type: DataTypes.STRING, 90 | }, 91 | interval_schedule: { 92 | unique: 'compositeIndex', 93 | type: DataTypes.JSON, 94 | }, 95 | type: DataTypes.STRING, 96 | whitelist: DataTypes.STRING, 97 | blacklist: DataTypes.STRING, 98 | status: DataTypes.NUMBER, 99 | dependences: DataTypes.STRING, 100 | extensions: DataTypes.STRING, 101 | sub_before: DataTypes.STRING, 102 | sub_after: DataTypes.STRING, 103 | branch: DataTypes.STRING, 104 | pull_type: DataTypes.STRING, 105 | pull_option: DataTypes.JSON, 106 | pid: DataTypes.NUMBER, 107 | is_disabled: DataTypes.NUMBER, 108 | log_path: DataTypes.STRING, 109 | schedule_type: DataTypes.STRING, 110 | alias: { type: DataTypes.STRING, unique: 'alias' }, 111 | proxy: { type: DataTypes.STRING, allowNull: true }, 112 | autoAddCron: { type: DataTypes.NUMBER, allowNull: true }, 113 | autoDelCron: { type: DataTypes.NUMBER, allowNull: true }, 114 | }, 115 | ); 116 | -------------------------------------------------------------------------------- /back/data/system.ts: -------------------------------------------------------------------------------- 1 | import { sequelize } from '.'; 2 | import { DataTypes, Model, ModelDefined } from 'sequelize'; 3 | import { NotificationInfo } from './notify'; 4 | 5 | export class SystemInfo { 6 | ip?: string; 7 | type: AuthDataType; 8 | info?: SystemModelInfo; 9 | id?: number; 10 | 11 | constructor(options: SystemInfo) { 12 | this.ip = options.ip; 13 | this.info = options.info; 14 | this.type = options.type; 15 | this.id = options.id; 16 | } 17 | } 18 | 19 | export enum LoginStatus { 20 | 'success', 21 | 'fail', 22 | } 23 | 24 | export enum AuthDataType { 25 | 'loginLog' = 'loginLog', 26 | 'authToken' = 'authToken', 27 | 'notification' = 'notification', 28 | 'removeLogFrequency' = 'removeLogFrequency', 29 | 'systemConfig' = 'systemConfig', 30 | 'authConfig' = 'authConfig', 31 | } 32 | 33 | export interface SystemConfigInfo { 34 | logRemoveFrequency?: number; 35 | cronConcurrency?: number; 36 | dependenceProxy?: string; 37 | nodeMirror?: string; 38 | pythonMirror?: string; 39 | linuxMirror?: string; 40 | timezone?: string; 41 | } 42 | 43 | export interface LoginLogInfo { 44 | timestamp?: number; 45 | address?: string; 46 | ip?: string; 47 | platform?: string; 48 | status?: LoginStatus; 49 | } 50 | 51 | export interface AuthInfo { 52 | username: string; 53 | password: string; 54 | retries: number; 55 | lastlogon: number; 56 | lastip: string; 57 | lastaddr: string; 58 | platform: string; 59 | isTwoFactorChecking: boolean; 60 | token: string; 61 | tokens: Record; 62 | twoFactorActivated: boolean; 63 | twoFactorSecret: string; 64 | avatar: string; 65 | } 66 | 67 | export type SystemModelInfo = SystemConfigInfo & 68 | Partial & 69 | LoginLogInfo & 70 | Partial; 71 | 72 | export interface SystemInstance 73 | extends Model, 74 | SystemInfo {} 75 | export const SystemModel = sequelize.define('Auth', { 76 | ip: DataTypes.STRING, 77 | type: DataTypes.STRING, 78 | info: { 79 | type: DataTypes.JSON, 80 | allowNull: true, 81 | }, 82 | }); 83 | -------------------------------------------------------------------------------- /back/interface/schedule.ts: -------------------------------------------------------------------------------- 1 | export enum ScheduleType { 2 | BOOT = '@boot', 3 | ONCE = '@once', 4 | } 5 | 6 | export type ScheduleValidator = (schedule?: string) => boolean; 7 | export type CronSchedulerPayload = { 8 | name: string; 9 | id: string; 10 | schedule: string; 11 | command: string; 12 | extra_schedules: Array<{ schedule: string }>; 13 | }; 14 | -------------------------------------------------------------------------------- /back/loaders/app.ts: -------------------------------------------------------------------------------- 1 | import expressLoader from './express'; 2 | import depInjectorLoader from './depInjector'; 3 | import Logger from './logger'; 4 | import initData from './initData'; 5 | import { Application } from 'express'; 6 | import linkDeps from './deps'; 7 | import initTask from './initTask'; 8 | import initFile from './initFile'; 9 | 10 | export default async ({ app }: { app: Application }) => { 11 | depInjectorLoader(); 12 | Logger.info('✌️ Dependency loaded'); 13 | 14 | await linkDeps(); 15 | Logger.info('✌️ Link deps loaded'); 16 | 17 | initFile(); 18 | Logger.info('✌️ Init file loaded'); 19 | 20 | await initData(); 21 | Logger.info('✌️ Init data loaded'); 22 | 23 | initTask(); 24 | Logger.info('✌️ Init task loaded'); 25 | 26 | expressLoader({ app }); 27 | Logger.info('✌️ Express loaded'); 28 | }; 29 | -------------------------------------------------------------------------------- /back/loaders/bootAfter.ts: -------------------------------------------------------------------------------- 1 | import Container from 'typedi'; 2 | import CronService from '../services/cron'; 3 | 4 | export default async () => { 5 | const cronService = Container.get(CronService); 6 | 7 | await cronService.bootTask(); 8 | }; 9 | -------------------------------------------------------------------------------- /back/loaders/db.ts: -------------------------------------------------------------------------------- 1 | import Logger from './logger'; 2 | import { EnvModel } from '../data/env'; 3 | import { CrontabModel } from '../data/cron'; 4 | import { DependenceModel } from '../data/dependence'; 5 | import { AppModel } from '../data/open'; 6 | import { SystemModel } from '../data/system'; 7 | import { SubscriptionModel } from '../data/subscription'; 8 | import { CrontabViewModel } from '../data/cronView'; 9 | import { sequelize } from '../data'; 10 | 11 | export default async () => { 12 | try { 13 | await CrontabModel.sync(); 14 | await DependenceModel.sync(); 15 | await AppModel.sync(); 16 | await SystemModel.sync(); 17 | await EnvModel.sync(); 18 | await SubscriptionModel.sync(); 19 | await CrontabViewModel.sync(); 20 | 21 | // 初始化新增字段 22 | try { 23 | await sequelize.query( 24 | 'alter table CrontabViews add column filterRelation VARCHAR(255)', 25 | ); 26 | } catch (error) {} 27 | try { 28 | await sequelize.query( 29 | 'alter table Subscriptions add column proxy VARCHAR(255)', 30 | ); 31 | } catch (error) {} 32 | try { 33 | await sequelize.query('alter table CrontabViews add column type NUMBER'); 34 | } catch (error) {} 35 | try { 36 | await sequelize.query( 37 | 'alter table Subscriptions add column autoAddCron NUMBER', 38 | ); 39 | } catch (error) {} 40 | try { 41 | await sequelize.query( 42 | 'alter table Subscriptions add column autoDelCron NUMBER', 43 | ); 44 | } catch (error) {} 45 | try { 46 | await sequelize.query('alter table Crontabs add column sub_id NUMBER'); 47 | } catch (error) {} 48 | try { 49 | await sequelize.query( 50 | 'alter table Crontabs add column extra_schedules JSON', 51 | ); 52 | } catch (error) {} 53 | try { 54 | await sequelize.query('alter table Crontabs add column task_before TEXT'); 55 | } catch (error) {} 56 | try { 57 | await sequelize.query('alter table Crontabs add column task_after TEXT'); 58 | } catch (error) {} 59 | 60 | Logger.info('✌️ DB loaded'); 61 | } catch (error) { 62 | Logger.error('✌️ DB load failed', error); 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /back/loaders/depInjector.ts: -------------------------------------------------------------------------------- 1 | import { Container } from 'typedi'; 2 | import LoggerInstance from './logger'; 3 | 4 | export default () => { 5 | try { 6 | Container.set('logger', LoggerInstance); 7 | } catch (e) { 8 | LoggerInstance.error('🔥 Error on dependency injector loader: %o', e); 9 | throw e; 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /back/loaders/deps.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs/promises'; 3 | import chokidar from 'chokidar'; 4 | import config from '../config/index'; 5 | import { fileExist, promiseExec, rmPath } from '../config/util'; 6 | 7 | async function linkToNodeModule(src: string, dst?: string) { 8 | const target = path.join(config.rootPath, 'node_modules', dst || src); 9 | const source = path.join(config.rootPath, src); 10 | 11 | try { 12 | const stats = await fs.lstat(target); 13 | if (!stats) { 14 | await fs.symlink(source, target, 'dir'); 15 | } 16 | } catch (error) {} 17 | } 18 | 19 | async function linkCommand() { 20 | const commandPath = await promiseExec('which node'); 21 | const commandDir = path.dirname(commandPath); 22 | const linkShell = [ 23 | { 24 | src: 'update.sh', 25 | dest: 'ql', 26 | tmp: 'ql_tmp', 27 | }, 28 | { 29 | src: 'task.sh', 30 | dest: 'task', 31 | tmp: 'task_tmp', 32 | }, 33 | ]; 34 | 35 | for (const link of linkShell) { 36 | const source = path.join(config.rootPath, 'shell', link.src); 37 | const target = path.join(commandDir, link.dest); 38 | const tmpTarget = path.join(commandDir, link.tmp); 39 | await fs.symlink(source, tmpTarget); 40 | await fs.rename(tmpTarget, target); 41 | } 42 | } 43 | 44 | export default async (src: string = 'deps') => { 45 | await linkCommand(); 46 | await linkToNodeModule(src); 47 | 48 | const source = path.join(config.rootPath, src); 49 | const watcher = chokidar.watch(source, { 50 | ignored: /(^|[\/\\])\../, // ignore dotfiles 51 | persistent: true, 52 | }); 53 | 54 | watcher 55 | .on('add', (path) => linkToNodeModule(src)) 56 | .on('change', (path) => linkToNodeModule(src)); 57 | }; 58 | -------------------------------------------------------------------------------- /back/loaders/initFile.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import path from 'path'; 3 | import os from 'os'; 4 | import Logger from './logger'; 5 | import { fileExist } from '../config/util'; 6 | import { writeFileWithLock } from '../shared/utils'; 7 | 8 | const rootPath = process.env.QL_DIR as string; 9 | let dataPath = path.join(rootPath, 'data/'); 10 | 11 | if (process.env.QL_DATA_DIR) { 12 | dataPath = process.env.QL_DATA_DIR.replace(/\/$/g, ''); 13 | } 14 | 15 | const preloadPath = path.join(rootPath, 'shell/preload/'); 16 | const configPath = path.join(dataPath, 'config/'); 17 | const scriptPath = path.join(dataPath, 'scripts/'); 18 | const logPath = path.join(dataPath, 'log/'); 19 | const uploadPath = path.join(dataPath, 'upload/'); 20 | const bakPath = path.join(dataPath, 'bak/'); 21 | const samplePath = path.join(rootPath, 'sample/'); 22 | const tmpPath = path.join(logPath, '.tmp/'); 23 | const confFile = path.join(configPath, 'config.sh'); 24 | const sampleConfigFile = path.join(samplePath, 'config.sample.sh'); 25 | const sampleTaskShellFile = path.join(samplePath, 'task.sample.sh'); 26 | const sampleNotifyJsFile = path.join(samplePath, 'notify.js'); 27 | const sampleNotifyPyFile = path.join(samplePath, 'notify.py'); 28 | const scriptNotifyJsFile = path.join(scriptPath, 'sendNotify.js'); 29 | const scriptNotifyPyFile = path.join(scriptPath, 'notify.py'); 30 | const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js'); 31 | const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py'); 32 | const TaskBeforeFile = path.join(configPath, 'task_before.sh'); 33 | const TaskBeforeJsFile = path.join(configPath, 'task_before.js'); 34 | const TaskBeforePyFile = path.join(configPath, 'task_before.py'); 35 | const TaskAfterFile = path.join(configPath, 'task_after.sh'); 36 | const homedir = os.homedir(); 37 | const sshPath = path.resolve(homedir, '.ssh'); 38 | const sshdPath = path.join(dataPath, 'ssh.d'); 39 | const systemLogPath = path.join(dataPath, 'syslog'); 40 | 41 | const directories = [ 42 | configPath, 43 | scriptPath, 44 | preloadPath, 45 | logPath, 46 | tmpPath, 47 | uploadPath, 48 | sshPath, 49 | bakPath, 50 | sshdPath, 51 | systemLogPath, 52 | ]; 53 | 54 | const files = [ 55 | { 56 | target: confFile, 57 | source: sampleConfigFile, 58 | checkExistence: true, 59 | }, 60 | { 61 | target: jsNotifyFile, 62 | source: sampleNotifyJsFile, 63 | checkExistence: false, 64 | }, 65 | { 66 | target: pyNotifyFile, 67 | source: sampleNotifyPyFile, 68 | checkExistence: false, 69 | }, 70 | { 71 | target: scriptNotifyJsFile, 72 | source: sampleNotifyJsFile, 73 | checkExistence: true, 74 | }, 75 | { 76 | target: scriptNotifyPyFile, 77 | source: sampleNotifyPyFile, 78 | checkExistence: true, 79 | }, 80 | { 81 | target: TaskBeforeFile, 82 | source: sampleTaskShellFile, 83 | checkExistence: true, 84 | }, 85 | { 86 | target: TaskBeforeJsFile, 87 | content: 88 | '// The JavaScript code that executes before the JavaScript task execution will execute.', 89 | checkExistence: true, 90 | }, 91 | { 92 | target: TaskBeforePyFile, 93 | content: 94 | '# The Python code that executes before the Python task execution will execute.', 95 | checkExistence: true, 96 | }, 97 | { 98 | target: TaskAfterFile, 99 | source: sampleTaskShellFile, 100 | checkExistence: true, 101 | }, 102 | ]; 103 | 104 | export default async () => { 105 | for (const dirPath of directories) { 106 | if (!(await fileExist(dirPath))) { 107 | await fs.mkdir(dirPath); 108 | } 109 | } 110 | 111 | for (const item of files) { 112 | const exists = await fileExist(item.target); 113 | if (!item.checkExistence || !exists) { 114 | if (!item.content && !item.source) { 115 | throw new Error( 116 | `Neither content nor source specified for ${item.target}`, 117 | ); 118 | } 119 | const content = 120 | item.content || 121 | (await fs.readFile(item.source!, { encoding: 'utf-8' })); 122 | await writeFileWithLock(item.target, content); 123 | } 124 | } 125 | 126 | Logger.info('✌️ Init file down'); 127 | }; 128 | -------------------------------------------------------------------------------- /back/loaders/initTask.ts: -------------------------------------------------------------------------------- 1 | import { Container } from 'typedi'; 2 | import SystemService from '../services/system'; 3 | import ScheduleService, { ScheduleTaskType } from '../services/schedule'; 4 | import SubscriptionService from '../services/subscription'; 5 | import config from '../config'; 6 | import { fileExist } from '../config/util'; 7 | import { join } from 'path'; 8 | 9 | export default async () => { 10 | const systemService = Container.get(SystemService); 11 | const scheduleService = Container.get(ScheduleService); 12 | const subscriptionService = Container.get(SubscriptionService); 13 | 14 | // 生成内置token 15 | let tokenCommand = `ts-node-transpile-only ${join( 16 | config.rootPath, 17 | 'back/token.ts', 18 | )}`; 19 | const tokenFile = join(config.rootPath, 'static/build/token.js'); 20 | 21 | if (await fileExist(tokenFile)) { 22 | tokenCommand = `node ${tokenFile}`; 23 | } 24 | const cron = { 25 | id: NaN, 26 | name: '生成token', 27 | command: tokenCommand, 28 | runOrigin: 'system', 29 | } as ScheduleTaskType; 30 | await scheduleService.cancelIntervalTask(cron); 31 | scheduleService.createIntervalTask( 32 | cron, 33 | { 34 | days: 28, 35 | }, 36 | true, 37 | ); 38 | 39 | // 运行删除日志任务 40 | const data = await systemService.getSystemConfig(); 41 | if (data && data.info) { 42 | if (data.info.logRemoveFrequency) { 43 | const rmlogCron = { 44 | id: data.id as number, 45 | name: '删除日志', 46 | command: `ql rmlog ${data.info.logRemoveFrequency}`, 47 | runOrigin: 'system' as const, 48 | }; 49 | await scheduleService.cancelIntervalTask(rmlogCron); 50 | scheduleService.createIntervalTask( 51 | rmlogCron, 52 | { 53 | days: data.info.logRemoveFrequency, 54 | }, 55 | true, 56 | ); 57 | } 58 | 59 | systemService.updateTimezone(data.info); 60 | } 61 | 62 | await subscriptionService.setSshConfig(); 63 | const subs = await subscriptionService.list(); 64 | for (const sub of subs) { 65 | subscriptionService.handleTask(sub.get({ plain: true }), !sub.is_disabled); 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /back/loaders/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import 'winston-daily-rotate-file'; 3 | import config from '../config'; 4 | import path from 'path'; 5 | 6 | const levelMap: Record = { 7 | info: 'ℹ️', // info图标 8 | warn: '⚠️', // 警告图标 9 | error: '❌', // 错误图标 10 | debug: '🐛', // debug调试图标 11 | }; 12 | 13 | const baseFormat = [ 14 | winston.format.splat(), 15 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), 16 | winston.format.align(), 17 | ]; 18 | 19 | const consoleFormat = winston.format.combine( 20 | winston.format.colorize({ level: true }), 21 | ...baseFormat, 22 | winston.format.printf((info) => { 23 | return `[${info.level} ${info.timestamp}]:${info.message}`; 24 | }), 25 | ); 26 | 27 | const plainFormat = winston.format.combine( 28 | winston.format.uncolorize(), 29 | ...baseFormat, 30 | winston.format.printf((info) => { 31 | return `[${levelMap[info.level] || ''}${info.level} ${info.timestamp}]:${ 32 | info.message 33 | }`; 34 | }), 35 | ); 36 | 37 | const consoleTransport = new winston.transports.Console({ 38 | format: consoleFormat, 39 | level: 'debug', 40 | }); 41 | 42 | const fileTransport = new winston.transports.DailyRotateFile({ 43 | filename: path.join(config.systemLogPath, '%DATE%.log'), 44 | datePattern: 'YYYY-MM-DD', 45 | maxSize: '20m', 46 | maxFiles: '7d', 47 | format: plainFormat, 48 | level: config.logs.level || 'info', 49 | }); 50 | 51 | const LoggerInstance = winston.createLogger({ 52 | level: 'debug', 53 | levels: winston.config.npm.levels, 54 | transports: [consoleTransport, fileTransport], 55 | exceptionHandlers: [consoleTransport, fileTransport], 56 | rejectionHandlers: [consoleTransport, fileTransport], 57 | }); 58 | 59 | LoggerInstance.on('error', (error) => { 60 | console.error('Logger error:', error); 61 | }); 62 | 63 | export default LoggerInstance; 64 | -------------------------------------------------------------------------------- /back/loaders/server.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'http'; 2 | import Logger from './logger'; 3 | import Sock from './sock'; 4 | 5 | export default async ({ server }: { server: Server }) => { 6 | await Sock({ server }); 7 | Logger.info('✌️ Sock loaded'); 8 | 9 | process.on('uncaughtException', (error) => { 10 | Logger.error('Uncaught exception:', error); 11 | }); 12 | 13 | process.on('unhandledRejection', (reason, promise) => { 14 | Logger.error('Unhandled rejection:', reason, promise); 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /back/loaders/sock.ts: -------------------------------------------------------------------------------- 1 | import sockJs from 'sockjs'; 2 | import { Server } from 'http'; 3 | import { Container } from 'typedi'; 4 | import SockService from '../services/sock'; 5 | import { getPlatform } from '../config/util'; 6 | import { shareStore } from '../shared/store'; 7 | 8 | export default async ({ server }: { server: Server }) => { 9 | const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} }); 10 | const sockService = Container.get(SockService); 11 | 12 | echo.on('connection', async (conn) => { 13 | if (!conn.headers || !conn.url || !conn.pathname) { 14 | conn.close('404'); 15 | } 16 | 17 | const authInfo = await shareStore.getAuthInfo(); 18 | const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop'; 19 | const headerToken = conn.url.replace(`${conn.pathname}?token=`, ''); 20 | if (authInfo) { 21 | const { token = '', tokens = {} } = authInfo; 22 | if (headerToken === token || tokens[platform] === headerToken) { 23 | sockService.addClient(conn); 24 | 25 | conn.on('data', (message) => { 26 | conn.write(message); 27 | }); 28 | 29 | conn.on('close', function () { 30 | sockService.removeClient(conn); 31 | }); 32 | 33 | return; 34 | } 35 | } 36 | 37 | conn.close('404'); 38 | }); 39 | 40 | echo.installHandlers(server); 41 | }; 42 | -------------------------------------------------------------------------------- /back/middlewares/monitoring.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import Logger from '../loaders/logger'; 3 | import { performance } from 'perf_hooks'; 4 | import { metricsService } from '../services/metrics'; 5 | 6 | interface RequestMetrics { 7 | method: string; 8 | path: string; 9 | duration: number; 10 | statusCode: number; 11 | timestamp: number; 12 | platform?: string; 13 | } 14 | 15 | const requestMetrics: RequestMetrics[] = []; 16 | 17 | export const monitoringMiddleware = ( 18 | req: Request, 19 | res: Response, 20 | next: NextFunction, 21 | ) => { 22 | const start = performance.now(); 23 | const originalEnd = res.end; 24 | 25 | res.end = function (chunk?: any, encoding?: any, cb?: any) { 26 | const duration = performance.now() - start; 27 | const metric: RequestMetrics = { 28 | method: req.method, 29 | path: req.path, 30 | duration, 31 | statusCode: res.statusCode, 32 | timestamp: Date.now(), 33 | platform: req.platform, 34 | }; 35 | 36 | requestMetrics.push(metric); 37 | metricsService.record('http_request', duration, { 38 | method: req.method, 39 | path: req.path, 40 | statusCode: res.statusCode.toString(), 41 | ...(req.platform && { platform: req.platform }), 42 | }); 43 | 44 | if (requestMetrics.length > 1000) { 45 | requestMetrics.shift(); 46 | } 47 | 48 | if (duration > 1000) { 49 | Logger.warn( 50 | `Slow request detected: ${req.method} ${ 51 | req.path 52 | } took ${duration.toFixed(2)}ms`, 53 | ); 54 | } 55 | 56 | return originalEnd.call(this, chunk, encoding, cb); 57 | }; 58 | 59 | next(); 60 | }; 61 | 62 | export const getMetrics = () => { 63 | return { 64 | totalRequests: requestMetrics.length, 65 | averageDuration: 66 | requestMetrics.reduce((acc, curr) => acc + curr.duration, 0) / 67 | requestMetrics.length, 68 | requestsByMethod: requestMetrics.reduce((acc, curr) => { 69 | acc[curr.method] = (acc[curr.method] || 0) + 1; 70 | return acc; 71 | }, {} as Record), 72 | requestsByPlatform: requestMetrics.reduce((acc, curr) => { 73 | if (curr.platform) { 74 | acc[curr.platform] = (acc[curr.platform] || 0) + 1; 75 | } 76 | return acc; 77 | }, {} as Record), 78 | recentRequests: requestMetrics.slice(-10), 79 | }; 80 | }; 81 | -------------------------------------------------------------------------------- /back/protos/api.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.ql.api; 4 | 5 | message EnvItem { 6 | optional int32 id = 1; 7 | optional string name = 2; 8 | optional string value = 3; 9 | optional string remarks = 4; 10 | optional int32 status = 5; 11 | optional int64 position = 6; 12 | } 13 | 14 | message GetEnvsRequest { string searchValue = 1; } 15 | 16 | message CreateEnvRequest { repeated EnvItem envs = 1; } 17 | 18 | message UpdateEnvRequest { EnvItem env = 1; } 19 | 20 | message DeleteEnvsRequest { repeated int32 ids = 1; } 21 | 22 | message MoveEnvRequest { 23 | int32 id = 1; 24 | int32 fromIndex = 2; 25 | int32 toIndex = 3; 26 | } 27 | 28 | message DisableEnvsRequest { repeated int32 ids = 1; } 29 | 30 | message EnableEnvsRequest { repeated int32 ids = 1; } 31 | 32 | message UpdateEnvNamesRequest { 33 | repeated int32 ids = 1; 34 | string name = 2; 35 | } 36 | 37 | message GetEnvByIdRequest { int32 id = 1; } 38 | 39 | message EnvsResponse { 40 | int32 code = 1; 41 | repeated EnvItem data = 2; 42 | optional string message = 3; 43 | } 44 | 45 | message EnvResponse { 46 | int32 code = 1; 47 | EnvItem data = 2; 48 | optional string message = 3; 49 | } 50 | 51 | message Response { 52 | int32 code = 1; 53 | optional string message = 2; 54 | } 55 | 56 | message SystemNotifyRequest { 57 | string title = 1; 58 | string content = 2; 59 | } 60 | 61 | message ExtraScheduleItem { 62 | string schedule = 1; 63 | } 64 | 65 | message CronItem { 66 | optional int32 id = 1; 67 | optional string command = 2; 68 | optional string schedule = 3; 69 | optional string name = 4; 70 | repeated string labels = 5; 71 | optional int32 sub_id = 6; 72 | repeated ExtraScheduleItem extra_schedules = 7; 73 | optional string task_before = 8; 74 | optional string task_after = 9; 75 | optional int32 status = 10; 76 | optional string log_path = 11; 77 | optional int32 pid = 12; 78 | optional int64 last_running_time = 13; 79 | optional int64 last_execution_time = 14; 80 | } 81 | 82 | message CreateCronRequest { 83 | string command = 1; 84 | string schedule = 2; 85 | optional string name = 3; 86 | repeated string labels = 4; 87 | optional int32 sub_id = 5; 88 | repeated ExtraScheduleItem extra_schedules = 6; 89 | optional string task_before = 7; 90 | optional string task_after = 8; 91 | } 92 | 93 | message UpdateCronRequest { 94 | int32 id = 1; 95 | optional string command = 2; 96 | optional string schedule = 3; 97 | optional string name = 4; 98 | repeated string labels = 5; 99 | optional int32 sub_id = 6; 100 | repeated ExtraScheduleItem extra_schedules = 7; 101 | optional string task_before = 8; 102 | optional string task_after = 9; 103 | } 104 | 105 | message DeleteCronsRequest { repeated int32 ids = 1; } 106 | 107 | message CronsResponse { 108 | int32 code = 1; 109 | repeated CronItem data = 2; 110 | optional string message = 3; 111 | } 112 | 113 | message CronResponse { 114 | int32 code = 1; 115 | CronItem data = 2; 116 | optional string message = 3; 117 | } 118 | 119 | message CronDetailRequest { string log_path = 1; } 120 | 121 | message CronDetailResponse { 122 | int32 code = 1; 123 | CronItem data = 2; 124 | optional string message = 3; 125 | } 126 | 127 | service Api { 128 | rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {} 129 | rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {} 130 | rpc UpdateEnv(UpdateEnvRequest) returns (EnvResponse) {} 131 | rpc DeleteEnvs(DeleteEnvsRequest) returns (Response) {} 132 | rpc MoveEnv(MoveEnvRequest) returns (EnvResponse) {} 133 | rpc DisableEnvs(DisableEnvsRequest) returns (Response) {} 134 | rpc EnableEnvs(EnableEnvsRequest) returns (Response) {} 135 | rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {} 136 | rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {} 137 | rpc SystemNotify(SystemNotifyRequest) returns (Response) {} 138 | rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {} 139 | rpc CreateCron(CreateCronRequest) returns (CronResponse) {} 140 | rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {} 141 | rpc DeleteCrons(DeleteCronsRequest) returns (Response) {} 142 | } -------------------------------------------------------------------------------- /back/protos/cron.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.ql.cron; 4 | 5 | service Cron { 6 | rpc addCron(AddCronRequest) returns (AddCronResponse); 7 | rpc delCron(DeleteCronRequest) returns (DeleteCronResponse); 8 | } 9 | 10 | message ISchedule { string schedule = 1; } 11 | 12 | message ICron { 13 | string id = 1; 14 | string schedule = 2; 15 | string command = 3; 16 | repeated ISchedule extra_schedules = 4; 17 | string name = 5; 18 | } 19 | 20 | message AddCronRequest { repeated ICron crons = 1; } 21 | 22 | message AddCronResponse {} 23 | 24 | message DeleteCronRequest { repeated string ids = 1; } 25 | 26 | message DeleteCronResponse {} 27 | -------------------------------------------------------------------------------- /back/protos/health.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.ql.health; 4 | 5 | message HealthCheckRequest { 6 | string service = 1; 7 | } 8 | 9 | message HealthCheckResponse { 10 | enum ServingStatus { 11 | UNKNOWN = 0; 12 | SERVING = 1; 13 | NOT_SERVING = 2; 14 | SERVICE_UNKNOWN = 3; 15 | } 16 | ServingStatus status = 1; 17 | } 18 | 19 | service Health { 20 | rpc Check(HealthCheckRequest) returns (HealthCheckResponse); 21 | rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse); 22 | } 23 | -------------------------------------------------------------------------------- /back/schedule/addCron.ts: -------------------------------------------------------------------------------- 1 | import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js'; 2 | import { AddCronRequest, AddCronResponse } from '../protos/cron'; 3 | import nodeSchedule from 'node-schedule'; 4 | import { scheduleStacks } from './data'; 5 | import { runCron } from '../shared/runCron'; 6 | import Logger from '../loaders/logger'; 7 | 8 | const addCron = ( 9 | call: ServerUnaryCall, 10 | callback: sendUnaryData, 11 | ) => { 12 | for (const item of call.request.crons) { 13 | const { id, schedule, command, extra_schedules, name } = item; 14 | if (scheduleStacks.has(id)) { 15 | scheduleStacks.get(id)?.forEach((x) => x.cancel()); 16 | } 17 | 18 | Logger.info( 19 | '[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s', 20 | id, 21 | name, 22 | schedule, 23 | command, 24 | ); 25 | 26 | if (extra_schedules?.length) { 27 | extra_schedules.forEach((x) => { 28 | Logger.info( 29 | '[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s', 30 | id, 31 | name, 32 | x.schedule, 33 | command, 34 | ); 35 | }); 36 | } 37 | 38 | scheduleStacks.set(id, [ 39 | nodeSchedule.scheduleJob(id, schedule, async () => { 40 | Logger.info(`[schedule][准备运行任务] 命令: ${command}`); 41 | runCron(command, item); 42 | }), 43 | ...(extra_schedules?.length 44 | ? extra_schedules.map((x) => 45 | nodeSchedule.scheduleJob(id, x.schedule, async () => { 46 | Logger.info(`[schedule][准备运行任务] 命令: ${command}`); 47 | runCron(command, item); 48 | }), 49 | ) 50 | : []), 51 | ]); 52 | } 53 | 54 | callback(null, null); 55 | }; 56 | 57 | export { addCron }; 58 | -------------------------------------------------------------------------------- /back/schedule/client.ts: -------------------------------------------------------------------------------- 1 | import { credentials } from '@grpc/grpc-js'; 2 | import { 3 | AddCronRequest, 4 | AddCronResponse, 5 | CronClient, 6 | DeleteCronRequest, 7 | DeleteCronResponse, 8 | } from '../protos/cron'; 9 | import config from '../config'; 10 | 11 | class Client { 12 | private client = new CronClient( 13 | `0.0.0.0:${config.grpcPort}`, 14 | credentials.createInsecure(), 15 | { 'grpc.enable_http_proxy': 0 }, 16 | ); 17 | 18 | addCron(request: AddCronRequest['crons']): Promise { 19 | return new Promise((resolve, reject) => { 20 | this.client.addCron({ crons: request }, (err, res) => { 21 | if (err) { 22 | reject(err); 23 | } 24 | resolve(res); 25 | }); 26 | }); 27 | } 28 | 29 | delCron(request: DeleteCronRequest['ids']): Promise { 30 | return new Promise((resolve, reject) => { 31 | this.client.delCron({ ids: request }, (err, res) => { 32 | if (err) { 33 | reject(err); 34 | } 35 | resolve(res); 36 | }); 37 | }); 38 | } 39 | } 40 | 41 | export default new Client(); 42 | -------------------------------------------------------------------------------- /back/schedule/data.ts: -------------------------------------------------------------------------------- 1 | import nodeSchedule from 'node-schedule'; 2 | import { ToadScheduler } from 'toad-scheduler'; 3 | 4 | export const scheduleStacks = new Map(); 5 | 6 | export const intervalSchedule = new ToadScheduler(); 7 | -------------------------------------------------------------------------------- /back/schedule/delCron.ts: -------------------------------------------------------------------------------- 1 | import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js'; 2 | import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron'; 3 | import { scheduleStacks } from './data'; 4 | import Logger from '../loaders/logger'; 5 | 6 | const delCron = ( 7 | call: ServerUnaryCall, 8 | callback: sendUnaryData, 9 | ) => { 10 | for (const id of call.request.ids) { 11 | if (scheduleStacks.has(id)) { 12 | Logger.info( 13 | '[schedule][取消定时任务], 任务ID: %s', 14 | id, 15 | ); 16 | scheduleStacks.get(id)?.forEach(x => x.cancel()); 17 | scheduleStacks.delete(id); 18 | } 19 | } 20 | 21 | callback(null, null); 22 | }; 23 | 24 | export { delCron }; 25 | -------------------------------------------------------------------------------- /back/schedule/health.ts: -------------------------------------------------------------------------------- 1 | import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js'; 2 | import { HealthCheckRequest, HealthCheckResponse } from '../protos/health'; 3 | import config from '../config'; 4 | import { promiseExec } from '../config/util'; 5 | 6 | const check = async ( 7 | call: ServerUnaryCall, 8 | callback: sendUnaryData, 9 | ) => { 10 | switch (call.request.service) { 11 | case 'cron': 12 | const res = await promiseExec( 13 | `curl -s --noproxy '*' http://0.0.0.0:${config.port}/api/system`, 14 | ); 15 | 16 | if (res.includes('200')) { 17 | return callback(null, { status: 1 }); 18 | } 19 | 20 | const panelErrLog = await promiseExec( 21 | `tail -n 300 ~/.pm2/logs/panel-error.log`, 22 | ); 23 | const scheduleErrLog = await promiseExec( 24 | `tail -n 300 ~/.pm2/logs/schedule-error.log`, 25 | ); 26 | return callback( 27 | new Error(`${scheduleErrLog || ''}\n${panelErrLog || ''}\n${res}`.trim()), 28 | ); 29 | 30 | default: 31 | return callback(null, { status: 1 }); 32 | } 33 | }; 34 | 35 | export { check }; 36 | -------------------------------------------------------------------------------- /back/services/config.ts: -------------------------------------------------------------------------------- 1 | import { Service, Inject } from 'typedi'; 2 | import path, { join } from 'path'; 3 | import config from '../config'; 4 | import { getFileContentByName } from '../config/util'; 5 | import { Response } from 'express'; 6 | import { request } from 'undici'; 7 | 8 | @Service() 9 | export default class ConfigService { 10 | constructor() {} 11 | 12 | public async getFile(filePath: string, res: Response) { 13 | let content = ''; 14 | const avaliablePath = [config.rootPath, config.configPath].map((x) => 15 | path.resolve(x, filePath), 16 | ); 17 | 18 | if ( 19 | config.blackFileList.includes(filePath) || 20 | avaliablePath.every( 21 | (x) => 22 | !x.startsWith(config.scriptPath) && !x.startsWith(config.configPath), 23 | ) || 24 | !filePath 25 | ) { 26 | return res.send({ code: 403, message: '文件无法访问' }); 27 | } 28 | 29 | if (filePath.startsWith('sample/')) { 30 | const res = await request( 31 | `https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`, 32 | ); 33 | content = await res.body.text(); 34 | } else if (filePath.startsWith('data/scripts/')) { 35 | content = await getFileContentByName(join(config.rootPath, filePath)); 36 | } else { 37 | content = await getFileContentByName(join(config.configPath, filePath)); 38 | } 39 | 40 | res.send({ code: 200, data: content }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /back/services/grpc.ts: -------------------------------------------------------------------------------- 1 | import { Server, ServerCredentials } from '@grpc/grpc-js'; 2 | import { CronService } from '../protos/cron'; 3 | import { HealthService } from '../protos/health'; 4 | import { ApiService } from '../protos/api'; 5 | import { addCron } from '../schedule/addCron'; 6 | import { delCron } from '../schedule/delCron'; 7 | import { check } from '../schedule/health'; 8 | import * as Api from '../schedule/api'; 9 | import Logger from '../loaders/logger'; 10 | import { promisify } from 'util'; 11 | import config from '../config'; 12 | import { metricsService } from './metrics'; 13 | import { Service } from 'typedi'; 14 | 15 | @Service() 16 | export class GrpcServerService { 17 | private server: Server = new Server({ 'grpc.enable_http_proxy': 0 }); 18 | 19 | async initialize() { 20 | try { 21 | this.server.addService(HealthService, { check }); 22 | this.server.addService(CronService, { addCron, delCron }); 23 | this.server.addService(ApiService, Api); 24 | 25 | const grpcPort = config.grpcPort; 26 | const bindAsync = promisify(this.server.bindAsync).bind(this.server); 27 | await bindAsync( 28 | `0.0.0.0:${grpcPort}`, 29 | ServerCredentials.createInsecure(), 30 | ); 31 | Logger.debug(`✌️ gRPC service started successfully`); 32 | 33 | metricsService.record('grpc_service_start', 1, { 34 | port: grpcPort.toString(), 35 | }); 36 | 37 | return grpcPort; 38 | } catch (err) { 39 | Logger.error('Failed to start gRPC service:', err); 40 | throw err; 41 | } 42 | } 43 | 44 | async shutdown() { 45 | try { 46 | if (this.server) { 47 | await new Promise((resolve) => { 48 | this.server.tryShutdown(() => { 49 | Logger.debug('gRPC service stopped'); 50 | metricsService.record('grpc_service_stop', 1); 51 | resolve(null); 52 | }); 53 | }); 54 | } 55 | } catch (err) { 56 | Logger.error('Error while shutting down gRPC service:', err); 57 | throw err; 58 | } 59 | } 60 | 61 | getServer() { 62 | return this.server; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /back/services/health.ts: -------------------------------------------------------------------------------- 1 | import { Service } from 'typedi'; 2 | import Logger from '../loaders/logger'; 3 | import { GrpcServerService } from './grpc'; 4 | import { HttpServerService } from './http'; 5 | 6 | interface HealthStatus { 7 | status: 'ok' | 'error'; 8 | services: { 9 | http: boolean; 10 | grpc: boolean; 11 | }; 12 | metrics: { 13 | uptime: number; 14 | memory: { 15 | used: number; 16 | total: number; 17 | }; 18 | }; 19 | } 20 | 21 | @Service() 22 | export class HealthService { 23 | private startTime = Date.now(); 24 | 25 | constructor( 26 | private grpcServerService: GrpcServerService, 27 | private httpServerService: HttpServerService, 28 | ) {} 29 | 30 | async check(): Promise { 31 | const status: HealthStatus = { 32 | status: 'ok', 33 | services: { 34 | http: true, 35 | grpc: true, 36 | }, 37 | metrics: { 38 | uptime: Math.floor((Date.now() - this.startTime) / 1000), 39 | memory: { 40 | used: process.memoryUsage().heapUsed, 41 | total: process.memoryUsage().heapTotal, 42 | }, 43 | }, 44 | }; 45 | 46 | try { 47 | const httpServer = this.httpServerService.getServer(); 48 | if (!httpServer) { 49 | status.services.http = false; 50 | status.status = 'error'; 51 | } 52 | } catch (err) { 53 | status.services.http = false; 54 | status.status = 'error'; 55 | Logger.error('HTTP server check failed:', err); 56 | } 57 | 58 | try { 59 | const grpcServer = this.grpcServerService.getServer(); 60 | if (!grpcServer) { 61 | status.services.grpc = false; 62 | status.status = 'error'; 63 | } 64 | } catch (err) { 65 | status.services.grpc = false; 66 | status.status = 'error'; 67 | Logger.error('gRPC server check failed:', err); 68 | } 69 | 70 | return status; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /back/services/http.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Logger from '../loaders/logger'; 3 | import { metricsService } from './metrics'; 4 | import { Service } from 'typedi'; 5 | import { Server } from 'http'; 6 | 7 | @Service() 8 | export class HttpServerService { 9 | private server?: Server = undefined; 10 | 11 | async initialize(expressApp: express.Application, port: number) { 12 | try { 13 | return new Promise((resolve, reject) => { 14 | this.server = expressApp.listen(port, '0.0.0.0', () => { 15 | Logger.debug(`✌️ HTTP service started successfully`); 16 | metricsService.record('http_service_start', 1, { 17 | port: port.toString(), 18 | }); 19 | resolve(this.server); 20 | }); 21 | 22 | this.server?.on('error', (err: Error) => { 23 | Logger.error('Failed to start HTTP service:', err); 24 | reject(err); 25 | }); 26 | }); 27 | } catch (err) { 28 | Logger.error('Failed to start HTTP service:', err); 29 | throw err; 30 | } 31 | } 32 | 33 | async shutdown() { 34 | try { 35 | if (this.server) { 36 | await new Promise((resolve) => { 37 | this.server?.close(() => { 38 | Logger.debug('HTTP service stopped'); 39 | metricsService.record('http_service_stop', 1); 40 | resolve(null); 41 | }); 42 | }); 43 | } 44 | } catch (err) { 45 | Logger.error('Error while shutting down HTTP service:', err); 46 | throw err; 47 | } 48 | } 49 | 50 | getServer() { 51 | return this.server; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /back/services/log.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { Inject, Service } from 'typedi'; 3 | import winston from 'winston'; 4 | import config from '../config'; 5 | 6 | @Service() 7 | export default class LogService { 8 | constructor(@Inject('logger') private logger: winston.Logger) {} 9 | 10 | public checkFilePath(filePath: string, fileName: string) { 11 | const finalPath = path.resolve(config.logPath, filePath, fileName); 12 | return finalPath.startsWith(config.logPath) ? finalPath : ''; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /back/services/metrics.ts: -------------------------------------------------------------------------------- 1 | import { performance } from 'perf_hooks'; 2 | import Logger from '../loaders/logger'; 3 | 4 | interface Metric { 5 | name: string; 6 | value: number; 7 | timestamp: number; 8 | tags?: Record; 9 | } 10 | 11 | class MetricsService { 12 | private metrics: Metric[] = []; 13 | private static instance: MetricsService; 14 | 15 | private constructor() { 16 | // 定期清理旧数据 17 | setInterval(() => { 18 | const oneHourAgo = Date.now() - 3600000; 19 | this.metrics = this.metrics.filter(m => m.timestamp > oneHourAgo); 20 | }, 60000); 21 | } 22 | 23 | static getInstance(): MetricsService { 24 | if (!MetricsService.instance) { 25 | MetricsService.instance = new MetricsService(); 26 | } 27 | return MetricsService.instance; 28 | } 29 | 30 | record(name: string, value: number, tags?: Record) { 31 | this.metrics.push({ 32 | name, 33 | value, 34 | timestamp: Date.now(), 35 | tags, 36 | }); 37 | } 38 | 39 | measure(name: string, fn: () => void, tags?: Record) { 40 | const start = performance.now(); 41 | try { 42 | fn(); 43 | } finally { 44 | const duration = performance.now() - start; 45 | this.record(name, duration, tags); 46 | } 47 | } 48 | 49 | async measureAsync(name: string, fn: () => Promise, tags?: Record) { 50 | const start = performance.now(); 51 | try { 52 | await fn(); 53 | } finally { 54 | const duration = performance.now() - start; 55 | this.record(name, duration, tags); 56 | } 57 | } 58 | 59 | getMetrics(name?: string, tags?: Record) { 60 | let filtered = this.metrics; 61 | 62 | if (name) { 63 | filtered = filtered.filter(m => m.name === name); 64 | } 65 | 66 | if (tags) { 67 | filtered = filtered.filter(m => { 68 | if (!m.tags) return false; 69 | return Object.entries(tags).every(([key, value]) => m.tags![key] === value); 70 | }); 71 | } 72 | 73 | return { 74 | count: filtered.length, 75 | average: filtered.reduce((acc, curr) => acc + curr.value, 0) / filtered.length, 76 | min: Math.min(...filtered.map(m => m.value)), 77 | max: Math.max(...filtered.map(m => m.value)), 78 | metrics: filtered, 79 | }; 80 | } 81 | 82 | report() { 83 | const report = { 84 | timestamp: Date.now(), 85 | metrics: this.getMetrics(), 86 | }; 87 | Logger.info('性能指标报告:', report); 88 | return report; 89 | } 90 | } 91 | 92 | export const metricsService = MetricsService.getInstance(); -------------------------------------------------------------------------------- /back/services/script.ts: -------------------------------------------------------------------------------- 1 | import { Service, Inject } from 'typedi'; 2 | import winston from 'winston'; 3 | import path, { join } from 'path'; 4 | import SockService from './sock'; 5 | import CronService from './cron'; 6 | import ScheduleService, { TaskCallbacks } from './schedule'; 7 | import config from '../config'; 8 | import { TASK_COMMAND } from '../config/const'; 9 | import { getFileContentByName, getPid, killTask, rmPath } from '../config/util'; 10 | import taskLimit from '../shared/pLimit'; 11 | 12 | @Service() 13 | export default class ScriptService { 14 | constructor( 15 | @Inject('logger') private logger: winston.Logger, 16 | private sockService: SockService, 17 | private cronService: CronService, 18 | private scheduleService: ScheduleService, 19 | ) {} 20 | 21 | private taskCallbacks(filePath: string): TaskCallbacks { 22 | return { 23 | onEnd: async (cp, endTime, diff) => { 24 | await rmPath(filePath); 25 | }, 26 | onError: async (message: string) => { 27 | this.sockService.sendMessage({ 28 | type: 'manuallyRunScript', 29 | message, 30 | }); 31 | }, 32 | onLog: async (message: string) => { 33 | this.sockService.sendMessage({ 34 | type: 'manuallyRunScript', 35 | message, 36 | }); 37 | }, 38 | }; 39 | } 40 | 41 | public async runScript(filePath: string) { 42 | const relativePath = path.relative(config.scriptPath, filePath); 43 | const command = `${TASK_COMMAND} ${relativePath} now`; 44 | const pid = await this.scheduleService.runTask( 45 | `real_time=true ${command}`, 46 | this.taskCallbacks(filePath), 47 | { command, id: relativePath.replace(/ /g, '-'), runOrigin: 'script' }, 48 | 'start', 49 | ); 50 | 51 | return { code: 200, data: pid }; 52 | } 53 | 54 | public async stopScript(filePath: string, pid: number) { 55 | if (!pid) { 56 | const relativePath = path.relative(config.scriptPath, filePath); 57 | taskLimit.removeQueuedCron(relativePath.replace(/ /g, '-')); 58 | pid = (await getPid(`${TASK_COMMAND} ${relativePath} now`)) as number; 59 | } 60 | try { 61 | await killTask(pid); 62 | } catch (error) {} 63 | 64 | return { code: 200 }; 65 | } 66 | 67 | public checkFilePath(filePath: string, fileName: string) { 68 | const finalPath = path.resolve(config.scriptPath, filePath, fileName); 69 | return finalPath.startsWith(config.scriptPath) ? finalPath : ''; 70 | } 71 | 72 | public async getFile(filePath: string, fileName: string) { 73 | const finalPath = this.checkFilePath(filePath, fileName); 74 | 75 | if (!finalPath) { 76 | return ''; 77 | } 78 | 79 | const content = await getFileContentByName(finalPath); 80 | return content; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /back/services/sock.ts: -------------------------------------------------------------------------------- 1 | import { Service, Inject } from 'typedi'; 2 | import winston from 'winston'; 3 | import { Connection } from 'sockjs'; 4 | import { SockMessage } from '../data/sock'; 5 | 6 | @Service() 7 | export default class SockService { 8 | private clients: Connection[] = []; 9 | 10 | constructor(@Inject('logger') private logger: winston.Logger) { } 11 | 12 | public getClients() { 13 | return this.clients; 14 | } 15 | 16 | public addClient(conn: Connection) { 17 | if (this.clients.indexOf(conn) === -1) { 18 | this.clients.push(conn); 19 | } 20 | } 21 | 22 | public removeClient(conn: Connection) { 23 | const index = this.clients.indexOf(conn); 24 | if (index !== -1) { 25 | this.clients.splice(index, 1); 26 | } 27 | } 28 | 29 | public sendMessage(msg: SockMessage) { 30 | this.clients.forEach((x) => { 31 | x.write(JSON.stringify(msg)); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /back/services/sshKey.ts: -------------------------------------------------------------------------------- 1 | import { Service, Inject } from 'typedi'; 2 | import winston from 'winston'; 3 | import fs from 'fs/promises'; 4 | import os from 'os'; 5 | import path from 'path'; 6 | import { Subscription } from '../data/subscription'; 7 | import { formatUrl } from '../config/subscription'; 8 | import config from '../config'; 9 | import { fileExist, rmPath } from '../config/util'; 10 | import { writeFileWithLock } from '../shared/utils'; 11 | 12 | @Service() 13 | export default class SshKeyService { 14 | private homedir = os.homedir(); 15 | private sshPath = config.sshdPath; 16 | private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config'); 17 | private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`; 18 | 19 | constructor(@Inject('logger') private logger: winston.Logger) { 20 | this.initSshConfigFile(); 21 | } 22 | 23 | private async initSshConfigFile() { 24 | let config = ''; 25 | const _exist = await fileExist(this.sshConfigFilePath); 26 | if (_exist) { 27 | config = await fs.readFile(this.sshConfigFilePath, { encoding: 'utf-8' }); 28 | } else { 29 | await writeFileWithLock(this.sshConfigFilePath, ''); 30 | } 31 | if (!config.includes(this.sshConfigHeader)) { 32 | await writeFileWithLock( 33 | this.sshConfigFilePath, 34 | `${this.sshConfigHeader}\n\n${config}`, 35 | ); 36 | } 37 | } 38 | 39 | private async generatePrivateKeyFile( 40 | alias: string, 41 | key: string, 42 | ): Promise { 43 | try { 44 | await writeFileWithLock( 45 | path.join(this.sshPath, alias), 46 | `${key}${os.EOL}`, 47 | { 48 | encoding: 'utf8', 49 | mode: '400', 50 | }, 51 | ); 52 | } catch (error) { 53 | this.logger.error('生成私钥文件失败', error); 54 | } 55 | } 56 | 57 | private async removePrivateKeyFile(alias: string): Promise { 58 | try { 59 | const filePath = path.join(this.sshPath, alias); 60 | await rmPath(filePath); 61 | } catch (error) { 62 | this.logger.error('删除私钥文件失败', error); 63 | } 64 | } 65 | 66 | private async generateSingleSshConfig( 67 | alias: string, 68 | host: string, 69 | proxy?: string, 70 | ) { 71 | if (host === 'github.com') { 72 | host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`; 73 | } 74 | const proxyStr = proxy 75 | ? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n` 76 | : ''; 77 | const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${path.join( 78 | this.sshPath, 79 | alias, 80 | )}\n StrictHostKeyChecking no\n${proxyStr}`; 81 | await writeFileWithLock( 82 | `${path.join(this.sshPath, `${alias}.config`)}`, 83 | config, 84 | ); 85 | } 86 | 87 | private async removeSshConfig(alias: string) { 88 | try { 89 | const filePath = path.join(this.sshPath, `${alias}.config`); 90 | await rmPath(filePath); 91 | } catch (error) { 92 | this.logger.error(`删除ssh配置文件${alias}失败`, error); 93 | } 94 | } 95 | 96 | public async addSSHKey( 97 | key: string, 98 | alias: string, 99 | host: string, 100 | proxy?: string, 101 | ): Promise { 102 | await this.generatePrivateKeyFile(alias, key); 103 | await this.generateSingleSshConfig(alias, host, proxy); 104 | } 105 | 106 | public async removeSSHKey( 107 | alias: string, 108 | host: string, 109 | proxy?: string, 110 | ): Promise { 111 | await this.removePrivateKeyFile(alias); 112 | await this.removeSshConfig(alias); 113 | } 114 | 115 | public async setSshConfig(docs: Subscription[]) { 116 | for (const doc of docs) { 117 | if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') { 118 | const { alias, proxy } = doc; 119 | const { host } = formatUrl(doc); 120 | await this.removePrivateKeyFile(alias); 121 | await this.removeSshConfig(alias); 122 | await this.generatePrivateKeyFile( 123 | alias, 124 | (doc.pull_option as any).private_key, 125 | ); 126 | await this.generateSingleSshConfig(alias, host, proxy); 127 | } 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /back/shared/interface.ts: -------------------------------------------------------------------------------- 1 | import { Dependence } from '../data/dependence'; 2 | import { ICron } from '../protos/cron'; 3 | 4 | export type Override< 5 | T, 6 | K extends Partial<{ [P in keyof T]: any }> | string, 7 | > = K extends string 8 | ? Omit & { [P in keyof T]: T[P] | unknown } 9 | : Omit & K; 10 | 11 | export type TCron = Override, { id: string }>; 12 | 13 | export interface IDependencyFn { 14 | (): Promise; 15 | dependency?: Dependence; 16 | } 17 | 18 | export interface ICronFn { 19 | (): Promise; 20 | cron?: TCron; 21 | } 22 | 23 | export interface ISchedule { 24 | schedule?: string; 25 | name?: string; 26 | command?: string; 27 | id: string; 28 | } 29 | 30 | export interface IScheduleFn { 31 | (): Promise; 32 | schedule?: ISchedule; 33 | } 34 | -------------------------------------------------------------------------------- /back/shared/runCron.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'cross-spawn'; 2 | import taskLimit from './pLimit'; 3 | import Logger from '../loaders/logger'; 4 | import { ICron } from '../protos/cron'; 5 | 6 | export function runCron(cmd: string, cron: ICron): Promise { 7 | return taskLimit.runWithCronLimit(cron, () => { 8 | return new Promise(async (resolve: any) => { 9 | Logger.info( 10 | `[schedule][开始执行任务] 参数 ${JSON.stringify({ 11 | ...cron, 12 | command: cmd, 13 | })}`, 14 | ); 15 | const cp = spawn(cmd, { shell: '/bin/bash' }); 16 | 17 | cp.stderr.on('data', (data) => { 18 | Logger.info( 19 | '[schedule][执行任务失败] 命令: %s, 错误信息: %j', 20 | cmd, 21 | data.toString(), 22 | ); 23 | }); 24 | cp.on('error', (err) => { 25 | Logger.error( 26 | '[schedule][创建任务失败] 命令: %s, 错误信息: %j', 27 | cmd, 28 | err, 29 | ); 30 | }); 31 | 32 | cp.on('exit', async (code) => { 33 | taskLimit.removeQueuedCron(cron.id); 34 | Logger.info( 35 | '[schedule][执行任务结束] 参数: %s, 退出码: %j', 36 | JSON.stringify({ 37 | ...cron, 38 | command: cmd, 39 | }), 40 | code, 41 | ); 42 | resolve({ ...cron, command: cmd, pid: cp.pid, code }); 43 | }); 44 | }); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /back/shared/store.ts: -------------------------------------------------------------------------------- 1 | import { AuthInfo } from '../data/system'; 2 | import { App } from '../data/open'; 3 | import Keyv from 'keyv'; 4 | import KeyvSqlite from '@keyv/sqlite'; 5 | import config from '../config'; 6 | import path from 'path'; 7 | 8 | export enum EKeyv { 9 | 'apps' = 'apps', 10 | 'authInfo' = 'authInfo', 11 | } 12 | 13 | export interface IKeyvStore { 14 | apps: App[]; 15 | authInfo: AuthInfo; 16 | } 17 | 18 | const keyvSqlite = new KeyvSqlite(path.join(config.dbPath, 'keyv.sqlite')); 19 | export const keyvStore = new Keyv({ store: keyvSqlite }); 20 | 21 | export const shareStore = { 22 | getAuthInfo() { 23 | return keyvStore.get(EKeyv.authInfo); 24 | }, 25 | updateAuthInfo(value: IKeyvStore['authInfo']) { 26 | return keyvStore.set(EKeyv.authInfo, value); 27 | }, 28 | getApps() { 29 | return keyvStore.get(EKeyv.apps); 30 | }, 31 | updateApps(apps: App[]) { 32 | return keyvStore.set(EKeyv.apps, apps); 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /back/shared/utils.ts: -------------------------------------------------------------------------------- 1 | import { lock } from 'proper-lockfile'; 2 | import os from 'os'; 3 | import path from 'path'; 4 | import { writeFile, open, chmod } from 'fs/promises'; 5 | import { fileExist } from '../config/util'; 6 | 7 | function getUniqueLockPath(filePath: string) { 8 | const sanitizedPath = filePath 9 | .replace(/[<>:"/\\|?*]/g, '_') 10 | .replace(/^_/, ''); 11 | return path.join(os.tmpdir(), `${sanitizedPath}.ql_lock`); 12 | } 13 | 14 | export async function writeFileWithLock( 15 | filePath: string, 16 | content: string, 17 | options: Parameters[2] = {}, 18 | ) { 19 | if (typeof options === 'string') { 20 | options = { encoding: options }; 21 | } 22 | if (!(await fileExist(filePath))) { 23 | const fileHandle = await open(filePath, 'w'); 24 | fileHandle.close(); 25 | } 26 | const lockfilePath = getUniqueLockPath(filePath); 27 | 28 | const release = await lock(filePath, { 29 | retries: { 30 | retries: 10, 31 | factor: 2, 32 | minTimeout: 100, 33 | maxTimeout: 3000, 34 | }, 35 | lockfilePath, 36 | }); 37 | await writeFile(filePath, content, { encoding: 'utf8', ...options }); 38 | if (options?.mode) { 39 | await chmod(filePath, options.mode); 40 | } 41 | await release(); 42 | } 43 | -------------------------------------------------------------------------------- /back/token.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import OpenService from './services/open'; 3 | import { Container } from 'typedi'; 4 | import LoggerInstance from './loaders/logger'; 5 | import config from './config'; 6 | import path from 'path'; 7 | import os from 'os'; 8 | import { writeFileWithLock } from './shared/utils'; 9 | 10 | const tokenFile = path.join(config.configPath, 'token.json'); 11 | 12 | async function getToken() { 13 | try { 14 | Container.set('logger', LoggerInstance); 15 | const openService = Container.get(OpenService); 16 | const appToken = await openService.generateSystemToken(); 17 | console.log(appToken.value); 18 | await writeFile({ 19 | value: appToken.value, 20 | expiration: appToken.expiration, 21 | }); 22 | } catch (error) { 23 | console.log(error); 24 | } 25 | } 26 | 27 | async function writeFile(data: any) { 28 | await writeFileWithLock(tokenFile, `${JSON.stringify(data)}${os.EOL}`); 29 | } 30 | 31 | getToken(); 32 | -------------------------------------------------------------------------------- /back/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["ESNext"], 5 | "typeRoots": [ 6 | "./types", 7 | "../node_modules/celebrate/lib", 8 | "../node_modules/@types" 9 | ], 10 | "allowSyntheticDefaultImports": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "strict": true, 15 | "skipLibCheck": true, 16 | "moduleResolution": "node", 17 | "module": "commonjs", 18 | "pretty": true, 19 | "sourceMap": true, 20 | "outDir": "../static/build", 21 | "allowJs": true, 22 | "noEmit": false, 23 | "esModuleInterop": true 24 | }, 25 | "include": ["./**/*"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /back/types/express.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | export {}; 4 | 5 | declare global { 6 | namespace Express { 7 | interface Request { 8 | platform: 'desktop' | 'mobile'; 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /back/validation/schedule.ts: -------------------------------------------------------------------------------- 1 | import { Joi } from 'celebrate'; 2 | import cron_parser from 'cron-parser'; 3 | import { ScheduleType } from '../interface/schedule'; 4 | 5 | const validateSchedule = (value: string, helpers: any) => { 6 | if ( 7 | value.startsWith(ScheduleType.ONCE) || 8 | value.startsWith(ScheduleType.BOOT) 9 | ) { 10 | return value; 11 | } 12 | 13 | try { 14 | if (cron_parser.parseExpression(value).hasNext()) { 15 | return value; 16 | } 17 | } catch (e) { 18 | return helpers.error('any.invalid'); 19 | } 20 | return helpers.error('any.invalid'); 21 | }; 22 | 23 | export const scheduleSchema = Joi.string() 24 | .required() 25 | .custom(validateSchedule) 26 | .messages({ 27 | 'any.invalid': '无效的定时规则', 28 | 'string.empty': '定时规则不能为空', 29 | }); 30 | 31 | export const commonCronSchema = { 32 | name: Joi.string().optional(), 33 | command: Joi.string().required(), 34 | schedule: scheduleSchema, 35 | labels: Joi.array().optional(), 36 | sub_id: Joi.number().optional().allow(null), 37 | extra_schedules: Joi.array().optional().allow(null), 38 | task_before: Joi.string().optional().allow('').allow(null), 39 | task_after: Joi.string().optional().allow('').allow(null), 40 | }; 41 | -------------------------------------------------------------------------------- /docker/310.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-alpine3.18 AS builder 2 | COPY package.json .npmrc pnpm-lock.yaml /tmp/build/ 3 | RUN set -x \ 4 | && apk update \ 5 | && apk add nodejs npm git \ 6 | && npm i -g pnpm@8.3.1 pm2 ts-node \ 7 | && cd /tmp/build \ 8 | && pnpm install --prod 9 | 10 | FROM python:3.10-alpine 11 | 12 | ARG QL_MAINTAINER="whyour" 13 | LABEL maintainer="${QL_MAINTAINER}" 14 | ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git 15 | ARG QL_BRANCH=develop 16 | ARG PYTHON_SHORT_VERSION=3.10 17 | 18 | ENV QL_DIR=/ql \ 19 | QL_BRANCH=${QL_BRANCH} \ 20 | LANG=C.UTF-8 \ 21 | SHELL=/bin/bash \ 22 | PS1="\u@\h:\w \$ " 23 | 24 | VOLUME /ql/data 25 | 26 | EXPOSE 5700 27 | 28 | COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ 29 | COPY --from=builder /usr/local/bin/. /usr/local/bin/ 30 | 31 | RUN set -x \ 32 | && apk update -f \ 33 | && apk upgrade \ 34 | && apk --no-cache add -f bash \ 35 | coreutils \ 36 | git \ 37 | curl \ 38 | wget \ 39 | tzdata \ 40 | perl \ 41 | openssl \ 42 | nginx \ 43 | nodejs \ 44 | jq \ 45 | openssh \ 46 | procps \ 47 | netcat-openbsd \ 48 | unzip \ 49 | npm \ 50 | && rm -rf /var/cache/apk/* \ 51 | && apk update \ 52 | && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ 53 | && echo "Asia/Shanghai" > /etc/timezone \ 54 | && git config --global user.email "qinglong@users.noreply.github.com" \ 55 | && git config --global user.name "qinglong" \ 56 | && git config --global http.postBuffer 524288000 \ 57 | && rm -rf /root/.cache \ 58 | && ulimit -c 0 59 | 60 | ARG SOURCE_COMMIT 61 | RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \ 62 | && cd ${QL_DIR} \ 63 | && cp -f .env.example .env \ 64 | && chmod 777 ${QL_DIR}/shell/*.sh \ 65 | && chmod 777 ${QL_DIR}/docker/*.sh \ 66 | && git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \ 67 | && mkdir -p ${QL_DIR}/static \ 68 | && cp -rf /static/* ${QL_DIR}/static \ 69 | && rm -rf /static 70 | 71 | ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ 72 | PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ 73 | PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 74 | 75 | ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \ 76 | NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \ 77 | PIP_CACHE_DIR=${PYTHON_HOME}/pip \ 78 | PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages 79 | 80 | RUN pip3 install --prefix ${PYTHON_HOME} requests 81 | 82 | COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/ 83 | 84 | WORKDIR ${QL_DIR} 85 | 86 | HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ 87 | CMD curl -sf --noproxy '*' http://127.0.0.1:5600/api/health || exit 1 88 | 89 | ENTRYPOINT ["./docker/docker-entrypoint.sh"] 90 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-alpine3.18 AS builder 2 | COPY package.json .npmrc pnpm-lock.yaml /tmp/build/ 3 | RUN set -x \ 4 | && apk update \ 5 | && apk add nodejs npm git \ 6 | && npm i -g pnpm@8.3.1 pm2 ts-node \ 7 | && cd /tmp/build \ 8 | && pnpm install --prod 9 | 10 | FROM python:3.11-alpine 11 | 12 | ARG QL_MAINTAINER="whyour" 13 | LABEL maintainer="${QL_MAINTAINER}" 14 | ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git 15 | ARG QL_BRANCH=develop 16 | ARG PYTHON_SHORT_VERSION=3.11 17 | 18 | ENV QL_DIR=/ql \ 19 | QL_BRANCH=${QL_BRANCH} \ 20 | LANG=C.UTF-8 \ 21 | SHELL=/bin/bash \ 22 | PS1="\u@\h:\w \$ " 23 | 24 | VOLUME /ql/data 25 | 26 | EXPOSE 5700 27 | 28 | COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ 29 | COPY --from=builder /usr/local/bin/. /usr/local/bin/ 30 | 31 | RUN set -x \ 32 | && apk update -f \ 33 | && apk upgrade \ 34 | && apk --no-cache add -f bash \ 35 | coreutils \ 36 | git \ 37 | curl \ 38 | wget \ 39 | tzdata \ 40 | perl \ 41 | openssl \ 42 | nginx \ 43 | nodejs \ 44 | jq \ 45 | openssh \ 46 | procps \ 47 | netcat-openbsd \ 48 | unzip \ 49 | npm \ 50 | && rm -rf /var/cache/apk/* \ 51 | && apk update \ 52 | && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ 53 | && echo "Asia/Shanghai" > /etc/timezone \ 54 | && git config --global user.email "qinglong@users.noreply.github.com" \ 55 | && git config --global user.name "qinglong" \ 56 | && git config --global http.postBuffer 524288000 \ 57 | && rm -rf /root/.cache \ 58 | && ulimit -c 0 59 | 60 | ARG SOURCE_COMMIT 61 | RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \ 62 | && cd ${QL_DIR} \ 63 | && cp -f .env.example .env \ 64 | && chmod 777 ${QL_DIR}/shell/*.sh \ 65 | && chmod 777 ${QL_DIR}/docker/*.sh \ 66 | && git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \ 67 | && mkdir -p ${QL_DIR}/static \ 68 | && cp -rf /static/* ${QL_DIR}/static \ 69 | && rm -rf /static 70 | 71 | ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ 72 | PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ 73 | PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 74 | 75 | ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \ 76 | NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \ 77 | PIP_CACHE_DIR=${PYTHON_HOME}/pip \ 78 | PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages 79 | 80 | RUN pip3 install --prefix ${PYTHON_HOME} requests 81 | 82 | COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/ 83 | 84 | WORKDIR ${QL_DIR} 85 | 86 | HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ 87 | CMD curl -sf --noproxy '*' http://127.0.0.1:5600/api/health || exit 1 88 | 89 | ENTRYPOINT ["./docker/docker-entrypoint.sh"] 90 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | web: 3 | image: whyour/qinglong:latest # 基于 Debian 的版本:whyour/qinglong:debian 4 | volumes: 5 | - ./data:/ql/data 6 | ports: 7 | - "5700:5700" 8 | environment: 9 | QlBaseUrl: '/' # 部署路径非必须,以斜杠开头和结尾,比如 /test/ 10 | restart: unless-stopped 11 | -------------------------------------------------------------------------------- /docker/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | dir_shell=/ql/shell 4 | . $dir_shell/share.sh 5 | . $dir_shell/env.sh 6 | 7 | log_with_style() { 8 | local level="$1" 9 | local message="$2" 10 | local timestamp=$(date '+%Y-%m-%d %H:%M:%S') 11 | 12 | printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}" 13 | } 14 | 15 | log_with_style "INFO" "🚀 1. 检测配置文件..." 16 | import_config "$@" 17 | make_dir /etc/nginx/conf.d 18 | make_dir /run/nginx 19 | init_nginx 20 | fix_config 21 | 22 | pm2 l &>/dev/null 23 | 24 | log_with_style "INFO" "🔄 2. 启动 nginx..." 25 | nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf 26 | 27 | log_with_style "INFO" "⚙️ 3. 启动 pm2 服务..." 28 | reload_pm2 29 | 30 | if [[ $AutoStartBot == true ]]; then 31 | log_with_style "INFO" "🤖 4. 启动 bot..." 32 | nohup ql bot >$dir_log/bot.log 2>&1 & 33 | fi 34 | 35 | if [[ $EnableExtraShell == true ]]; then 36 | log_with_style "INFO" "🛠️ 5. 执行自定义脚本..." 37 | nohup ql extra >$dir_log/extra.log 2>&1 & 38 | fi 39 | 40 | log_with_style "SUCCESS" "🎉 容器启动成功!" 41 | 42 | crond -f >/dev/null 43 | 44 | exec "$@" 45 | -------------------------------------------------------------------------------- /docker/front.conf: -------------------------------------------------------------------------------- 1 | upstream baseApi { 2 | server 0.0.0.0:5600; 3 | } 4 | 5 | map $http_upgrade $connection_upgrade { 6 | default keep-alive; 7 | 'websocket' upgrade; 8 | } 9 | 10 | server { 11 | IPV4_CONFIG 12 | IPV6_CONFIG 13 | ssl_session_timeout 5m; 14 | 15 | location QL_BASE_URLapi/ { 16 | proxy_set_header Host $http_host; 17 | proxy_set_header X-Real-IP $remote_addr; 18 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 19 | proxy_pass http://baseApi/api/; 20 | proxy_buffering off; 21 | proxy_redirect default; 22 | proxy_connect_timeout 1800; 23 | proxy_send_timeout 1800; 24 | proxy_read_timeout 1800; 25 | 26 | proxy_set_header Upgrade $http_upgrade; 27 | proxy_set_header Connection $connection_upgrade; 28 | } 29 | 30 | location QL_BASE_URLopen/ { 31 | proxy_set_header Host $http_host; 32 | proxy_set_header X-Real-IP $remote_addr; 33 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 34 | proxy_pass http://baseApi/open/; 35 | proxy_buffering off; 36 | proxy_redirect default; 37 | proxy_connect_timeout 1800; 38 | proxy_send_timeout 1800; 39 | proxy_read_timeout 1800; 40 | } 41 | 42 | gzip on; 43 | gzip_static on; 44 | gzip_types text/plain application/json application/javascript application/x-javascript text/css application/xml text/javascript; 45 | gzip_proxied any; 46 | gzip_vary on; 47 | gzip_comp_level 6; 48 | gzip_buffers 16 8k; 49 | gzip_http_version 1.0; 50 | QL_ROOT_CONFIG 51 | 52 | location QL_BASE_URL_LOCATION { 53 | QL_ALIAS_CONFIG 54 | index index.html index.htm; 55 | try_files $uri QL_BASE_URLindex.html; 56 | } 57 | 58 | location ~ .*\.(html)$ { 59 | add_header Cache-Control no-cache; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /docker/nginx.conf: -------------------------------------------------------------------------------- 1 | user root; 2 | worker_processes auto; 3 | pcre_jit on; 4 | error_log /var/log/nginx/error.log warn; 5 | include /etc/nginx/modules/*.conf; 6 | 7 | events { 8 | worker_connections 1024; 9 | } 10 | 11 | http { 12 | include /etc/nginx/mime.types; 13 | default_type application/octet-stream; 14 | 15 | server_tokens off; 16 | 17 | client_max_body_size 4096m; 18 | client_body_buffer_size 20m; 19 | 20 | keepalive_timeout 65; 21 | 22 | sendfile on; 23 | 24 | tcp_nodelay on; 25 | 26 | ssl_prefer_server_ciphers on; 27 | 28 | ssl_session_cache shared:SSL:2m; 29 | 30 | gzip on; 31 | gzip_static on; 32 | gzip_types text/plain application/json application/javascript application/x-javascript text/css application/xml text/javascript; 33 | gzip_proxied any; 34 | gzip_vary on; 35 | gzip_comp_level 6; 36 | gzip_buffers 16 8k; 37 | gzip_http_version 1.0; 38 | 39 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 40 | '$status $body_bytes_sent "$http_referer" ' 41 | '"$http_user_agent" "$http_x_forwarded_for"'; 42 | 43 | access_log /var/log/nginx/access.log main; 44 | include /etc/nginx/conf.d/*.conf; 45 | } 46 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'qinglong', 5 | max_restarts: 5, 6 | kill_timeout: 1000, 7 | wait_ready: true, 8 | listen_timeout: 5000, 9 | source_map_support: true, 10 | time: true, 11 | script: 'static/build/app.js', 12 | env: { 13 | http_proxy: '', 14 | https_proxy: '', 15 | HTTP_PROXY: '', 16 | HTTPS_PROXY: '', 17 | all_proxy: '', 18 | ALL_PROXY: '', 19 | }, 20 | }, 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["back", ".env"], 3 | "ext": "js,ts,json", 4 | "exec": "ts-node -P ./back/tsconfig.json ./back/app.ts" 5 | } 6 | -------------------------------------------------------------------------------- /sample/auth.sample.json: -------------------------------------------------------------------------------- 1 | { "username": "admin", "password": "admin" } 2 | -------------------------------------------------------------------------------- /sample/extra.sample.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ## 添加你需要重启自动执行的任意命令,比如 ql repo 4 | ## 安装node依赖使用 pnpm add -g xxx xxx 5 | ## 安装python依赖使用 pip3 install xxx 6 | -------------------------------------------------------------------------------- /sample/ql_sample.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 任务名称 3 | * name: script name 4 | * 定时规则 5 | * cron: 1 9 * * * 6 | */ 7 | console.log('test scripts'); 8 | QLAPI.notify('test scripts', 'test desc'); 9 | QLAPI.getEnvs({ searchValue: 'dddd' }).then((x) => { 10 | console.log('getEnvs', x); 11 | }); 12 | QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => { 13 | console.log('systemNotify', x); 14 | }); 15 | console.log('test desc'); 16 | -------------------------------------------------------------------------------- /sample/ql_sample.py: -------------------------------------------------------------------------------- 1 | """ 2 | 任务名称 3 | name: script name 4 | 定时规则 5 | cron: 1 9 * * * 6 | """ 7 | 8 | print("test script") 9 | print(QLAPI.notify("test script", "test desc")) 10 | print("test systemNotify") 11 | print(QLAPI.systemNotify({"title": "test script", "content": "dddd"})) 12 | print("test getEnvs") 13 | print(QLAPI.getEnvs({"searchValue": "1"})) 14 | print("test desc") 15 | -------------------------------------------------------------------------------- /sample/task.sample.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | -------------------------------------------------------------------------------- /sample/tool.ts: -------------------------------------------------------------------------------- 1 | import * as qiniu from 'qiniu'; 2 | import dotenv from 'dotenv'; 3 | const envFound = dotenv.config(); 4 | 5 | const accessKey = process.env.QINIU_AK; 6 | const secretKey = process.env.QINIU_SK; 7 | const mac = new qiniu.auth.digest.Mac(accessKey, secretKey); 8 | const key = 'version.yaml'; 9 | const options = { 10 | scope: `${process.env.QINIU_SCOPE}:${key}`, 11 | }; 12 | const putPolicy = new qiniu.rs.PutPolicy(options); 13 | const uploadToken = putPolicy.uploadToken(mac); 14 | 15 | const localFile = 'version.yaml'; 16 | const config = new qiniu.conf.Config({ zone: qiniu.zone.Zone_z1 }); 17 | const formUploader = new qiniu.form_up.FormUploader(config); 18 | const putExtra = new qiniu.form_up.PutExtra( 19 | '', 20 | {}, 21 | 'text/plain; charset=utf-8', 22 | ); 23 | // 文件上传 24 | formUploader.putFile( 25 | uploadToken, 26 | key, 27 | localFile, 28 | putExtra, 29 | function (respErr, respBody, respInfo) { 30 | if (respErr) { 31 | throw respErr; 32 | } 33 | if (respInfo.statusCode == 200) { 34 | console.log(respBody); 35 | } else { 36 | console.log(respInfo.statusCode); 37 | console.log(respBody); 38 | } 39 | }, 40 | ); 41 | -------------------------------------------------------------------------------- /shell/bot.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ -z ${BotRepoUrl} ]]; then 4 | url="https://github.com/SuMaiKaDe/bot.git" 5 | repo_path="${dir_repo}/dockerbot" 6 | else 7 | url=${BotRepoUrl} 8 | repo_path="${dir_repo}/diybot" 9 | fi 10 | 11 | echo -e "\n1、安装bot依赖...\n" 12 | apk --no-cache add -f zlib-dev gcc jpeg-dev python3-dev musl-dev freetype-dev 13 | echo -e "\nbot依赖安装成功...\n" 14 | 15 | echo -e "2、下载bot所需文件...\n" 16 | if [[ ! -d ${repo_path}/.git ]]; then 17 | rm -rf ${repo_path} 18 | git_clone_scripts ${url} ${repo_path} "main" 19 | fi 20 | 21 | cp -rf "$repo_path/jbot" $dir_data 22 | if [[ ! -f "$dir_config/bot.json" ]]; then 23 | cp -f "$repo_path/config/bot.json" "$dir_config" 24 | fi 25 | echo -e "\nbot文件下载成功...\n" 26 | 27 | echo -e "3、安装python3依赖...\n" 28 | cp -f "$repo_path/jbot/requirements.txt" "$dir_data" 29 | 30 | cd $dir_data 31 | cat requirements.txt | while read LREAD; do 32 | if [[ ! $(pip3 show "${LREAD%%=*}" 2>/dev/null) ]]; then 33 | pip3 --default-timeout=100 install ${LREAD} 34 | fi 35 | done 36 | 37 | echo -e "\npython3依赖安装成功...\n" 38 | 39 | echo -e "4、启动bot程序...\n" 40 | make_dir $dir_log/bot 41 | cd $dir_data 42 | ps -eo pid,command | grep "python3 -m jbot" | grep -v grep | awk '{print $1}' | xargs kill -9 2>/dev/null 43 | nohup python3 -m jbot >$dir_log/bot/nohup.log 2>&1 & 44 | echo -e "bot启动成功...\n" 45 | -------------------------------------------------------------------------------- /shell/check.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | reset_env() { 4 | echo -e "---> 1. 开始检测配置文件\n" 5 | fix_config 6 | echo -e "---> 配置文件检测完成\n" 7 | 8 | echo -e "---> 2. 开始安装青龙依赖\n" 9 | npm_install_2 $dir_root 10 | echo -e "---> 青龙依赖安装完成\n" 11 | 12 | echo -e "---> 脚本依赖安装完成\n" 13 | } 14 | 15 | copy_dep() { 16 | echo -e "---> 1. 复制通知文件\n" 17 | echo -e "---> 复制一份 $file_notify_py_sample 为 $file_notify_py\n" 18 | cp -fv $file_notify_py_sample $file_notify_py 19 | echo 20 | echo -e "---> 复制一份 $file_notify_js_sample 为 $file_notify_js\n" 21 | cp -fv $file_notify_js_sample $file_notify_js 22 | echo -e "---> 通知文件复制完成\n" 23 | 24 | echo -e "---> 2. 复制nginx配置文件\n" 25 | init_nginx 26 | echo -e "---> 配置文件复制完成\n" 27 | } 28 | 29 | pm2_log() { 30 | echo -e "---> pm2日志" 31 | local panelOut="/root/.pm2/logs/panel-out.log" 32 | local panelError="/root/.pm2/logs/panel-error.log" 33 | tail -n 300 "$panelOut" 34 | tail -n 300 "$panelError" 35 | } 36 | 37 | check_nginx() { 38 | local nginxPid=$(ps -eo pid,command | grep nginx | grep -v grep) 39 | echo -e "=====> 检测nginx服务\n$nginxPid" 40 | if [[ $nginxPid ]]; then 41 | echo -e "\n=====> nginx服务正常\n" 42 | nginx -s reload 43 | else 44 | echo -e "\n=====> nginx服务异常,重新启动nginx\n" 45 | nginx -c /etc/nginx/nginx.conf 46 | fi 47 | } 48 | 49 | check_ql() { 50 | local api=$(curl -s --noproxy "*" "http://0.0.0.0:5700") 51 | echo -e "\n=====> 检测面板\n\n$api\n" 52 | if [[ $api =~ "
" ]]; then 53 | echo -e "=====> 面板服务启动正常\n" 54 | fi 55 | } 56 | 57 | check_pm2() { 58 | pm2_log 59 | local currentTimeStamp=$(date +%s) 60 | local api=$( 61 | curl -s --noproxy "*" "http://0.0.0.0:5600/api/system?t=$currentTimeStamp" \ 62 | -H 'Accept: */*' \ 63 | -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \ 64 | -H 'Referer: http://0.0.0.0:5700/crontab' \ 65 | -H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \ 66 | --compressed 67 | ) 68 | echo -e "\n=====> 检测后台\n\n$api\n" 69 | if [[ $api =~ "{\"code\"" ]]; then 70 | echo -e "=====> 后台服务启动正常\n" 71 | fi 72 | } 73 | 74 | main() { 75 | echo -e "=====> 开始检测" 76 | npm i -g pnpm@8.3.1 pm2 ts-node 77 | 78 | reset_env 79 | copy_dep 80 | check_ql 81 | check_nginx 82 | check_pm2 83 | reload_pm2 84 | echo -e "\n=====> 检测结束\n" 85 | } 86 | 87 | main 88 | -------------------------------------------------------------------------------- /shell/env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | store_env_vars() { 4 | initial_vars=($(env | cut -d= -f1)) 5 | } 6 | 7 | restore_env_vars() { 8 | for key in $(env | cut -d= -f1); do 9 | if ! [[ " ${initial_vars[@]} " =~ " $key " ]]; then 10 | unset "$key" 11 | fi 12 | done 13 | } 14 | 15 | store_env_vars 16 | -------------------------------------------------------------------------------- /shell/preload/client.js: -------------------------------------------------------------------------------- 1 | const grpc = require('@grpc/grpc-js'); 2 | const protoLoader = require('@grpc/proto-loader'); 3 | const { join } = require('path'); 4 | 5 | class GrpcClient { 6 | static #config = { 7 | protoPath: join(process.env.QL_DIR, 'back/protos/api.proto'), 8 | serverAddress: '0.0.0.0:5500', 9 | protoOptions: { 10 | keepCase: true, 11 | longs: String, 12 | enums: String, 13 | defaults: true, 14 | }, 15 | grpcOptions: { 16 | 'grpc.enable_http_proxy': 0, 17 | }, 18 | defaultTimeout: 30000, 19 | }; 20 | 21 | static #methods = [ 22 | 'getEnvs', 23 | 'createEnv', 24 | 'updateEnv', 25 | 'deleteEnvs', 26 | 'moveEnv', 27 | 'disableEnvs', 28 | 'enableEnvs', 29 | 'updateEnvNames', 30 | 'getEnvById', 31 | 'systemNotify', 32 | 'getCronDetail', 33 | 'createCron', 34 | 'updateCron', 35 | 'deleteCrons', 36 | ]; 37 | 38 | #client; 39 | #api = {}; 40 | 41 | constructor() { 42 | this.#initializeClient(); 43 | this.#bindMethods(); 44 | } 45 | 46 | #initializeClient() { 47 | try { 48 | const { protoPath, protoOptions, serverAddress, grpcOptions } = 49 | GrpcClient.#config; 50 | 51 | const packageDefinition = protoLoader.loadSync(protoPath, protoOptions); 52 | const apiProto = grpc.loadPackageDefinition(packageDefinition).com.ql.api; 53 | 54 | this.#client = new apiProto.Api( 55 | serverAddress, 56 | grpc.credentials.createInsecure(), 57 | grpcOptions, 58 | ); 59 | } catch (error) { 60 | console.error('Failed to initialize gRPC client:', error); 61 | process.exit(1); 62 | } 63 | } 64 | 65 | #promisifyMethod(methodName) { 66 | const capitalizedMethod = 67 | methodName.charAt(0).toUpperCase() + methodName.slice(1); 68 | const method = this.#client[capitalizedMethod].bind(this.#client); 69 | 70 | return async (params = {}) => { 71 | return new Promise((resolve, reject) => { 72 | const metadata = new grpc.Metadata(); 73 | const deadline = new Date( 74 | Date.now() + GrpcClient.#config.defaultTimeout, 75 | ); 76 | 77 | method(params, metadata, { deadline }, (error, response) => { 78 | if (error) { 79 | return reject(error); 80 | } 81 | resolve(response); 82 | }); 83 | }); 84 | }; 85 | } 86 | 87 | #bindMethods() { 88 | GrpcClient.#methods.forEach((method) => { 89 | this.#api[method] = this.#promisifyMethod(method); 90 | }); 91 | } 92 | 93 | getApi() { 94 | return { 95 | ...this.#api, 96 | close: this.close.bind(this), 97 | }; 98 | } 99 | 100 | close() { 101 | if (this.#client) { 102 | this.#client.close(); 103 | this.#client = null; 104 | } 105 | } 106 | } 107 | 108 | const grpcClient = new GrpcClient(); 109 | 110 | process.on('SIGTERM', () => { 111 | grpcClient.close(); 112 | process.exit(0); 113 | }); 114 | 115 | process.on('SIGINT', () => { 116 | grpcClient.close(); 117 | process.exit(0); 118 | }); 119 | 120 | process.on('unhandledRejection', (reason, promise) => { 121 | if (reason instanceof Error) { 122 | if (reason.stack) { 123 | const relevantStack = reason.stack 124 | .split('\n') 125 | .filter((line) => { 126 | return ( 127 | !line.includes('node:internal') && 128 | !line.includes('node_modules/@grpc') && 129 | !line.includes('processTicksAndRejections') 130 | ); 131 | }) 132 | .join('\n'); 133 | console.error(relevantStack); 134 | } 135 | } else { 136 | console.error(reason); 137 | } 138 | }); 139 | 140 | module.exports = grpcClient.getApi(); 141 | -------------------------------------------------------------------------------- /shell/preload/sitecustomize.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require('child_process'); 2 | const client = require('./client.js'); 3 | require(`./env.js`); 4 | 5 | function expandRange(rangeStr, max) { 6 | const tempRangeStr = rangeStr 7 | .trim() 8 | .replace(/-max/g, `-${max}`) 9 | .replace(/max-/g, `${max}-`); 10 | 11 | return tempRangeStr.split(' ').flatMap((part) => { 12 | const rangeMatch = part.match(/^(\d+)([-~_])(\d+)$/); 13 | if (rangeMatch) { 14 | const [, start, , end] = rangeMatch.map(Number); 15 | const step = start < end ? 1 : -1; 16 | return Array.from( 17 | { length: Math.abs(end - start) + 1 }, 18 | (_, i) => start + i * step, 19 | ); 20 | } 21 | return Number(part); 22 | }); 23 | } 24 | 25 | function run() { 26 | const { 27 | envParam, 28 | numParam, 29 | file_task_before, 30 | file_task_before_js, 31 | dir_scripts, 32 | task_before, 33 | PREV_NODE_OPTIONS, 34 | } = process.env; 35 | 36 | try { 37 | process.env.NODE_OPTIONS = PREV_NODE_OPTIONS; 38 | 39 | const splitStr = '__sitecustomize__'; 40 | const fileName = process.argv[1].replace(`${dir_scripts}/`, ''); 41 | const tempFile = `/tmp/env_${process.pid}.json`; 42 | 43 | const commands = [ 44 | `source ${file_task_before} ${fileName}`, 45 | task_before ? `eval '${task_before.replace(/'/g, "'\\''")}'` : null, 46 | `echo -e '${splitStr}'`, 47 | `node -e "require('fs').writeFileSync('${tempFile}', JSON.stringify(process.env))"`, 48 | ].filter(Boolean); 49 | 50 | if (task_before) { 51 | console.log('执行前置命令\n'); 52 | } 53 | 54 | const res = execSync(commands.join(' && '), { 55 | encoding: 'utf-8', 56 | maxBuffer: 50 * 1024 * 1024, 57 | shell: '/bin/bash', 58 | }); 59 | 60 | const [output] = res.split(splitStr); 61 | 62 | try { 63 | const envStr = require('fs').readFileSync(tempFile, 'utf-8'); 64 | const newEnvObject = JSON.parse(envStr); 65 | if (typeof newEnvObject === 'object' && newEnvObject !== null) { 66 | for (const key in newEnvObject) { 67 | if (Object.prototype.hasOwnProperty.call(newEnvObject, key)) { 68 | process.env[key] = newEnvObject[key]; 69 | } 70 | } 71 | } 72 | require('fs').unlinkSync(tempFile); 73 | } catch (jsonError) { 74 | console.log( 75 | '\ue926 Failed to parse environment variables:', 76 | jsonError.message, 77 | ); 78 | try { 79 | require('fs').unlinkSync(tempFile); 80 | } catch (e) {} 81 | } 82 | 83 | if (output) { 84 | console.log(output); 85 | } 86 | if (task_before) { 87 | console.log('执行前置命令结束\n'); 88 | } 89 | } catch (error) { 90 | if (!error.message.includes('spawnSync /bin/sh E2BIG')) { 91 | console.log(`\ue926 run task before error: `, error); 92 | } else { 93 | console.log( 94 | `\ue926 The environment variable is too large. It is recommended to use task_before.js instead of task_before.sh\n`, 95 | ); 96 | } 97 | if (task_before) { 98 | console.log('执行前置命令结束\n'); 99 | } 100 | } 101 | 102 | require(file_task_before_js); 103 | 104 | if (envParam && numParam) { 105 | const array = (process.env[envParam] || '').split('&'); 106 | const runArr = expandRange(numParam, array.length); 107 | const arrayRun = runArr.map((i) => array[i - 1]); 108 | const envStr = arrayRun.join('&'); 109 | process.env[envParam] = envStr; 110 | } 111 | } 112 | 113 | try { 114 | if (!process.argv[1]) { 115 | return; 116 | } 117 | 118 | process.on('SIGTERM', (code) => { 119 | process.exit(15); 120 | }); 121 | 122 | run(); 123 | 124 | const { sendNotify } = require('./__ql_notify__.js'); 125 | global.QLAPI = { 126 | notify: sendNotify, 127 | ...client, 128 | }; 129 | } catch (error) { 130 | console.log(`run builtin code error: `, error, '\n'); 131 | } 132 | -------------------------------------------------------------------------------- /shell/pub.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo -e "开始发布" 3 | 4 | echo -e "切换master分支" 5 | git checkout master 6 | 7 | echo -e "合并develop代码" 8 | git merge origin/develop 9 | 10 | echo -e "提交master代码" 11 | git push 12 | 13 | echo -e "更新cdn文件" 14 | ts-node-transpile-only sample/tool.ts 15 | 16 | string=$(cat version.yaml | grep "version" | egrep "[^ ]*" -o | egrep "\d\.*") 17 | version="v$string" 18 | echo -e "当前版本$version" 19 | 20 | echo -e "删除已经存在的本地tag" 21 | git tag -d "$version" &>/dev/null 22 | 23 | echo -e "删除已经存在的远程tag" 24 | git push origin :refs/tags/$version &>/dev/null 25 | 26 | echo -e "创建新tag" 27 | git tag -a "$version" -m "release $version" 28 | 29 | echo -e "提交tag" 30 | git push --tags 31 | 32 | echo -e "完成发布" 33 | -------------------------------------------------------------------------------- /shell/rmlog.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | days=$1 4 | 5 | remove_js_log() { 6 | local log_full_path_list=$(find $dir_log -name "*.log") 7 | local diff_time 8 | for log in $log_full_path_list; do 9 | local log_date=$(echo $log | awk -F "/" '{print $NF}' | cut -c1-10) 10 | if ! [[ $log_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then 11 | if [[ $is_macos -eq 1 ]]; then 12 | log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log") 13 | else 14 | log_date=$(stat -c %y "$log" | cut -d ' ' -f 1) 15 | fi 16 | fi 17 | if [[ $is_macos -eq 1 ]]; then 18 | diff_time=$(($(date +%s) - $(date -j -f "%Y-%m-%d" "$log_date" +%s))) 19 | else 20 | diff_time=$(($(date +%s) - $(date +%s -d "$log_date"))) 21 | fi 22 | if [[ $diff_time -gt $((${days} * 86400)) ]]; then 23 | local log_path=$(echo "$log" | sed "s,${dir_log}/,,g") 24 | local result=$(find_cron_api "log_path=$log_path") 25 | echo -e "查询文件 $log_path" 26 | if [[ -z $result ]]; then 27 | echo -e "删除中~" 28 | rm -vf $log 29 | else 30 | echo -e "正在被 $result 使用,跳过~" 31 | fi 32 | fi 33 | done 34 | } 35 | 36 | remove_empty_dir() { 37 | cd $dir_log 38 | for dir in $(ls); do 39 | if [[ -d $dir ]] && [[ -z $(ls $dir) ]]; then 40 | rm -rf $dir 41 | fi 42 | done 43 | } 44 | 45 | if [[ ${days} ]]; then 46 | echo -e "查找旧日志文件中...\n" 47 | remove_js_log 48 | remove_empty_dir 49 | echo -e "删除旧日志执行完毕\n" 50 | fi 51 | -------------------------------------------------------------------------------- /shell/task.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | dir_shell=$QL_DIR/shell 4 | . $dir_shell/share.sh 5 | . $dir_shell/api.sh 6 | 7 | trap "single_hanle" 2 3 20 15 14 19 1 8 | single_hanle() { 9 | eval MANUAL=true handle_task_end "$@" "$cmd" 10 | exit 1 11 | } 12 | 13 | define_program() { 14 | local file_param=$1 15 | if [[ $file_param == *.js ]] || [[ $file_param == *.mjs ]]; then 16 | which_program="node" 17 | elif [[ $file_param == *.py ]] || [[ $file_param == *.pyc ]]; then 18 | which_program="python3" 19 | elif [[ $file_param == *.sh ]]; then 20 | which_program="." 21 | elif [[ $file_param == *.ts ]]; then 22 | which_program="ts-node-transpile-only" 23 | else 24 | which_program="" 25 | fi 26 | } 27 | 28 | handle_log_path() { 29 | local file_param=$1 30 | 31 | if [[ -z $file_param ]]; then 32 | file_param="task" 33 | fi 34 | 35 | if [[ -z ${ID:=} ]]; then 36 | ID=$(cat $list_crontab_user | grep -E "$cmd_task.* $file_param" | perl -pe "s|.*ID=(.*) $cmd_task.* $file_param\.*|\1|" | head -1 | awk -F " " '{print $1}') 37 | fi 38 | local suffix="" 39 | if [[ ! -z $ID ]]; then 40 | if [[ "$ID" -gt 0 ]] 2>/dev/null; then 41 | suffix="_${ID}" 42 | else 43 | ID="" 44 | fi 45 | fi 46 | 47 | time=$(date "+$mtime_format") 48 | log_time=$(format_log_time "$mtime_format" "$time") 49 | log_dir_tmp="${file_param##*/}" 50 | if [[ $file_param =~ "/" ]]; then 51 | if [[ $file_param == /* ]]; then 52 | log_dir_tmp_path="${file_param:1}" 53 | else 54 | log_dir_tmp_path="${file_param}" 55 | fi 56 | fi 57 | log_dir_tmp_path="${log_dir_tmp_path%/*}" 58 | log_dir_tmp_path="${log_dir_tmp_path##*/}" 59 | [[ $log_dir_tmp_path ]] && log_dir_tmp="${log_dir_tmp_path}_${log_dir_tmp}" 60 | log_dir="${log_dir_tmp%.*}${suffix}" 61 | log_path="$log_dir/$log_time.log" 62 | 63 | if [[ ${real_log_path:=} ]]; then 64 | log_path="$real_log_path" 65 | fi 66 | 67 | cmd="2>&1 | tee -a $dir_log/$log_path" 68 | make_dir "$dir_log/$log_dir" 69 | if [[ "${no_tee:=}" == "true" ]]; then 70 | cmd=">> $dir_log/$log_path 2>&1" 71 | fi 72 | 73 | if [[ "${real_time:=}" == "true" ]]; then 74 | cmd="" 75 | fi 76 | } 77 | 78 | format_params() { 79 | time_format="%Y-%m-%d %H:%M:%S" 80 | if [[ $is_macos -eq 1 ]]; then 81 | mtime_format=$time_format 82 | else 83 | mtime_format="%Y-%m-%d %H:%M:%S.%3N" 84 | fi 85 | timeoutCmd="" 86 | if [[ $command_timeout_time ]]; then 87 | if type timeout &>/dev/null; then 88 | timeoutCmd="timeout --foreground -s 2 -k 10s $command_timeout_time " 89 | fi 90 | fi 91 | # params=$(echo "$@" | sed -E 's/([^ ])&([^ ])/\1\\\&\2/g') 92 | 93 | # 分割 task 内置参数和脚本参数 94 | task_shell_params=() 95 | script_params=() 96 | found_double_dash=false 97 | 98 | for arg in "$@"; do 99 | if $found_double_dash; then 100 | script_params+=("$arg") 101 | elif [ "$arg" == "--" ]; then 102 | found_double_dash=true 103 | else 104 | task_shell_params+=("$arg") 105 | fi 106 | done 107 | } 108 | 109 | init_begin_time() { 110 | begin_time=$(format_time "$time_format" "$time") 111 | begin_timestamp=$(format_timestamp "$time_format" "$time") 112 | } 113 | 114 | import_config "$@" 115 | while getopts ":lm:" opt; do 116 | case $opt in 117 | l) 118 | show_log="true" 119 | ;; 120 | m) 121 | max_time="$OPTARG" 122 | ;; 123 | esac 124 | done 125 | [[ ${show_log:=} ]] && shift $(($OPTIND - 1)) 126 | if [[ ${max_time:=} ]]; then 127 | shift $(($OPTIND - 1)) 128 | command_timeout_time="$max_time" 129 | fi 130 | 131 | format_params "$@" 132 | define_program "${task_shell_params[@]}" 133 | handle_log_path "${task_shell_params[@]}" 134 | init_begin_time 135 | 136 | eval . $dir_shell/otask.sh "$cmd" 137 | exit 0 138 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | const baseUrl = window.__ENV__QlBaseUrl || '/'; 2 | import { setLocale } from '@umijs/max'; 3 | import intl from 'react-intl-universal'; 4 | 5 | export function rootContainer(container: any) { 6 | const locales = { 7 | 'en': require('./locales/en-US.json'), 8 | 'zh': require('./locales/zh-CN.json'), 9 | }; 10 | let currentLocale = intl.determineLocale({ 11 | urlLocaleKey: 'lang', 12 | cookieLocaleKey: 'lang', 13 | localStorageLocaleKey: 'lang', 14 | }).slice(0, 2); 15 | 16 | if (!currentLocale || !Object.keys(locales).includes(currentLocale)) { 17 | currentLocale = 'zh'; 18 | } 19 | 20 | intl.init({ currentLocale, locales }); 21 | setLocale(currentLocale === 'zh' ? 'zh-CN' : 'en-US'); 22 | return container; 23 | } 24 | 25 | export function modifyClientRenderOpts(memo: any) { 26 | return { 27 | ...memo, 28 | publicPath: baseUrl, 29 | basename: baseUrl, 30 | }; 31 | } 32 | 33 | export function modifyContextOpts(memo: any) { 34 | return { 35 | ...memo, 36 | basename: baseUrl, 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /src/assets/fonts/SourceCodePro-Regular.otf.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whyour/qinglong/47c194c1f4969f2cbe4a305bc4fa0fcb0dee8ea3/src/assets/fonts/SourceCodePro-Regular.otf.woff -------------------------------------------------------------------------------- /src/assets/fonts/SourceCodePro-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whyour/qinglong/47c194c1f4969f2cbe4a305bc4fa0fcb0dee8ea3/src/assets/fonts/SourceCodePro-Regular.ttf -------------------------------------------------------------------------------- /src/assets/fonts/SourceCodePro-Regular.ttf.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whyour/qinglong/47c194c1f4969f2cbe4a305bc4fa0fcb0dee8ea3/src/assets/fonts/SourceCodePro-Regular.ttf.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/log.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whyour/qinglong/47c194c1f4969f2cbe4a305bc4fa0fcb0dee8ea3/src/assets/fonts/log.ttf -------------------------------------------------------------------------------- /src/assets/fonts/log.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whyour/qinglong/47c194c1f4969f2cbe4a305bc4fa0fcb0dee8ea3/src/assets/fonts/log.woff -------------------------------------------------------------------------------- /src/assets/fonts/log.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whyour/qinglong/47c194c1f4969f2cbe4a305bc4fa0fcb0dee8ea3/src/assets/fonts/log.woff2 -------------------------------------------------------------------------------- /src/components/copy.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal'; 2 | import React, { useRef, useState, useEffect } from 'react'; 3 | import { Tooltip, Typography } from 'antd'; 4 | import { CopyOutlined, CheckOutlined } from '@ant-design/icons'; 5 | import { CopyToClipboard } from 'react-copy-to-clipboard'; 6 | 7 | const { Link } = Typography; 8 | 9 | const Copy = ({ text }: { text: string }) => { 10 | const [copied, setCopied] = useState(false); 11 | const copyIdRef = useRef(); 12 | 13 | const copyText = (e?: React.MouseEvent) => { 14 | e?.preventDefault(); 15 | e?.stopPropagation(); 16 | 17 | setCopied(true); 18 | 19 | cleanCopyId(); 20 | copyIdRef.current = window.setTimeout(() => { 21 | setCopied(false); 22 | }, 3000); 23 | }; 24 | 25 | const cleanCopyId = () => { 26 | window.clearTimeout(copyIdRef.current!); 27 | }; 28 | 29 | return ( 30 | 31 | 32 | 36 | {copied ? : } 37 | 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default Copy; 44 | -------------------------------------------------------------------------------- /src/components/iconfont.tsx: -------------------------------------------------------------------------------- 1 | import { createFromIconfontCN } from '@ant-design/icons'; 2 | 3 | const IconFont = createFromIconfontCN({ 4 | scriptUrl: ['//at.alicdn.com/t/c/font_3354854_lc939gab1iq.js'], 5 | }); 6 | 7 | export default IconFont; 8 | -------------------------------------------------------------------------------- /src/components/index.less: -------------------------------------------------------------------------------- 1 | .react-terminal-wrapper { 2 | width: 100%; 3 | background: #252a33; 4 | color: #eee; 5 | font-size: 18px; 6 | font-family: 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, 7 | monospace; 8 | border-radius: 4px; 9 | padding: 75px 45px 35px; 10 | position: relative; 11 | -webkit-box-sizing: border-box; 12 | box-sizing: border-box; 13 | } 14 | 15 | .react-terminal { 16 | overflow: auto; 17 | display: flex; 18 | flex-direction: column; 19 | } 20 | 21 | .react-terminal-wrapper.react-terminal-light { 22 | background: #ddd; 23 | color: #1a1e24; 24 | } 25 | 26 | .react-terminal-wrapper:before { 27 | content: ''; 28 | position: absolute; 29 | top: 15px; 30 | left: 15px; 31 | display: inline-block; 32 | width: 15px; 33 | height: 15px; 34 | border-radius: 50%; 35 | background: #d9515d; 36 | -webkit-box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; 37 | box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; 38 | } 39 | 40 | .react-terminal-wrapper:after { 41 | content: attr(data-terminal-name); 42 | position: absolute; 43 | color: #a2a2a2; 44 | top: 5px; 45 | left: 0; 46 | width: 100%; 47 | text-align: center; 48 | } 49 | 50 | .react-terminal-wrapper.react-terminal-light:after { 51 | color: #d76d77; 52 | } 53 | 54 | .react-terminal-line { 55 | display: block; 56 | line-height: 1.5; 57 | } 58 | 59 | .react-terminal-line:before { 60 | content: ''; 61 | display: inline-block; 62 | vertical-align: middle; 63 | color: #a2a2a2; 64 | } 65 | 66 | .react-terminal-light .react-terminal-line:before { 67 | color: #d76d77; 68 | } 69 | 70 | .react-terminal-input:before { 71 | margin-right: 0.75em; 72 | content: '$'; 73 | } 74 | 75 | .react-terminal-input[data-terminal-prompt]:before { 76 | content: attr(data-terminal-prompt); 77 | } 78 | -------------------------------------------------------------------------------- /src/components/name.tsx: -------------------------------------------------------------------------------- 1 | import { useRequest } from 'ahooks'; 2 | import { Service, Options } from 'ahooks/lib/useRequest/src/types'; 3 | import { Spin, Typography } from 'antd'; 4 | 5 | export default function Name< 6 | TData extends { data?: { name: string } }, 7 | TParams, 8 | >({ 9 | service, 10 | options, 11 | }: { 12 | service: Service; 13 | options: Options; 14 | }) { 15 | const { loading, data } = useRequest(service, options); 16 | 17 | return ( 18 | 19 | {data?.data?.name} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/tag.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal'; 2 | import { Tag, Input } from 'antd'; 3 | import { TweenOneGroup } from 'rc-tween-one'; 4 | import { PlusOutlined } from '@ant-design/icons'; 5 | import { useEffect, useRef, useState } from 'react'; 6 | 7 | const EditableTagGroup = ({ 8 | value, 9 | onChange, 10 | }: { 11 | value?: string[]; 12 | onChange?: (tags: string[]) => void; 13 | }) => { 14 | const [inputValue, setInputValue] = useState(''); 15 | const [inputVisible, setInputVisible] = useState(false); 16 | const [tags, setTags] = useState([]); 17 | const saveInputRef = useRef(); 18 | 19 | const handleClose = (removedTag: string) => { 20 | const _tags = tags.filter((tag) => tag !== removedTag); 21 | setTags(_tags); 22 | onChange?.(_tags); 23 | }; 24 | 25 | const showInput = () => { 26 | setInputVisible(true); 27 | }; 28 | 29 | const handleInputChange = (e) => { 30 | setInputValue(e.target.value); 31 | }; 32 | 33 | const handleInputConfirm = () => { 34 | if (inputValue && !tags.includes(inputValue)) { 35 | setTags([...tags, inputValue]); 36 | onChange?.([...tags, inputValue]); 37 | } 38 | setInputVisible(false); 39 | setInputValue(''); 40 | }; 41 | 42 | const tagChild = tags.map((tag) => { 43 | const tagElem = ( 44 | { 47 | e.preventDefault(); 48 | handleClose(tag); 49 | }} 50 | > 51 | {tag} 52 | 53 | ); 54 | 55 | return ( 56 | 57 | {tagElem} 58 | 59 | ); 60 | }); 61 | 62 | useEffect(() => { 63 | if (inputVisible && saveInputRef) { 64 | saveInputRef.current.focus(); 65 | } 66 | }, [inputVisible]); 67 | 68 | useEffect(() => { 69 | if (value) { 70 | setTags(value); 71 | } 72 | }, [value]); 73 | 74 | return ( 75 | <> 76 | 86 | {tagChild} 87 | 88 | {inputVisible && ( 89 | 99 | )} 100 | {!inputVisible && ( 101 | 105 | {intl.get('新建')} 106 | 107 | )} 108 | 109 | ); 110 | }; 111 | 112 | export default EditableTagGroup; 113 | -------------------------------------------------------------------------------- /src/components/terminal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | import './index.less'; 3 | 4 | export enum LineType { 5 | Input, 6 | Output, 7 | } 8 | 9 | export enum ColorMode { 10 | Light, 11 | Dark, 12 | } 13 | 14 | export interface Props { 15 | name?: string; 16 | prompt?: string; 17 | colorMode?: ColorMode; 18 | lineData: Array<{ type: LineType; value: string | React.ReactNode }>; 19 | startingInputValue?: string; 20 | } 21 | 22 | const Terminal = ({ 23 | name, 24 | prompt, 25 | colorMode, 26 | lineData, 27 | startingInputValue = '', 28 | }: Props) => { 29 | const lastLineRef = useRef(null); 30 | 31 | // An effect that handles scrolling into view the last line of terminal input or output 32 | const performScrolldown = useRef(false); 33 | useEffect(() => { 34 | if (performScrolldown.current) { 35 | // skip scrolldown when the component first loads 36 | setTimeout( 37 | () => 38 | lastLineRef?.current?.scrollIntoView({ 39 | behavior: 'smooth', 40 | block: 'nearest', 41 | }), 42 | 500, 43 | ); 44 | } 45 | performScrolldown.current = true; 46 | }, [lineData.length]); 47 | 48 | const renderedLineData = lineData.map((ld, i) => { 49 | const classes = ['react-terminal-line']; 50 | if (ld.type === LineType.Input) { 51 | classes.push('react-terminal-input'); 52 | } 53 | // `lastLineRef` is used to ensure the terminal scrolls into view to the last line; make sure to add the ref to the last 54 | if (lineData.length === i + 1) { 55 | return ( 56 | 57 | {ld.value} 58 | 59 | ); 60 | } else { 61 | return ( 62 | 63 | {ld.value} 64 | 65 | ); 66 | } 67 | }); 68 | 69 | const classes = ['react-terminal-wrapper']; 70 | if (colorMode === ColorMode.Light) { 71 | classes.push('react-terminal-light'); 72 | } 73 | return ( 74 |
75 |
{renderedLineData}
76 |
77 | ); 78 | }; 79 | 80 | export default Terminal; 81 | -------------------------------------------------------------------------------- /src/hooks/useFilterTreeData.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from 'react'; 2 | 3 | export default ( 4 | treeData: any[], 5 | searchValue: string, 6 | { 7 | treeNodeFilterProp, 8 | }: { 9 | treeNodeFilterProp: string; 10 | }, 11 | ) => { 12 | return useMemo(() => { 13 | const keys: string[] = []; 14 | 15 | if (!searchValue) { 16 | return { treeData, keys }; 17 | } 18 | 19 | const upperStr = searchValue.toUpperCase(); 20 | function filterOptionFunc(_: string, dataNode: any[]) { 21 | const value = dataNode[treeNodeFilterProp as any]; 22 | 23 | return String(value).toUpperCase().includes(upperStr); 24 | } 25 | 26 | function dig(list: any[], keepAll: boolean = false): any[] { 27 | return list 28 | .map((dataNode) => { 29 | const children = dataNode.children; 30 | 31 | const match = keepAll || filterOptionFunc!(searchValue, dataNode); 32 | const childList = dig(children || [], match); 33 | 34 | if (match || childList.length) { 35 | childList.length && keys.push(dataNode.key); 36 | return { 37 | ...dataNode, 38 | children: childList, 39 | }; 40 | } 41 | return null; 42 | }) 43 | .filter((node) => node); 44 | } 45 | 46 | return { treeData: dig(treeData), keys }; 47 | }, [treeData, searchValue, treeNodeFilterProp]); 48 | }; 49 | -------------------------------------------------------------------------------- /src/hooks/useScrollHeight.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useState } from 'react'; 2 | import useResizeObserver from '@react-hook/resize-observer'; 3 | 4 | export default (target: RefObject) => { 5 | const [height, setHeight] = useState(0); 6 | 7 | useResizeObserver(target, (entry) => { 8 | let _height = entry.target.clientHeight; 9 | if (height !== _height) { 10 | setHeight(_height); 11 | } 12 | }); 13 | return height; 14 | }; 15 | -------------------------------------------------------------------------------- /src/hooks/useTableScrollHeight.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useState } from 'react'; 2 | import useResizeObserver from '@react-hook/resize-observer'; 3 | import { getTableScroll } from '@/utils'; 4 | 5 | export default ( 6 | target: RefObject, 7 | extraHeight?: number, 8 | ) => { 9 | const [height, setHeight] = useState(0); 10 | 11 | useResizeObserver(target, (entry) => { 12 | let _target = entry.target as any; 13 | if (!_target.classList.contains('ant-table-wrapper')) { 14 | _target = entry.target.querySelector('.ant-table-wrapper'); 15 | } 16 | setHeight(getTableScroll({ extraHeight, target: _target as HTMLElement })); 17 | }); 18 | return height; 19 | }; 20 | -------------------------------------------------------------------------------- /src/layouts/defaultProps.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal'; 2 | import { SettingOutlined } from '@ant-design/icons'; 3 | import IconFont from '@/components/iconfont'; 4 | import { BasicLayoutProps } from '@ant-design/pro-layout'; 5 | 6 | export default { 7 | route: { 8 | routes: [ 9 | { 10 | name: intl.get('登录'), 11 | path: '/login', 12 | hideInMenu: true, 13 | component: '@/pages/login/index', 14 | }, 15 | { 16 | name: intl.get('初始化'), 17 | path: '/initialization', 18 | hideInMenu: true, 19 | component: '@/pages/initialization/index', 20 | }, 21 | { 22 | name: intl.get('错误'), 23 | path: '/error', 24 | hideInMenu: true, 25 | component: '@/pages/error/index', 26 | }, 27 | { 28 | path: '/crontab', 29 | name: intl.get('定时任务'), 30 | icon: , 31 | component: '@/pages/crontab/index', 32 | }, 33 | { 34 | path: '/subscription', 35 | name: intl.get('订阅管理'), 36 | icon: , 37 | component: '@/pages/subscription/index', 38 | }, 39 | { 40 | path: '/env', 41 | name: intl.get('环境变量'), 42 | icon: , 43 | component: '@/pages/env/index', 44 | }, 45 | { 46 | path: '/config', 47 | name: intl.get('配置文件'), 48 | icon: , 49 | component: '@/pages/config/index', 50 | }, 51 | { 52 | path: '/script', 53 | name: intl.get('脚本管理'), 54 | icon: , 55 | component: '@/pages/script/index', 56 | }, 57 | { 58 | path: '/dependence', 59 | name: intl.get('依赖管理'), 60 | icon: , 61 | component: '@/pages/dependence/index', 62 | }, 63 | { 64 | path: '/log', 65 | name: intl.get('日志管理'), 66 | icon: , 67 | component: '@/pages/log/index', 68 | }, 69 | { 70 | path: '/diff', 71 | name: intl.get('对比工具'), 72 | icon: , 73 | component: '@/pages/diff/index', 74 | }, 75 | { 76 | path: '/setting', 77 | name: intl.get('系统设置'), 78 | icon: , 79 | component: '@/pages/password/index', 80 | }, 81 | ], 82 | }, 83 | navTheme: 'light', 84 | fixSiderbar: true, 85 | contentWidth: 'Fixed', 86 | splitMenus: false, 87 | siderWidth: 180, 88 | } as BasicLayoutProps; 89 | -------------------------------------------------------------------------------- /src/loading.tsx: -------------------------------------------------------------------------------- 1 | import { PageLoading } from '@ant-design/pro-layout'; 2 | 3 | const NewPageLoading = () => { 4 | return ; 5 | }; 6 | 7 | export default NewPageLoading; 8 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal'; 2 | import React from 'react'; 3 | import { Button, Result, Typography } from 'antd'; 4 | 5 | const { Link } = Typography; 6 | 7 | const NotFound: React.FC = () => ( 8 | 13 | {intl.get('返回首页')} 14 | 15 | } 16 | /> 17 | ); 18 | 19 | export default NotFound; 20 | -------------------------------------------------------------------------------- /src/pages/config/index.less: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whyour/qinglong/47c194c1f4969f2cbe4a305bc4fa0fcb0dee8ea3/src/pages/config/index.less -------------------------------------------------------------------------------- /src/pages/crontab/const.ts: -------------------------------------------------------------------------------- 1 | import { ScheduleType } from './type'; 2 | 3 | export const scheduleTypeMap = { 4 | [ScheduleType.Normal]: '', 5 | [ScheduleType.Once]: '@once', 6 | [ScheduleType.Boot]: '@boot', 7 | }; 8 | 9 | export const getScheduleType = (schedule?: string): ScheduleType => { 10 | if (schedule?.startsWith('@once')) return ScheduleType.Once; 11 | if (schedule?.startsWith('@boot')) return ScheduleType.Boot; 12 | return ScheduleType.Normal; 13 | }; 14 | -------------------------------------------------------------------------------- /src/pages/crontab/index.less: -------------------------------------------------------------------------------- 1 | .ant-table-pagination.ant-pagination { 2 | margin-bottom: 0 !important; 3 | } 4 | 5 | .crontab-detail { 6 | .card-wrapper { 7 | .ant-card:last-child { 8 | .ant-card-body { 9 | min-height: 0; 10 | height: calc(80vh - 314px); 11 | height: calc(80vh - var(--vh-offset, 0px) - 314px); 12 | overflow-y: auto; 13 | 14 | > div { 15 | height: 100%; 16 | } 17 | } 18 | } 19 | } 20 | 21 | .ant-modal-body { 22 | background: #eee; 23 | padding: 12px; 24 | max-height: calc(80vh - 57px); 25 | max-height: calc(80vh - var(--vh-offset, 57px)); 26 | word-wrap: unset; 27 | } 28 | 29 | .ant-card-body { 30 | padding: 18px; 31 | } 32 | .ant-card-head { 33 | padding: 0 18px; 34 | } 35 | 36 | .ant-card:first-child { 37 | max-height: 66px; 38 | overflow: auto; 39 | 40 | .ant-card-body { 41 | min-width: 1000px; 42 | } 43 | 44 | .cron-detail-info-item { 45 | display: flex; 46 | 47 | .cron-detail-info-title { 48 | width: 50px; 49 | } 50 | 51 | .cron-detail-info-value { 52 | flex: 1; 53 | margin-top: 0; 54 | } 55 | } 56 | } 57 | 58 | .ant-card:nth-child(2) { 59 | overflow-x: auto; 60 | 61 | .ant-card-body { 62 | display: flex; 63 | justify-content: space-between; 64 | min-width: 1000px; 65 | } 66 | } 67 | 68 | .cron-detail-info-item { 69 | flex: auto; 70 | 71 | .cron-detail-info-title { 72 | color: #888; 73 | } 74 | 75 | .cron-detail-info-value { 76 | margin-top: 12px; 77 | } 78 | } 79 | 80 | .crontab-title-wrapper { 81 | display: flex; 82 | align-items: center; 83 | justify-content: space-between; 84 | gap: 24px; 85 | 86 | .operations { 87 | display: flex; 88 | align-items: center; 89 | 90 | .ant-btn:not(:first-child) { 91 | margin-left: 8px; 92 | } 93 | } 94 | } 95 | } 96 | 97 | .log-item { 98 | cursor: pointer; 99 | &:hover { 100 | background: #f2f2f2; 101 | } 102 | } 103 | 104 | .crontab-view { 105 | .ant-tabs-nav-wrap { 106 | flex: unset !important; 107 | } 108 | 109 | .ant-tabs-nav-operations { 110 | position: absolute; 111 | visibility: hidden; 112 | pointer-events: none; 113 | } 114 | 115 | .view-more { 116 | margin-left: 32px; 117 | padding: 8px 0; 118 | cursor: pointer; 119 | 120 | .ant-tabs-ink-bar { 121 | width: 0; 122 | } 123 | 124 | &:hover, 125 | &:focus-visible { 126 | color: #1890ff; 127 | 128 | .ant-tabs-ink-bar { 129 | width: 50px; 130 | } 131 | } 132 | 133 | &.active { 134 | color: #1890ff; 135 | 136 | .ant-tabs-ink-bar { 137 | width: 50px; 138 | } 139 | } 140 | } 141 | 142 | &.more-active { 143 | .ant-tabs-nav-list { 144 | .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn { 145 | color: unset; 146 | } 147 | 148 | .ant-tabs-ink-bar { 149 | width: 0 !important; 150 | } 151 | } 152 | } 153 | } 154 | 155 | .view-create-modal-filters { 156 | display: flex; 157 | 158 | .ant-space-item:nth-child(3) { 159 | flex: 1; 160 | } 161 | } 162 | 163 | .view-create-modal-sorts { 164 | display: flex; 165 | 166 | .ant-space-item:nth-child(1) { 167 | flex: 1; 168 | } 169 | } 170 | 171 | tr.drop-over-downward td { 172 | border-bottom: 2px dashed #1890ff; 173 | } 174 | 175 | tr.drop-over-upward td { 176 | border-top: 2px dashed #1890ff; 177 | } 178 | 179 | .view-manage-modal { 180 | .ant-modal-body { 181 | padding-top: 10px; 182 | } 183 | } 184 | 185 | .view-filters-container.active { 186 | .filter-item > div > .ant-form-item-control { 187 | margin-left: 40px; 188 | width: calc(100% - 40px); 189 | } 190 | } 191 | 192 | body[data-mode='desktop'] { 193 | .crontab-wrapper { 194 | tbody .ant-table-cell { 195 | height: 69px !important; 196 | } 197 | } 198 | } 199 | 200 | .cron.pinned-cron > td { 201 | background: #f2f2f2; 202 | } 203 | -------------------------------------------------------------------------------- /src/pages/crontab/type.ts: -------------------------------------------------------------------------------- 1 | export enum CrontabStatus { 2 | 'running' = 0, 3 | 'queued' = 0.5, 4 | 'idle' = 1, 5 | 'disabled', 6 | } 7 | 8 | export enum OperationName { 9 | '启用', 10 | '禁用', 11 | '运行', 12 | '停止', 13 | '置顶', 14 | '取消置顶', 15 | } 16 | 17 | export enum OperationPath { 18 | 'enable', 19 | 'disable', 20 | 'run', 21 | 'stop', 22 | 'pin', 23 | 'unpin', 24 | } 25 | 26 | export interface ICrontab { 27 | name: string; 28 | command: string; 29 | schedule: string; 30 | id: number; 31 | status: number; 32 | isDisabled?: 1 | 0; 33 | isPinned?: 1 | 0; 34 | labels?: string[]; 35 | last_running_time?: number; 36 | last_execution_time?: number; 37 | nextRunTime: Date; 38 | sub_id: number; 39 | extra_schedules?: Array<{ schedule: string }>; 40 | } 41 | 42 | export enum ScheduleType { 43 | Normal = 'normal', 44 | Once = 'once', 45 | Boot = 'boot', 46 | } 47 | -------------------------------------------------------------------------------- /src/pages/dependence/index.less: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whyour/qinglong/47c194c1f4969f2cbe4a305bc4fa0fcb0dee8ea3/src/pages/dependence/index.less -------------------------------------------------------------------------------- /src/pages/dependence/modal.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Modal, message, Input, Form, Radio, Select } from 'antd'; 4 | import { request } from '@/utils/http'; 5 | import config from '@/utils/config'; 6 | 7 | const { Option } = Select; 8 | enum DependenceTypes { 9 | 'nodejs', 10 | 'python3', 11 | 'linux', 12 | } 13 | 14 | const DependenceModal = ({ 15 | dependence, 16 | handleCancel, 17 | defaultType, 18 | }: { 19 | dependence?: any; 20 | handleCancel: (cks?: any[]) => void; 21 | defaultType: string; 22 | }) => { 23 | const [form] = Form.useForm(); 24 | const [loading, setLoading] = useState(false); 25 | 26 | const handleOk = async (values: any) => { 27 | setLoading(true); 28 | const { name, split, type, remark } = values; 29 | const method = dependence ? 'put' : 'post'; 30 | let payload; 31 | if (!dependence) { 32 | if (split === '1') { 33 | const symbol = name.includes('&') ? '&' : '\n'; 34 | payload = name.split(symbol).map((x: any) => { 35 | return { 36 | name: x, 37 | type, 38 | remark, 39 | }; 40 | }); 41 | } else { 42 | payload = [{ name, type, remark }]; 43 | } 44 | } else { 45 | payload = { ...values, id: dependence.id }; 46 | } 47 | try { 48 | const { code, data } = await request[method]( 49 | `${config.apiPrefix}dependencies`, 50 | payload, 51 | ); 52 | 53 | if (code === 200) { 54 | handleCancel(data); 55 | } 56 | setLoading(false); 57 | } catch (error) { 58 | setLoading(false); 59 | } 60 | }; 61 | 62 | return ( 63 | { 70 | form 71 | .validateFields() 72 | .then((values) => { 73 | handleOk(values); 74 | }) 75 | .catch((info) => { 76 | console.log('Validate Failed:', info); 77 | }); 78 | }} 79 | onCancel={() => handleCancel()} 80 | confirmLoading={loading} 81 | > 82 |
88 | 93 | 100 | 101 | {!dependence && ( 102 | 108 | 109 | {intl.get('是')} 110 | {intl.get('否')} 111 | 112 | 113 | )} 114 | 125 | 130 | 131 | 132 | 133 | 134 |
135 |
136 | ); 137 | }; 138 | 139 | export default DependenceModal; 140 | -------------------------------------------------------------------------------- /src/pages/dependence/type.ts: -------------------------------------------------------------------------------- 1 | export enum DependenceStatus { 2 | 'installing', 3 | 'installed', 4 | 'installFailed', 5 | 'removing', 6 | 'removed', 7 | 'removeFailed', 8 | 'queued', 9 | 'cancelled', 10 | } 11 | 12 | export enum Status { 13 | '安装中', 14 | '已安装', 15 | '安装失败', 16 | '删除中', 17 | '已删除', 18 | '删除失败', 19 | '队列中', 20 | '已取消', 21 | } -------------------------------------------------------------------------------- /src/pages/diff/index.less: -------------------------------------------------------------------------------- 1 | .d2h-files-diff { 2 | height: calc(100vh - 130px); 3 | height: calc(100vh - var(--vh-offset, 0px) - 130px); 4 | overflow: auto; 5 | } 6 | 7 | .d2h-code-side-linenumber { 8 | position: relative; 9 | } 10 | 11 | .d2h-code-side-line { 12 | padding: 0 0.5em; 13 | } 14 | 15 | .diff-switch-file { 16 | min-width: 768px; 17 | .ant-form-item { 18 | margin-bottom: 8px; 19 | } 20 | 21 | + section { 22 | height: calc(100% - 40px) !important; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/pages/env/editNameModal.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal' 2 | import React, { useEffect, useState } from 'react'; 3 | import { Modal, message, Input, Form } from 'antd'; 4 | import { request } from '@/utils/http'; 5 | import config from '@/utils/config'; 6 | 7 | const EditNameModal = ({ 8 | ids, 9 | handleCancel, 10 | }: { 11 | ids?: string[]; 12 | handleCancel: () => void; 13 | }) => { 14 | const [form] = Form.useForm(); 15 | const [loading, setLoading] = useState(false); 16 | 17 | const handleOk = async (values: any) => { 18 | setLoading(true); 19 | try { 20 | const { code, data } = await request.put(`${config.apiPrefix}envs/name`, { 21 | ids, 22 | name: values.name, 23 | }); 24 | 25 | if (code === 200) { 26 | message.success(intl.get('更新环境变量名称成功')); 27 | handleCancel(); 28 | } 29 | setLoading(false); 30 | } catch (error) { 31 | setLoading(false); 32 | } 33 | }; 34 | 35 | return ( 36 | { 43 | form 44 | .validateFields() 45 | .then((values) => { 46 | handleOk(values); 47 | }) 48 | .catch((info) => { 49 | console.log('Validate Failed:', info); 50 | }); 51 | }} 52 | onCancel={() => handleCancel()} 53 | confirmLoading={loading} 54 | > 55 |
56 | 60 | 61 | 62 |
63 |
64 | ); 65 | }; 66 | 67 | export default EditNameModal; 68 | -------------------------------------------------------------------------------- /src/pages/env/index.less: -------------------------------------------------------------------------------- 1 | tr.drop-over-downward td { 2 | border-bottom: 2px dashed #1890ff; 3 | } 4 | 5 | tr.drop-over-upward td { 6 | border-top: 2px dashed #1890ff; 7 | } 8 | 9 | .text-ellipsis { 10 | text-overflow: ellipsis; 11 | white-space: nowrap; 12 | overflow: hidden; 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/env/modal.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Modal, message, Input, Form, Radio } from 'antd'; 4 | import { request } from '@/utils/http'; 5 | import config from '@/utils/config'; 6 | 7 | const EnvModal = ({ 8 | env, 9 | handleCancel, 10 | }: { 11 | env?: any; 12 | handleCancel: (cks?: any[]) => void; 13 | }) => { 14 | const [form] = Form.useForm(); 15 | const [loading, setLoading] = useState(false); 16 | 17 | const handleOk = async (values: any) => { 18 | setLoading(true); 19 | const { value, split, name, remarks } = values; 20 | const method = env ? 'put' : 'post'; 21 | let payload; 22 | if (!env) { 23 | if (split === '1') { 24 | const symbol = value.includes('&') ? '&' : '\n'; 25 | payload = value.split(symbol).map((x: any) => { 26 | return { 27 | name: name, 28 | value: x, 29 | remarks: remarks, 30 | }; 31 | }); 32 | } else { 33 | payload = [{ value, name, remarks }]; 34 | } 35 | } else { 36 | payload = { ...values, id: env.id }; 37 | } 38 | try { 39 | const { code, data } = await request[method]( 40 | `${config.apiPrefix}envs`, 41 | payload, 42 | ); 43 | 44 | if (code === 200) { 45 | message.success( 46 | env ? intl.get('更新变量成功') : intl.get('创建变量成功'), 47 | ); 48 | handleCancel(data); 49 | } 50 | setLoading(false); 51 | } catch (error: any) { 52 | setLoading(false); 53 | } 54 | }; 55 | 56 | return ( 57 | { 64 | form 65 | .validateFields() 66 | .then((values) => { 67 | handleOk(values); 68 | }) 69 | .catch((info) => { 70 | console.log('Validate Failed:', info); 71 | }); 72 | }} 73 | onCancel={() => handleCancel()} 74 | confirmLoading={loading} 75 | > 76 |
77 | 92 | 93 | 94 | {!env && ( 95 | 101 | 102 | {intl.get('是')} 103 | {intl.get('否')} 104 | 105 | 106 | )} 107 | 118 | 122 | 123 | 124 | 125 | 126 |
127 |
128 | ); 129 | }; 130 | 131 | export default EnvModal; 132 | -------------------------------------------------------------------------------- /src/pages/error/index.less: -------------------------------------------------------------------------------- 1 | .error-wrapper { 2 | display: flex; 3 | justify-content: center; 4 | height: 100vh; 5 | 6 | .react-terminal-wrapper { 7 | max-width: 90%; 8 | height: calc(100vh - 80px); 9 | overflow-y: auto; 10 | } 11 | 12 | .code-box { 13 | position: relative; 14 | display: inline-block; 15 | width: 80vw; 16 | height: 90vh; 17 | margin: 16px; 18 | background-color: #ffffff; 19 | border: 1px solid rgba(5, 5, 5, 0.06); 20 | border-radius: 6px; 21 | -webkit-transition: all 0.2s; 22 | transition: all 0.2s; 23 | border-radius: 6px 6px 0 0; 24 | color: rgba(0, 0, 0, 0.88); 25 | border-bottom: 1px solid rgba(5, 5, 5, 0.06); 26 | 27 | .browser-markup { 28 | position: relative; 29 | border-top: 2em solid rgba(230, 230, 230, 0.7); 30 | border-radius: 3px 3px 0 0; 31 | 32 | &::before { 33 | position: absolute; 34 | top: -1.25em; 35 | left: 1em; 36 | display: block; 37 | width: 0.5em; 38 | height: 0.5em; 39 | background-color: #f44; 40 | border-radius: 50%; 41 | box-shadow: 0 0 0 2px #f44, 1.5em 0 0 2px #9b3, 3em 0 0 2px #fb5; 42 | content: ''; 43 | } 44 | } 45 | 46 | .log { 47 | height: calc(90vh - 150px); 48 | overflow-y: auto; 49 | padding: 12px; 50 | white-space: pre-line; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/pages/error/index.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal'; 2 | import React, { useState, useEffect, useRef } from 'react'; 3 | import config from '@/utils/config'; 4 | import { request } from '@/utils/http'; 5 | import { PageLoading } from '@ant-design/pro-layout'; 6 | import { history, useOutletContext } from '@umijs/max'; 7 | import './index.less'; 8 | import { SharedContext } from '@/layouts'; 9 | import { Alert, Typography } from 'antd'; 10 | 11 | const Error = () => { 12 | const { user } = useOutletContext(); 13 | const [loading, setLoading] = useState(false); 14 | const [data, setData] = useState(intl.get('暂无日志')); 15 | const retryTimes = useRef(1); 16 | 17 | const loopStatus = (message: string) => { 18 | if (retryTimes.current > 3) { 19 | setData(message); 20 | return; 21 | } 22 | retryTimes.current += 1; 23 | setTimeout(() => { 24 | getHealthStatus(false); 25 | }, 3000); 26 | }; 27 | 28 | const getHealthStatus = (needLoading: boolean = true) => { 29 | needLoading && setLoading(true); 30 | request 31 | .get(`${config.apiPrefix}health`) 32 | .then(({ error, data }) => { 33 | if (data?.status === 'ok') { 34 | if (retryTimes.current > 1) { 35 | setTimeout(() => { 36 | window.location.reload(); 37 | }); 38 | } 39 | return; 40 | } 41 | 42 | loopStatus(error?.details); 43 | }) 44 | .catch((error) => { 45 | const responseStatus = error.response.status; 46 | if (responseStatus === 401) { 47 | history.push('/login'); 48 | } else { 49 | loopStatus(error.response?.message || error?.message); 50 | } 51 | }) 52 | .finally(() => needLoading && setLoading(false)); 53 | }; 54 | 55 | useEffect(() => { 56 | if (user && user.username) { 57 | history.push('/crontab'); 58 | } 59 | }, [user]); 60 | 61 | useEffect(() => { 62 | getHealthStatus(); 63 | }, []); 64 | 65 | return ( 66 |
67 | {loading ? ( 68 | 69 | ) : retryTimes.current > 3 ? ( 70 |
71 |
72 | 76 | {intl.get('服务启动超时')} 77 | 78 | } 79 | description={ 80 | 81 |
{intl.get('请先按如下方式修复:')}
82 |
83 | 1. 宿主机执行 docker run --rm -v 84 | /var/run/docker.sock:/var/run/docker.sock 85 | containrrr/watchtower -cR <容器名> 86 |
87 |
{intl.get('2. 容器内执行 ql check、ql update')}
88 |
89 | {intl.get( 90 | '3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果', 91 | )} 92 | 93 | {intl.get('提交 issue')} 94 | 95 |
96 |
97 | } 98 | banner 99 | /> 100 | 101 | {data} 102 | 103 |
104 | ) : ( 105 | 106 | )} 107 |
108 | ); 109 | }; 110 | 111 | export default Error; 112 | -------------------------------------------------------------------------------- /src/pages/initialization/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd/es/style/themes/default.less'; 2 | 3 | .container { 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | height: 100vh; 8 | height: calc(100vh - var(--vh-offset, 0px)); 9 | overflow: auto; 10 | background: @layout-body-background; 11 | padding-top: 70px; 12 | } 13 | 14 | @media (min-width: @screen-md-min) { 15 | .container { 16 | background-image: url('https://gw.alipayobjects.com/zos/rmsportal/TVYTbAXWheQpRcWDaDMu.svg'); 17 | background-repeat: no-repeat; 18 | background-position: center 110px; 19 | background-size: 100%; 20 | } 21 | } 22 | 23 | .top { 24 | text-align: center; 25 | } 26 | 27 | .header { 28 | display: flex; 29 | align-items: center; 30 | flex-direction: column; 31 | } 32 | 33 | .logo { 34 | width: 48px; 35 | display: block; 36 | margin-bottom: 24px; 37 | margin-top: 20px; 38 | } 39 | 40 | .title { 41 | font-size: 20px; 42 | margin-bottom: 16px; 43 | } 44 | 45 | .desc { 46 | margin-top: 12px; 47 | margin-bottom: 40px; 48 | color: @text-color-secondary; 49 | font-size: @font-size-base; 50 | } 51 | 52 | .main { 53 | padding: 20px; 54 | border-radius: 6px; 55 | background-color: #f6f8fa; 56 | border: 1px solid #ebedef; 57 | display: flex; 58 | max-width: 500px; 59 | width: 90%; 60 | height: 400px; 61 | 62 | .ant-steps { 63 | width: 35%; 64 | min-width: 110px; 65 | display: flex; 66 | align-items: center; 67 | position: relative; 68 | top: 6%; 69 | } 70 | .steps-container { 71 | flex: 1; 72 | overflow-y: auto; 73 | } 74 | } 75 | 76 | .extra { 77 | margin-top: 20px; 78 | } 79 | -------------------------------------------------------------------------------- /src/pages/log/index.module.less: -------------------------------------------------------------------------------- 1 | @import '~antd/es/style/themes/default.less'; 2 | @import '~@/styles/variable.less'; 3 | 4 | .left-tree { 5 | &-container { 6 | overflow: hidden; 7 | position: relative; 8 | background-color: @component-background; 9 | height: 100%; 10 | display: flex; 11 | flex-direction: column; 12 | } 13 | &-scroller { 14 | flex: 1; 15 | overflow: auto; 16 | padding-top: 6px; 17 | } 18 | } 19 | 20 | .log-container { 21 | display: flex; 22 | position: relative; 23 | } 24 | 25 | :global { 26 | .Pane.vertical.Pane1 { 27 | padding-right: 5px; 28 | border-right: 1px dashed @text-color; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/pages/login/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd/es/style/themes/default.less'; 2 | 3 | .container { 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | height: 100vh; 8 | height: calc(100vh - var(--vh-offset, 0px)); 9 | overflow: auto; 10 | background: @layout-body-background; 11 | padding-top: 70px; 12 | } 13 | 14 | @media (min-width: @screen-md-min) { 15 | .container { 16 | background-image: url('https://gw.alipayobjects.com/zos/rmsportal/TVYTbAXWheQpRcWDaDMu.svg'); 17 | background-repeat: no-repeat; 18 | background-position: center 110px; 19 | background-size: 100%; 20 | } 21 | } 22 | 23 | .top { 24 | text-align: center; 25 | } 26 | 27 | .header { 28 | display: flex; 29 | align-items: center; 30 | flex-direction: column; 31 | } 32 | 33 | .logo { 34 | width: 48px; 35 | height: 48px; 36 | display: block; 37 | margin-bottom: 24px; 38 | } 39 | 40 | .title { 41 | font-size: 20px; 42 | margin-bottom: 16px; 43 | } 44 | 45 | .desc { 46 | margin-top: 12px; 47 | margin-bottom: 40px; 48 | color: @text-color-secondary; 49 | font-size: @font-size-base; 50 | } 51 | 52 | .main { 53 | padding: 20px; 54 | border-radius: 6px; 55 | background-color: #f6f8fa; 56 | border: 1px solid #ebedef; 57 | width: 340px; 58 | 59 | @media screen and (max-width: @screen-sm) { 60 | width: 95%; 61 | max-width: 320px; 62 | } 63 | 64 | :global { 65 | .@{ant-prefix}-tabs-nav-list { 66 | margin: auto; 67 | font-size: 16px; 68 | } 69 | } 70 | 71 | .icon { 72 | margin-left: 16px; 73 | color: rgba(0, 0, 0, 0.2); 74 | font-size: 24px; 75 | vertical-align: middle; 76 | cursor: pointer; 77 | transition: color 0.3s; 78 | 79 | &:hover { 80 | color: @primary-color; 81 | } 82 | } 83 | 84 | .other { 85 | margin-top: 24px; 86 | line-height: 22px; 87 | text-align: left; 88 | .register { 89 | float: right; 90 | } 91 | } 92 | 93 | .prefixIcon { 94 | color: @primary-color; 95 | font-size: @font-size-base; 96 | } 97 | } 98 | 99 | .extra { 100 | margin-top: 20px; 101 | width: 340px; 102 | } 103 | -------------------------------------------------------------------------------- /src/pages/script/components/UnsupportedFilePreview/index.module.less: -------------------------------------------------------------------------------- 1 | .container { 2 | height: 100%; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | background: var(--background-color); 7 | padding: 16px; 8 | } 9 | 10 | .content { 11 | text-align: center; 12 | background: var(--card-background); 13 | padding: 24px; 14 | border-radius: 12px; 15 | max-width: 390px; 16 | width: 100%; 17 | transition: all 0.3s ease; 18 | } 19 | 20 | .iconWrapper { 21 | display: inline-flex; 22 | align-items: center; 23 | justify-content: center; 24 | width: 64px; 25 | height: 64px; 26 | border-radius: 50%; 27 | background: var(--background-color); 28 | margin-bottom: 16px; 29 | } 30 | 31 | .icon { 32 | font-size: 32px; 33 | color: var(--text-color-secondary); 34 | } 35 | 36 | .message { 37 | font-size: 16px; 38 | color: var(--text-color); 39 | margin-bottom: 16px; 40 | font-weight: 500; 41 | line-height: 1.5; 42 | } 43 | 44 | .actionArea { 45 | width: 100%; 46 | } 47 | 48 | .button { 49 | min-width: 140px; 50 | height: 36px; 51 | font-size: 14px; 52 | } 53 | 54 | .warning { 55 | font-size: 13px; 56 | color: var(--text-color-secondary); 57 | line-height: 1.5; 58 | display: flex; 59 | align-items: center; 60 | justify-content: center; 61 | gap: 6px; 62 | } 63 | 64 | .warningIcon { 65 | font-size: 14px; 66 | color: #faad14; 67 | } -------------------------------------------------------------------------------- /src/pages/script/components/UnsupportedFilePreview/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, Space } from 'antd'; 3 | import { FileUnknownOutlined, WarningOutlined } from '@ant-design/icons'; 4 | import intl from 'react-intl-universal'; 5 | import styles from './index.module.less'; 6 | 7 | interface UnsupportedFilePreviewProps { 8 | onForceOpen: () => void; 9 | } 10 | 11 | const UnsupportedFilePreview: React.FC = ({ 12 | onForceOpen, 13 | }) => { 14 | return ( 15 |
16 |
17 |
18 | 19 |
20 |
21 | {intl.get('当前文件不支持预览')} 22 |
23 | 24 | 31 |
32 | 33 | {intl.get('强制打开可能会导致编辑器显示异常')} 34 |
35 |
36 |
37 |
38 | ); 39 | }; 40 | 41 | export default UnsupportedFilePreview; -------------------------------------------------------------------------------- /src/pages/script/index.module.less: -------------------------------------------------------------------------------- 1 | @import '~antd/es/style/themes/default.less'; 2 | @import '~@/styles/variable.less'; 3 | 4 | .left-tree { 5 | &-container { 6 | overflow: hidden; 7 | position: relative; 8 | background-color: @component-background; 9 | height: 100%; 10 | display: flex; 11 | flex-direction: column; 12 | } 13 | &-scroller { 14 | flex: 1; 15 | overflow: auto; 16 | padding-top: 6px; 17 | } 18 | } 19 | 20 | .log-container { 21 | display: flex; 22 | position: relative; 23 | } 24 | 25 | :global { 26 | .Pane.vertical.Pane1 { 27 | padding-right: 5px; 28 | border-right: 1px dashed @text-color; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/pages/script/renameModal.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Modal, message, Input, Form } from 'antd'; 4 | import { request } from '@/utils/http'; 5 | import config from '@/utils/config'; 6 | 7 | const RenameModal = ({ 8 | currentNode, 9 | handleCancel, 10 | }: { 11 | currentNode?: any; 12 | handleCancel: () => void; 13 | }) => { 14 | const [form] = Form.useForm(); 15 | const [loading, setLoading] = useState(false); 16 | 17 | const handleOk = async (values: any) => { 18 | setLoading(true); 19 | try { 20 | const { code, data } = await request.put( 21 | `${config.apiPrefix}scripts/rename`, 22 | { 23 | filename: currentNode.title, 24 | path: currentNode.parent || '', 25 | newFilename: values.name, 26 | }, 27 | ); 28 | 29 | if (code === 200) { 30 | message.success(intl.get('更新名称成功')); 31 | handleCancel(); 32 | } 33 | setLoading(false); 34 | } catch (error) { 35 | setLoading(false); 36 | } 37 | }; 38 | 39 | return ( 40 | { 47 | form 48 | .validateFields() 49 | .then((values) => { 50 | handleOk(values); 51 | }) 52 | .catch((info) => { 53 | console.log('Validate Failed:', info); 54 | }); 55 | }} 56 | onCancel={() => handleCancel()} 57 | confirmLoading={loading} 58 | > 59 |
65 | 69 | 70 | 71 |
72 |
73 | ); 74 | }; 75 | 76 | export default RenameModal; 77 | -------------------------------------------------------------------------------- /src/pages/script/saveModal.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Modal, message, Input, Form } from 'antd'; 4 | import { request } from '@/utils/http'; 5 | import config from '@/utils/config'; 6 | 7 | const SaveModal = ({ 8 | file, 9 | handleCancel, 10 | }: { 11 | file?: any; 12 | handleCancel: (cks?: any[]) => void; 13 | }) => { 14 | const [form] = Form.useForm(); 15 | const [loading, setLoading] = useState(false); 16 | 17 | const handleOk = async (values: any) => { 18 | setLoading(true); 19 | const payload = { ...file, ...values, originFilename: file.title }; 20 | request 21 | .post(`${config.apiPrefix}scripts`, payload) 22 | .then(({ code, data }) => { 23 | if (code === 200) { 24 | message.success(intl.get('保存文件成功')); 25 | handleCancel(data); 26 | } 27 | }) 28 | .finally(() => { 29 | setLoading(false); 30 | }); 31 | }; 32 | 33 | return ( 34 | { 41 | form 42 | .validateFields() 43 | .then((values) => { 44 | handleOk(values); 45 | }) 46 | .catch((info) => { 47 | console.log('Validate Failed:', info); 48 | }); 49 | }} 50 | onCancel={() => handleCancel()} 51 | confirmLoading={loading} 52 | > 53 |
59 | 64 | 65 | 66 | 67 | 68 | 69 |
70 |
71 | ); 72 | }; 73 | 74 | export default SaveModal; 75 | -------------------------------------------------------------------------------- /src/pages/script/setting.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Modal, message, Input, Form } from 'antd'; 4 | import { request } from '@/utils/http'; 5 | import config from '@/utils/config'; 6 | 7 | const SettingModal = ({ 8 | file, 9 | handleCancel, 10 | }: { 11 | file?: any; 12 | handleCancel: (cks?: any[]) => void; 13 | }) => { 14 | const [form] = Form.useForm(); 15 | const [loading, setLoading] = useState(false); 16 | 17 | const handleOk = async (values: any) => { 18 | setLoading(true); 19 | const payload = { ...file, ...values }; 20 | request 21 | .post(`${config.apiPrefix}scripts`, payload) 22 | .then(({ code, data }) => { 23 | if (code === 200) { 24 | message.success(intl.get('保存文件成功')); 25 | handleCancel(data); 26 | } 27 | setLoading(false); 28 | }); 29 | }; 30 | 31 | return ( 32 | handleCancel()} 38 | > 39 |
45 | 50 | 51 | 52 |
53 |
54 | ); 55 | }; 56 | 57 | export default SettingModal; 58 | -------------------------------------------------------------------------------- /src/pages/setting/about.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Typography, Input, Form, Button, message, Descriptions } from 'antd'; 4 | import styles from './index.less'; 5 | import { SharedContext } from '@/layouts'; 6 | import dayjs from 'dayjs'; 7 | 8 | const { Link } = Typography; 9 | 10 | const About = ({ systemInfo }: { systemInfo: SharedContext['systemInfo'] }) => { 11 | return ( 12 |
13 | logo 18 |
19 | {intl.get('青龙')} 20 | 21 | {intl.get( 22 | '支持python3、javascript、shell、typescript 的定时任务管理面板', 23 | )} 24 | 25 | 26 | 27 | {systemInfo?.branch === 'develop' 28 | ? intl.get('开发版') 29 | : intl.get('正式版')}{' '} 30 | v{systemInfo.version} 31 | 32 | 33 | {dayjs(systemInfo.publishTime * 1000).format('YYYY-MM-DD HH:mm')} 34 | 35 | 36 | 40 | {intl.get('查看')} 41 | 42 | 43 | 44 |
45 | 50 | Github 51 | 52 | 57 | {intl.get('Telegram频道')} 58 | 59 | 63 | {intl.get('提交BUG')} 64 | 65 |
66 |
67 |
68 | ); 69 | }; 70 | 71 | export default About; 72 | -------------------------------------------------------------------------------- /src/pages/setting/appModal.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Modal, message, Input, Form, Select } from 'antd'; 4 | import { request } from '@/utils/http'; 5 | import config from '@/utils/config'; 6 | 7 | const AppModal = ({ 8 | app, 9 | handleCancel, 10 | }: { 11 | app?: any; 12 | handleCancel: (needUpdate?: boolean) => void; 13 | }) => { 14 | const [form] = Form.useForm(); 15 | const [loading, setLoading] = useState(false); 16 | 17 | const handleOk = async (values: any) => { 18 | setLoading(true); 19 | const method = app ? 'put' : 'post'; 20 | const payload = { ...values }; 21 | if (app) { 22 | payload.id = app.id; 23 | } 24 | try { 25 | const { code, data } = await request[method]( 26 | `${config.apiPrefix}apps`, 27 | payload, 28 | ); 29 | 30 | if (code === 200) { 31 | message.success( 32 | app ? intl.get('更新应用成功') : intl.get('创建应用成功'), 33 | ); 34 | handleCancel(data); 35 | } 36 | setLoading(false); 37 | } catch (error) { 38 | setLoading(false); 39 | } 40 | }; 41 | 42 | return ( 43 | { 50 | form 51 | .validateFields() 52 | .then((values) => { 53 | handleOk(values); 54 | }) 55 | .catch((info) => { 56 | console.log('Validate Failed:', info); 57 | }); 58 | }} 59 | onCancel={() => handleCancel()} 60 | confirmLoading={loading} 61 | > 62 |
68 | 74 | ['system'].includes(value) 75 | ? Promise.reject(new Error(intl.get('名称不能为保留关键字'))) 76 | : Promise.resolve(), 77 | }, 78 | ]} 79 | > 80 | 81 | 82 | 87 | 101 | 102 |
103 |
104 | ); 105 | }; 106 | 107 | export default AppModal; 108 | -------------------------------------------------------------------------------- /src/pages/setting/index.less: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | justify-content: center; 4 | flex-wrap: wrap; 5 | max-width: 800px; 6 | margin: 20px auto; 7 | 8 | .right { 9 | display: flex; 10 | justify-content: center; 11 | flex-direction: column; 12 | 13 | .title { 14 | font-size: 25px; 15 | margin-bottom: 10px; 16 | } 17 | 18 | .desc { 19 | margin-bottom: 10px; 20 | } 21 | 22 | :global { 23 | .ant-descriptions-row > th, 24 | .ant-descriptions-row > td { 25 | padding-bottom: 10px; 26 | } 27 | } 28 | } 29 | } 30 | 31 | .dependence-config-wrapper { 32 | display: flex; 33 | gap: 40px; 34 | } 35 | 36 | .ql-container-wrapper.ql-setting-container { 37 | .ant-tabs-tabpane > div { 38 | padding-left: 2px; 39 | } 40 | 41 | .ant-tabs-tabpane > .ant-form { 42 | padding-left: 2px; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/pages/setting/loginLog.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Typography, Table, Tag, Button, Spin, message } from 'antd'; 4 | import { request } from '@/utils/http'; 5 | import config from '@/utils/config'; 6 | import dayjs from 'dayjs'; 7 | 8 | const { Text, Link } = Typography; 9 | 10 | enum LoginStatus { 11 | '成功', 12 | '失败', 13 | } 14 | 15 | enum LoginStatusColor { 16 | 'success', 17 | 'error', 18 | } 19 | 20 | const columns = [ 21 | { 22 | title: intl.get('序号'), 23 | width: 50, 24 | render: (text: string, record: any, index: number) => { 25 | return index + 1; 26 | }, 27 | }, 28 | { 29 | title: intl.get('登录时间'), 30 | dataIndex: 'timestamp', 31 | key: 'timestamp', 32 | width: 120, 33 | render: (text: string, record: any) => { 34 | return dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss'); 35 | }, 36 | }, 37 | { 38 | title: intl.get('登录地址'), 39 | dataIndex: 'address', 40 | width: 120, 41 | key: 'address', 42 | }, 43 | { 44 | title: intl.get('登录IP'), 45 | dataIndex: 'ip', 46 | width: 100, 47 | key: 'ip', 48 | }, 49 | { 50 | title: intl.get('登录设备'), 51 | dataIndex: 'platform', 52 | key: 'platform', 53 | width: 80, 54 | }, 55 | { 56 | title: intl.get('登录状态'), 57 | dataIndex: 'status', 58 | key: 'status', 59 | width: 80, 60 | render: (text: string, record: any) => { 61 | return ( 62 | 63 | {intl.get(LoginStatus[record.status])} 64 | 65 | ); 66 | }, 67 | }, 68 | ]; 69 | 70 | const LoginLog = ({ 71 | data, 72 | height, 73 | }: { 74 | data: Array; 75 | height: number; 76 | }) => { 77 | return ( 78 | <> 79 | 87 | 88 | ); 89 | }; 90 | 91 | export default LoginLog; 92 | -------------------------------------------------------------------------------- /src/pages/setting/notification.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Typography, Input, Form, Button, Select, message } from 'antd'; 4 | import { request } from '@/utils/http'; 5 | import config from '@/utils/config'; 6 | 7 | const { Option } = Select; 8 | 9 | const NotificationSetting = ({ data }: any) => { 10 | const [loading, setLoading] = useState(false); 11 | const [notificationMode, setNotificationMode] = useState('closed'); 12 | const [fields, setFields] = useState([]); 13 | const [form] = Form.useForm(); 14 | 15 | const handleOk = (values: any) => { 16 | setLoading(true); 17 | const { type } = values; 18 | if (type == 'closed') { 19 | values.type = ''; 20 | } 21 | 22 | request 23 | .put(`${config.apiPrefix}user/notification`, values) 24 | .then(({ code, data }) => { 25 | if (code === 200) { 26 | message.success( 27 | values.type ? intl.get('通知发送成功') : intl.get('通知关闭成功'), 28 | ); 29 | } 30 | }) 31 | .catch((error: any) => { 32 | console.log(error); 33 | }) 34 | .finally(() => setLoading(false)); 35 | }; 36 | 37 | const notificationModeChange = (value: string) => { 38 | setNotificationMode(value); 39 | const _fields = (config.notificationModeMap as any)[value]; 40 | setFields(_fields || []); 41 | }; 42 | 43 | useEffect(() => { 44 | if (data && data.type) { 45 | notificationModeChange(data.type); 46 | form.setFieldsValue({ ...data }); 47 | } 48 | }, [data]); 49 | 50 | return ( 51 |
52 |
53 | 60 | 67 | 68 | {fields.map((x) => ( 69 | 77 | {x.items ? ( 78 | 88 | ) : ( 89 | 94 | )} 95 | 96 | ))} 97 | 100 | 101 |
102 | ); 103 | }; 104 | 105 | export default NotificationSetting; 106 | -------------------------------------------------------------------------------- /src/pages/setting/progress.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal'; 2 | import { Modal, Progress } from 'antd'; 3 | import { useRef } from 'react'; 4 | 5 | const ProgressElement = ({ percent }: { percent: number }) => ( 6 | 11 | ); 12 | 13 | export default function useProgress(title: string) { 14 | const modalRef = useRef | null>(); 15 | 16 | const showProgress = (percent: number) => { 17 | if (modalRef.current) { 18 | modalRef.current.update({ 19 | title: `${title}${ 20 | percent >= 100 ? intl.get('成功') : intl.get('中...') 21 | }`, 22 | content: , 23 | okButtonProps: { disabled: percent !== 100 }, 24 | }); 25 | if (percent === 100) { 26 | setTimeout(() => { 27 | modalRef.current?.destroy(); 28 | modalRef.current = null; 29 | }); 30 | } 31 | } else { 32 | modalRef.current = Modal.info({ 33 | width: 600, 34 | maskClosable: false, 35 | title: `${title}${ 36 | percent >= 100 ? intl.get('成功') : intl.get('中...') 37 | }`, 38 | centered: true, 39 | content: , 40 | okButtonProps: { disabled: true }, 41 | }); 42 | } 43 | }; 44 | 45 | return showProgress; 46 | } 47 | -------------------------------------------------------------------------------- /src/pages/subscription/index.less: -------------------------------------------------------------------------------- 1 | .inline-form-item { 2 | margin-bottom: 0; 3 | 4 | .ant-form-item { 5 | display: inline-block; 6 | width: 50%; 7 | margin-bottom: 0px; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/pages/subscription/logModal.tsx: -------------------------------------------------------------------------------- 1 | import intl from 'react-intl-universal'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Modal, message, Input, Form, Statistic, Button } from 'antd'; 4 | import { request } from '@/utils/http'; 5 | import config from '@/utils/config'; 6 | import { 7 | Loading3QuartersOutlined, 8 | CheckCircleOutlined, 9 | } from '@ant-design/icons'; 10 | import { PageLoading } from '@ant-design/pro-layout'; 11 | import { logEnded } from '@/utils'; 12 | import Ansi from 'ansi-to-react'; 13 | 14 | const SubscriptionLogModal = ({ 15 | subscription, 16 | handleCancel, 17 | data, 18 | logUrl, 19 | }: { 20 | subscription?: any; 21 | handleCancel: () => void; 22 | data?: string; 23 | logUrl?: string; 24 | }) => { 25 | const [value, setValue] = useState(intl.get('启动中...')); 26 | const [loading, setLoading] = useState(true); 27 | const [executing, setExecuting] = useState(true); 28 | const [isPhone, setIsPhone] = useState(false); 29 | 30 | const getCronLog = (isFirst?: boolean) => { 31 | if (isFirst) { 32 | setLoading(true); 33 | } 34 | request 35 | .get( 36 | logUrl 37 | ? logUrl 38 | : `${config.apiPrefix}subscriptions/${subscription.id}/log`, 39 | ) 40 | .then(({ code, data }) => { 41 | if ( 42 | code === 200 && 43 | localStorage.getItem('logSubscription') === String(subscription.id) 44 | ) { 45 | const log = data as string; 46 | setValue(log || intl.get('暂无日志')); 47 | setExecuting(log && !logEnded(log)); 48 | if (log && !logEnded(log)) { 49 | setTimeout(() => { 50 | getCronLog(); 51 | }, 2000); 52 | } 53 | } 54 | }) 55 | .finally(() => { 56 | if (isFirst) { 57 | setLoading(false); 58 | } 59 | }); 60 | }; 61 | 62 | const cancel = () => { 63 | localStorage.removeItem('logSubscription'); 64 | handleCancel(); 65 | }; 66 | 67 | const titleElement = () => { 68 | return ( 69 | <> 70 | {(executing || loading) && } 71 | {!executing && !loading && } 72 | 73 | {subscription && subscription.name} 74 | 75 | 76 | ); 77 | }; 78 | 79 | useEffect(() => { 80 | if (subscription && subscription.id) { 81 | getCronLog(true); 82 | } 83 | }, [subscription]); 84 | 85 | useEffect(() => { 86 | if (data) { 87 | setValue(data); 88 | } 89 | }, [data]); 90 | 91 | useEffect(() => { 92 | setIsPhone(document.body.clientWidth < 768); 93 | }, []); 94 | 95 | return ( 96 | cancel()} 103 | onCancel={() => cancel()} 104 | footer={[ 105 | , 108 | ]} 109 | > 110 |
111 | {loading ? ( 112 | 113 | ) : ( 114 |
124 |             {value}
125 |           
126 | )} 127 |
128 |
129 | ); 130 | }; 131 | 132 | export default SubscriptionLogModal; 133 | -------------------------------------------------------------------------------- /src/styles/variable.less: -------------------------------------------------------------------------------- 1 | @tree-width: 300px; 2 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import Intl from 'react-intl-universal'; 2 | 3 | export function diffTime(num: number) { 4 | const diff = num * 1000; 5 | 6 | const days = Math.floor(diff / (24 * 3600 * 1000)); 7 | 8 | const leave1 = diff % (24 * 3600 * 1000); 9 | const hours = Math.floor(leave1 / (3600 * 1000)); 10 | 11 | const leave2 = leave1 % (3600 * 1000); 12 | const minutes = Math.floor(leave2 / (60 * 1000)); 13 | 14 | const leave3 = leave2 % (60 * 1000); 15 | const seconds = Math.round(leave3 / 1000); 16 | 17 | let returnStr = `${seconds} ${Intl.get('秒')}`; 18 | if (minutes > 0) { 19 | returnStr = `${minutes} ${Intl.get('分')} ` + returnStr; 20 | } 21 | if (hours > 0) { 22 | returnStr = `${hours} ${Intl.get('时')} ` + returnStr; 23 | } 24 | if (days > 0) { 25 | returnStr = `${days} ${Intl.get('天')} ` + returnStr; 26 | } 27 | return returnStr; 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useMemo } from 'react'; 2 | import browserType from './index'; 3 | 4 | export const useCtx = () => { 5 | const [width, setWidth] = useState('100%'); 6 | const [marginLeft, setMarginLeft] = useState(0); 7 | const [marginTop, setMarginTop] = useState(-48); 8 | const [isPhone, setIsPhone] = useState(false); 9 | const { platform } = useMemo(() => browserType(), []); 10 | 11 | useEffect(() => { 12 | if (platform === 'mobile' && document.body.offsetWidth < 768) { 13 | setWidth('auto'); 14 | setMarginLeft(0); 15 | setMarginTop(0); 16 | setIsPhone(true); 17 | document.body.setAttribute('data-mode', 'phone'); 18 | } else { 19 | setWidth('100%'); 20 | setMarginLeft(0); 21 | setMarginTop(-48); 22 | setIsPhone(false); 23 | document.body.setAttribute('data-mode', 'desktop'); 24 | } 25 | }, []); 26 | 27 | return { 28 | headerStyle: { 29 | padding: '4px 16px 4px 15px', 30 | position: 'sticky', 31 | top: 0, 32 | left: 0, 33 | zIndex: 20, 34 | marginTop, 35 | width, 36 | marginLeft, 37 | } as any, 38 | isPhone, 39 | }; 40 | }; 41 | 42 | export const useTheme = () => { 43 | const [theme, setTheme] = useState<'vs' | 'vs-dark'>(); 44 | 45 | const reloadTheme = () => { 46 | const media = window.matchMedia('(prefers-color-scheme: dark)'); 47 | const storageTheme = localStorage.getItem('qinglong_dark_theme'); 48 | const isDark = 49 | (media.matches && storageTheme !== 'light') || storageTheme === 'dark'; 50 | setTheme(isDark ? 'vs-dark' : 'vs'); 51 | }; 52 | 53 | useEffect(() => { 54 | const media = window.matchMedia('(prefers-color-scheme: dark)'); 55 | const storageTheme = localStorage.getItem('qinglong_dark_theme'); 56 | const isDark = 57 | (media.matches && storageTheme !== 'light') || storageTheme === 'dark'; 58 | setTheme(isDark ? 'vs-dark' : 'vs'); 59 | 60 | const cb = (e: any) => { 61 | if (storageTheme === 'auto' || !storageTheme) { 62 | if (e.matches) { 63 | setTheme('vs-dark'); 64 | } else { 65 | setTheme('vs'); 66 | } 67 | } 68 | }; 69 | if (typeof media.addEventListener === 'function') { 70 | media.addEventListener('change', cb); 71 | } else if (typeof media.addListener === 'function') { 72 | media.addListener(cb); 73 | } 74 | }, []); 75 | 76 | return { theme, reloadTheme }; 77 | }; 78 | -------------------------------------------------------------------------------- /src/utils/init.ts: -------------------------------------------------------------------------------- 1 | import { loader } from '@monaco-editor/react'; 2 | import config from './config'; 3 | 4 | export function init(version: string) { 5 | // monaco 编辑器配置cdn和locale 6 | loader.config({ 7 | paths: { 8 | vs: `${config.baseUrl}monaco-editor/min/vs`, 9 | }, 10 | 'vs/nls': { 11 | availableLanguages: { 12 | '*': 'zh-cn', 13 | }, 14 | }, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/type.ts: -------------------------------------------------------------------------------- 1 | export type SockMessageType = 2 | | 'ping' 3 | | 'installDependence' 4 | | 'uninstallDependence' 5 | | 'updateSystemVersion' 6 | | 'manuallyRunScript' 7 | | 'runSubscriptionEnd' 8 | | 'reloadSystem' 9 | | 'updateNodeMirror' 10 | | 'updateLinuxMirror'; 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "importHelpers": true, 7 | "jsx": "react-jsx", 8 | "esModuleInterop": true, 9 | "sourceMap": true, 10 | "baseUrl": "./", 11 | "strict": true, 12 | "paths": { 13 | "@/*": ["src/*"], 14 | "@@/*": ["src/.umi/*"] 15 | }, 16 | "lib": ["dom", "es2021", "esnext.asynciterable"], 17 | "allowSyntheticDefaultImports": true, 18 | "experimentalDecorators": true, 19 | "emitDecoratorMetadata": true, 20 | "forceConsistentCasingInFileNames": true, 21 | "pretty": true, 22 | "allowJs": true, 23 | "noEmit": false 24 | }, 25 | "include": ["src/**/*", ".umirc.ts", "typings.d.ts"], 26 | "exclude": ["node_modules", "static", "data"] 27 | } 28 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | declare module '*.less'; 3 | declare module '*.png'; 4 | declare module '*.svg' { 5 | export function ReactComponent( 6 | props: React.SVGProps, 7 | ): React.ReactElement; 8 | const url: string; 9 | export default url; 10 | } 11 | 12 | interface Window { 13 | __ENV__QlBaseUrl: string; 14 | __ENV__DeployEnv: string; 15 | __ENV__QL_DIR: string; 16 | } 17 | -------------------------------------------------------------------------------- /version.yaml: -------------------------------------------------------------------------------- 1 | version: 2.19.1 2 | changeLogLink: https://t.me/jiao_long/430 3 | publishTime: 2025-05-24 16:00 4 | changeLog: | 5 | 1. 修复依赖是否安装检查逻辑 6 | 2. 修复文件下载 path 参数 7 | 3. 修复 python 查询逻辑 8 | 4. 修复任务视图状态筛选 9 | 5. 修复创建脚本可能失败 10 | 6. 修复重置用户名失败 11 | 7. 修复无法识别 python 依赖安装的命令 12 | 8. 其他缺陷修复 --------------------------------------------------------------------------------