Compare commits

...
54 Commits
Author SHA1 Message Date
whyour 33fa3aca99 更新版本 2.20.0 2025-12-11 01:53:17 +08:00
whyour c772fc9527 修复脚本调试保存文件错误 2025-12-11 01:52:47 +08:00
whyour c5d2aa3aba 更新 pipeline 2025-12-10 00:34:35 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
02a05f06bd Add signature verification support for Feishu bot notifications (#2856)
* Initial plan

* Add signature verification support for Feishu bot notifications

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add clarifying comments about Feishu signature algorithm

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add i18n translations for larkSecret configuration field

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-27 01:10:04 +08:00
whyour 3b0f55caf4 修复任务实例默认值 2025-11-23 12:45:02 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
6a3dd4f83c Fix null log_name issue by omitting it from shell command when not set (#2849)
* Initial plan

* Fix null log_name handling in runSingle method

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Update cron.log_name before makeCommand to avoid passing null to shell

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Fix: Only pass log_name to shell when it has a value

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Fix uniqPath calculation in runSingle for null log_name

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Improve comment clarity in makeCommand

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Refactor: Move no_tee and ID to initial commandVariable declaration

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Refactor: Simplify uniqPath ternary expression

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-22 12:06:01 +08:00
whyour 177cd3de81 更新 docker 日志 2025-11-22 01:05:28 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
d473c3ae88 Fix SSH global private key matching before subscription-specific keys (#2845)
* Initial plan

* Fix SSH global private key loading order by using zzz_ prefix

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Use tilde (~) prefix for global SSH config to ensure it loads last

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-21 01:53:58 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
ee2fbe5335 Add global SSH key configuration in system settings (#2840)
* Initial plan

* Add backend support for global SSH keys

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add frontend UI for global SSH keys management

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add SshKeyModel to database initialization

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add SSH config generation for global SSH keys

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add internationalization support for SSH key management UI

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Simplify to single global SSH key in system settings

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-20 10:09:01 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
48abf44ceb feat: Support multiple concurrent login sessions per platform (#2816)
* Initial plan

* Implement multi-device login support - allow multiple concurrent sessions

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Address code review feedback - extract constants and utility functions

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add validation and logging improvements based on code review

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Revert unnecessary file changes - keep only multi-device login feature files

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-19 00:18:29 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
03c7031a3c Fix task duplication: add single/multi-instance support with UI configuration and stop all running instances (#2837)
* Initial plan

* Stop running tasks before starting new scheduled instance

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add multi-instance support and fix stop to kill all running instances

- Add allow_multiple_instances field to Crontab model (default: 0 for single instance)
- Add validation for new field in commonCronSchema
- Add getAllPids and killAllTasks utility functions
- Update stop method to kill ALL running instances of a task
- Update runCron to respect allow_multiple_instances config
- Backward compatible: defaults to single instance mode

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add UI support for allow_multiple_instances configuration

- Add allow_multiple_instances field to ICrontab interface
- Add instance mode selector in task creation/edit modal
- Add translations for instance mode in Chinese and English
- Default to single instance mode for backward compatibility

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add allow_multiple_instances column migration and optimize db.ts

- Add allow_multiple_instances column to Crontabs table migration
- Refactor migration code to use data-driven approach
- Replace 11 individual try-catch blocks with single loop
- Improve code maintainability and readability

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-19 00:10:27 +08:00
whyour 0e5de4a824 更新启动日志 2025-11-16 21:31:52 +08:00
whyour af96bd98ac 修复系统提示 2025-11-16 21:26:14 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
08ef509e27 Optimize log file writes using stream pooling (#2835)
* Initial plan

* Implement LogStreamManager for optimized log writing

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Fix error handler in LogStreamManager to avoid race conditions

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-16 21:11:10 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
fbeb4f4a6c Fix PM2 startup failures on ARM routers with Node.js incompatibility (#2828)
* Initial plan

* Add fallback mechanism for PM2 startup failures on incompatible hardware

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-16 20:29:32 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
eb09a417a1 Fix DNS resolution failures in Alpine containers by setting ndots:0 (#2827)
* Initial plan

* Fix DNS resolution issue in Alpine containers by adding ndots:0 option

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-16 20:28:12 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
e84ddb6cfc Fix environment variable copy reliability by validating clipboard operation (#2833)
* Initial plan

* Fix env variable copy issue by using onCopy callback

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-16 18:23:45 +08:00
whyour d01ec3b310 修复 parse cron 2025-11-15 19:09:07 +08:00
whyour 45fac29804 移除用户判断 2025-11-15 17:02:00 +08:00
whyour 72d4c8bfd4 修复 homedir 权限 2025-11-15 02:27:27 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>whyour
8f23c61fee Add non-root Docker user support with automatic command setup (#2830)
* Initial plan

* Add non-root user guide and improve error messages

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add PM2_HOME configuration to fix non-root user permission errors

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Fix PM2 socket error by using /tmp for PM2_HOME instead of mounted volume

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Document symlink permission limitation for non-root users

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Clarify that scheduled tasks auto-adapt to use full paths when symlinks unavailable

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add error handling for symlink creation to prevent worker crashes

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add practical solutions for non-root users to use ql and task commands

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Modify linkCommand to create symlinks in ~/bin for non-root users

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* 修复链接 task 命令

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
Co-authored-by: whyour <imwhyour@gmail.com>
2025-11-15 01:46:24 +08:00
whyour e41eed81ae 移除说明文档 2025-11-14 23:23:01 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
58f5fe763f Add cron task management to QLAPI (#2826)
* Initial plan

* Add cron management capabilities to QLAPI

- Added GetCrons, GetCronById, EnableCrons, DisableCrons, RunCrons to proto
- Implemented gRPC handlers for new cron operations
- Updated client.js to expose new methods to scripts
- Updated sample to demonstrate new API usage

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add documentation for QLAPI cron management features

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Specify pnpm version 8.3.1 in package.json

Add packageManager field to enforce pnpm@8.3.1 as requested

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Revert pnpm-lock.yaml to original state

The lockfile was inadvertently updated during dependency installation
for proto generation. Reverting to maintain consistency with pnpm@8.3.1.

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Regenerate proto files with correct tool versions

Regenerated using protoc v3.17.3 and protoc-gen-ts_proto v2.6.1
to match the original project configuration.

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-14 23:20:56 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
214241797d Fix QlPort and QlGrpcPort environment variables ignored in host network mode and pm2 reload (#2825)
* Initial plan

* Fix host mode port configuration by using QlPort environment variable

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Fix GRPC_PORT conflict in host network mode

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Ensure BACK_PORT and GRPC_PORT survive pm2 reload with --update-env

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Move env.sh sourcing after fix_config to preserve more environment variables

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Refactor: Extract export_ql_envs function and move env.sh sourcing earlier

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Extract load_ql_envs function and reorder initialization in docker-entrypoint.sh and update.sh

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-14 22:56:39 +08:00
whyour aedd48c9c4 修改错误日志 2025-11-14 22:23:30 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
90ddf0fb57 Fix race condition preventing scheduled tasks from executing in clustered mode (#2819)
* Initial plan

* Fix race condition causing scheduled tasks not to run

Added synchronization to ensure gRPC worker is ready before HTTP worker starts. This prevents the race condition where autosave_crontab() tries to register cron jobs before the gRPC server is ready to accept them.

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add timeout protection for gRPC worker initialization

Added 30-second timeout to prevent system hang if gRPC worker fails to start. This provides better error handling and prevents indefinite waiting.

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Refactor worker ready logic and improve restart handling

- Extracted waitForWorkerReady() method for better code reusability
- Improved worker restart logic to wait for gRPC worker readiness
- This addresses code review feedback for better maintainability

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Improve logging accuracy for worker restarts

- Fixed log messages to accurately reflect worker state
- Added proper logging after gRPC worker is confirmed ready
- Improved HTTP worker restart logging with PID
- Addresses code review feedback for better clarity

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Revert unnecessary pnpm-lock.yaml changes

The pnpm-lock.yaml was unintentionally updated when installing dependencies for testing. No package dependencies were actually changed - only existing code was modified in back/app.ts. Reverting to original state.

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Re-register cron jobs when gRPC worker restarts

When the gRPC worker restarts, the scheduled tasks need to be re-added to the new gRPC server instance. This fix:

1. Tracks the HTTP worker reference in the master process
2. Sends a 'reregister-crons' message to the HTTP worker after gRPC restarts
3. HTTP worker calls autosave_crontab() to re-register all cron jobs with the new gRPC server

This ensures scheduled tasks continue to work after a gRPC worker restart.

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-12 00:59:22 +08:00
whyour c9fc9b4b45 定时任务支持订阅筛选 2025-11-11 01:24:28 +08:00
whyour 8fdc69421c 修改定时任务支持的排序顺序 2025-11-11 01:02:36 +08:00
whyour 1deb264913 升级 cron-parser 2025-11-11 00:37:03 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1f2fd8ac02 Fix URIError from malformed cookies causing white screen on load (#2811)
* Initial plan

* Fix decodeURIComponent error in cookie parsing by adding try-catch

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add type annotation and logging to catch block per code review

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Fix TypeScript errors in deps.ts - remove unused path parameter

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Revert pnpm-lock.yaml to avoid unnecessary lockfile version upgrade

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-09 21:53:06 +08:00
whyour 06aa07329f 修复日志目录逻辑 2025-11-09 21:42:45 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>whyour
4cb9f57479 环境变量支持置顶 (#2822)
* Initial plan

* Add pin to top feature for environment variables

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Format code with prettier

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add database migration for isPinned column in Envs table

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Use snake_case naming (is_pinned) for database column

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
Co-authored-by: whyour <imwhyour@gmail.com>
2025-11-09 19:43:33 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
c369514741 定时任务支持自定义日志文件或者 /dev/null (#2823)
* Initial plan

* Add log_name field to enable custom log folder naming

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add database migration for log_name column

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add security validation to prevent path traversal attacks

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Apply prettier formatting to modified files

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Support absolute paths like /dev/null for log redirection

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Restrict absolute paths to log directory except /dev/null

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-09 19:32:40 +08:00
whyour 0e28e1b6c4 修复 pm2 日志目录 2025-11-09 17:52:38 +08:00
whyour 73f8f3c5fa 增加 agent
Create a new agent configuration file for QL agent.
2025-11-08 00:05:56 +08:00
whyour 494ccb3c08 更新 workflow 2025-11-03 00:09:36 +08:00
whyour 399728b433 修复 jwt 认证 2025-11-02 22:28:58 +08:00
whyour 52a1de5063 更新 demo 地址 2025-11-02 21:54:19 +08:00
whyour 18f27a9a69 移除 nginx 2025-11-02 19:29:59 +08:00
whyour 07951964a1 修复模块注入 2025-10-26 22:32:03 +08:00
涛之雨andGitHub a1f888af59 Add validation to dependencies GET endpoint and update service logic (#2778)
* Add validation to dependencies GET endpoint and update service logic

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

* remove default condition type

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

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

* Fix optional path compatibility checks

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

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

* fix:ntfy动作包含中文报错
2025-06-07 00:26:27 +08:00
whyour 394e96bbf8 修复 health 接口报错 2025-06-07 00:25:47 +08:00
85 changed files with 4606 additions and 1072 deletions
+1 -2
View File
@@ -1,6 +1,5 @@
GRPC_PORT=5500
BACK_PORT=5600
PORT=5700
BACK_PORT=5700
LOG_LEVEL='info'
+4
View File
@@ -0,0 +1,4 @@
---
name: Bug Fixer
description: Fix this issue following our error handling pattern.
---
@@ -20,12 +20,14 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wearerequired/git-mirror-action@v1
env:
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
- uses: Yikun/hub-mirror-action@master
with:
source-repo: https://github.com/whyour/qinglong.git
destination-repo: git@gitlab.com:whyour/qinglong.git
src: github/whyour
dst: gitlab/whyour
dst_key: ${{ secrets.GITLAB_SSH_PK }}
dst_token: ${{ secrets.GITLAB_TOKEN }}
static_list: "qinglong"
force_update: true
code_gitee:
runs-on: ubuntu-latest
@@ -33,12 +35,14 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wearerequired/git-mirror-action@v1
env:
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
- uses: Yikun/hub-mirror-action@master
with:
source-repo: https://github.com/whyour/qinglong.git
destination-repo: git@gitee.com:whyour/qinglong.git
src: github/whyour
dst: gitee/whyour
dst_key: ${{ secrets.GITLAB_SSH_PK }}
dst_token: ${{ secrets.GITEE_TOKEN }}
static_list: "qinglong"
force_update: true
build-static:
runs-on: ubuntu-latest
@@ -82,12 +86,14 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wearerequired/git-mirror-action@v1
env:
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
- uses: Yikun/hub-mirror-action@master
with:
source-repo: https://github.com/whyour/qinglong-static.git
destination-repo: git@gitlab.com:whyour/qinglong-static.git
src: github/whyour
dst: gitlab/whyour
dst_key: ${{ secrets.GITLAB_SSH_PK }}
dst_token: ${{ secrets.GITLAB_TOKEN }}
static_list: "qinglong-static"
force_update: true
static_gitee:
needs: build-static
@@ -96,12 +102,14 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wearerequired/git-mirror-action@v1
env:
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
- uses: Yikun/hub-mirror-action@master
with:
source-repo: https://github.com/whyour/qinglong-static.git
destination-repo: git@gitee.com:whyour/qinglong-static.git
src: github/whyour
dst: gitee/whyour
dst_key: ${{ secrets.GITLAB_SSH_PK }}
dst_token: ${{ secrets.GITEE_TOKEN }}
static_list: "qinglong-static"
force_update: true
build:
needs: build-static
@@ -121,6 +129,13 @@ jobs:
with:
cache: "pnpm"
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Setup timezone
uses: szenius/set-timezone@v2.0
with:
@@ -146,19 +161,13 @@ jobs:
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
# generate Docker tags based on the following events/attributes
# nightly, master, pr-2, 1.2.3, 1.2, 1
flavor: |
latest=false
tags: |
type=schedule,pattern=nightly
type=edge
type=ref,event=pr
type=ref,event=branch,enable=${{ github.ref != format('refs/heads/{0}', 'master') }}
type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -208,6 +217,13 @@ jobs:
with:
cache: "pnpm"
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Setup timezone
uses: szenius/set-timezone@v2.0
with:
@@ -246,7 +262,9 @@ jobs:
context: .
file: ./docker/310.Dockerfile
push: true
tags: whyour/qinglong:python3.10
tags: |
whyour/qinglong:python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max
+1 -1
View File
@@ -17,7 +17,7 @@ export default defineConfig({
publicPath: process.env.NODE_ENV === 'production' ? './' : '/',
proxy: {
[`${baseUrl}api`]: {
target: 'http://127.0.0.1:5600/',
target: 'http://127.0.0.1:5700/',
changeOrigin: true,
ws: true,
pathRewrite: { [`^${baseUrl}api`]: '/api' },
+4 -2
View File
@@ -18,9 +18,9 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
[Demo](http://demo.ninesix.cc:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
[Demo](http://demo.qinglong.online:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
[演示](http://demo.ninesix.cc:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
[演示](http://demo.qinglong.online:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
</div>
![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png)
@@ -41,6 +41,8 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image.
**⚠️ Important**: If you need to run Docker as a **non-root user**, please use the `debian` image. Alpine's `crond` requires root privileges.
```bash
docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian
+4 -2
View File
@@ -20,9 +20,9 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
[Demo](http://demo.ninesix.cc:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
[Demo](http://demo.qinglong.online:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
[演示](http://demo.ninesix.cc:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
[演示](http://demo.qinglong.online:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
</div>
![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png)
@@ -43,6 +43,8 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
`latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像
**⚠️ 重要提示**: 如果您需要以**非 root 用户**运行 Docker,请使用 `debian` 镜像。Alpine 的 `crond` 需要 root 权限。
```bash
docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian
+22 -11
View File
@@ -8,17 +8,28 @@ const route = Router();
export default (app: Router) => {
app.use('/dependencies', route);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.dependencies(req.query as any);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
route.get(
'/',
celebrate({
query:
Joi.object({
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.dependencies(req.query as any);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.post(
'/',
+41 -7
View File
@@ -1,12 +1,12 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import EnvService from '../services/env';
import { Logger } from 'winston';
import { celebrate, Joi } from 'celebrate';
import multer from 'multer';
import config from '../config';
import { Joi, celebrate } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express';
import fs from 'fs';
import multer from 'multer';
import { Container } from 'typedi';
import { Logger } from 'winston';
import config from '../config';
import { safeJSONParse } from '../config/util';
import EnvService from '../services/env';
const route = Router();
const storage = multer.diskStorage({
@@ -196,6 +196,40 @@ export default (app: Router) => {
},
);
route.put(
'/pin',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.pin(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/unpin',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.unPin(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.post(
'/upload',
upload.single('env'),
+88 -45
View File
@@ -24,55 +24,68 @@ const upload = multer({ storage: storage });
export default (app: Router) => {
app.use('/scripts', route);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let result: IFile[] = [];
const blacklist = [
'node_modules',
'.git',
'.pnpm',
'pnpm-lock.yaml',
'yarn.lock',
'package-lock.json',
];
if (req.query.path) {
result = await readDir(
req.query.path as string,
config.scriptPath,
blacklist,
);
} else {
result = await readDirs(
config.scriptPath,
config.scriptPath,
blacklist,
(a, b) => {
if (a.type === b.type) {
return a.title.localeCompare(b.title);
} else {
return a.type === 'directory' ? -1 : 1;
}
},
);
route.get(
'/',
celebrate({
query: Joi.object({
path: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let result: IFile[] = [];
const blacklist = [
'node_modules',
'.git',
'.pnpm',
'pnpm-lock.yaml',
'yarn.lock',
'package-lock.json',
];
if (req.query.path) {
result = await readDir(
req.query.path as string,
config.scriptPath,
blacklist,
);
} else {
result = await readDirs(
config.scriptPath,
config.scriptPath,
blacklist,
(a, b) => {
if (a.type === b.type) {
return a.title.localeCompare(b.title);
} else {
return a.type === 'directory' ? -1 : 1;
}
},
);
}
res.send({
code: 200,
data: result,
});
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
res.send({
code: 200,
data: result,
});
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
});
route.get(
'/detail',
celebrate({
query: Joi.object({
path: Joi.string().optional().allow(''),
file: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scriptService = Container.get(ScriptService);
const content = await scriptService.getFile(
req.query.path as string,
req.query?.path as string || '',
req.query.file as string,
);
res.send({ code: 200, data: content });
@@ -84,11 +97,19 @@ export default (app: Router) => {
route.get(
'/:file',
celebrate({
params: Joi.object({
file: Joi.string().required(),
}),
query: Joi.object({
path: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scriptService = Container.get(ScriptService);
const content = await scriptService.getFile(
req.query.path as string,
req.query?.path as string || '',
req.params.file,
);
res.send({ code: 200, data: content });
@@ -101,6 +122,16 @@ export default (app: Router) => {
route.post(
'/',
upload.single('file'),
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
content: Joi.string().optional().allow(''),
originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path, content, originFilename, directory } =
@@ -145,6 +176,7 @@ export default (app: Router) => {
path,
`${originFilename.replace(/\//g, '')}`,
);
await fs.mkdir(path, { recursive: true });
const filePath = join(path, `${filename.replace(/\//g, '')}`);
const fileExists = await fileExist(filePath);
if (fileExists) {
@@ -201,7 +233,7 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().allow(''),
path: Joi.string().optional().allow(''),
type: Joi.string().optional(),
}),
}),
@@ -211,6 +243,9 @@ export default (app: Router) => {
filename: string;
path: string;
};
if (!path) {
path = '';
}
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
@@ -276,6 +311,9 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
let { filename, content, path } = req.body;
if (!path) {
path = '';
}
const { name, ext } = parse(filename);
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
await writeFileWithLock(filePath, content || '');
@@ -301,6 +339,9 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path, pid } = req.body;
if (!path) {
path = '';
}
const { name, ext } = parse(filename);
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
const logPath = join(config.logPath, path, `${name}.swap`);
@@ -328,12 +369,14 @@ export default (app: Router) => {
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path, type, newFilename } = req.body as {
let { filename, path, newFilename } = req.body as {
filename: string;
path: string;
type: string;
newFilename: string;
};
if (!path) {
path = '';
}
const filePath = join(config.scriptPath, path, filename);
const newPath = join(config.scriptPath, path, newFilename);
await fs.rename(filePath, newPath);
+3 -3
View File
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston';
import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate';
import cron_parser from 'cron-parser';
import { CronExpressionParser } from 'cron-parser';
const route = Router();
export default (app: Router) => {
@@ -60,7 +60,7 @@ export default (app: Router) => {
try {
if (
!req.body.schedule ||
cron_parser.parseExpression(req.body.schedule).hasNext()
CronExpressionParser.parse(req.body.schedule).hasNext()
) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.create(req.body);
@@ -193,7 +193,7 @@ export default (app: Router) => {
if (
!req.body.schedule ||
typeof req.body.schedule === 'object' ||
cron_parser.parseExpression(req.body.schedule).hasNext()
CronExpressionParser.parse(req.body.schedule).hasNext()
) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.update(req.body);
+50 -5
View File
@@ -14,6 +14,7 @@ import {
} from '../config/util';
import dayjs from 'dayjs';
import multer from 'multer';
import { logStreamManager } from '../shared/logStreamManager';
const route = Router();
const storage = multer.diskStorage({
@@ -273,19 +274,22 @@ export default (app: Router) => {
{
onStart: async (cp, startTime) => {
res.setHeader('QL-Task-Pid', `${cp.pid}`);
res.setHeader('QL-Task-Log', `${logPath}`);
},
onEnd: async (cp, endTime, diff) => {
// Close the stream after task completion
await logStreamManager.closeStream(await handleLogPath(logPath));
res.end();
},
onError: async (message: string) => {
res.write(`\n${message}`);
res.write(message);
const absolutePath = await handleLogPath(logPath);
await fs.appendFile(absolutePath, `\n${message}`);
await logStreamManager.write(absolutePath, message);
},
onLog: async (message: string) => {
res.write(`\n${message}`);
res.write(message);
const absolutePath = await handleLogPath(logPath);
await fs.appendFile(absolutePath, `\n${message}`);
await logStreamManager.write(absolutePath, message);
},
},
);
@@ -316,10 +320,15 @@ export default (app: Router) => {
route.put(
'/data/export',
celebrate({
body: Joi.object({
type: Joi.array().items(Joi.string()).optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
await systemService.exportData(res);
await systemService.exportData(res, req.body.type);
} catch (e) {
return next(e);
}
@@ -416,4 +425,40 @@ export default (app: Router) => {
}
},
);
route.put(
'/config/global-ssh-key',
celebrate({
body: Joi.object({
globalSshKey: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateGlobalSshKey(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/dependence-clean',
celebrate({
body: Joi.object({
type: Joi.string().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.cleanDependence(req.body.type);
res.send(result);
} catch (e) {
return next(e);
}
},
);
};
+3 -2
View File
@@ -8,7 +8,7 @@ import path from 'path';
import { v4 as uuidV4 } from 'uuid';
import rateLimit from 'express-rate-limit';
import config from '../config';
import { isDemoEnv } from '../config/util';
import { isDemoEnv, getToken } from '../config/util';
const route = Router();
const storage = multer.diskStorage({
@@ -56,7 +56,8 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
await userService.logout(req.platform);
const token = getToken(req);
await userService.logout(req.platform, token);
res.send({ code: 200 });
} catch (e) {
return next(e);
+244 -34
View File
@@ -1,4 +1,5 @@
import 'reflect-metadata';
import cluster, { type Worker } from 'cluster';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
@@ -10,76 +11,195 @@ import { monitoringMiddleware } from './middlewares/monitoring';
import { type GrpcServerService } from './services/grpc';
import { type HttpServerService } from './services/http';
interface WorkerMetadata {
id: number;
pid: number;
serviceType: string;
startTime: Date;
}
class Application {
private app: express.Application;
private httpServerService?: HttpServerService;
private grpcServerService?: GrpcServerService;
private isShuttingDown = false;
private workerMetadataMap = new Map<number, WorkerMetadata>();
private httpWorker?: Worker;
constructor() {
this.app = express();
// 创建一个全局中间件,删除查询参数中的t
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.query.t) {
delete req.query.t;
}
next();
});
}
async start() {
try {
await this.initializeDatabase();
await this.initServer();
this.setupMiddlewares();
await this.initializeServices();
this.setupGracefulShutdown();
process.send?.('ready');
if (cluster.isPrimary) {
await this.initializeDatabase();
}
if (cluster.isPrimary) {
this.startMasterProcess();
} else {
await this.startWorkerProcess();
}
} catch (error) {
Logger.error('Failed to start application:', error);
process.exit(1);
}
}
async initServer() {
const { HttpServerService } = await import('./services/http');
const { GrpcServerService } = await import('./services/grpc');
this.httpServerService = Container.get(HttpServerService);
this.grpcServerService = Container.get(GrpcServerService);
private startMasterProcess() {
// Fork gRPC worker first and wait for it to be ready
const grpcWorker = this.forkWorker('grpc');
// Wait for gRPC worker to signal it's ready before starting HTTP worker
this.waitForWorkerReady(grpcWorker, 30000)
.then(() => {
Logger.info('✌️ gRPC worker is ready, starting HTTP worker');
this.httpWorker = this.forkWorker('http');
})
.catch((error) => {
Logger.error('✌️ Failed to wait for gRPC worker:', error);
process.exit(1);
});
cluster.on('exit', (worker, code, signal) => {
const metadata = this.workerMetadataMap.get(worker.id);
if (metadata) {
if (!this.isShuttingDown) {
Logger.error(
`✌️ ${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
}). Restarting...`,
);
// If gRPC worker died, restart it and wait for it to be ready
if (metadata.serviceType === 'grpc') {
const newGrpcWorker = this.forkWorker('grpc');
this.waitForWorkerReady(newGrpcWorker, 30000)
.then(() => {
Logger.info('✌️ gRPC worker restarted and ready');
// Re-register cron jobs by notifying the HTTP worker
if (this.httpWorker) {
try {
this.httpWorker.send('reregister-crons');
Logger.info('✌️ Sent reregister-crons message to HTTP worker');
} catch (error) {
Logger.error('✌️ Failed to send reregister-crons message:', error);
}
}
})
.catch((error) => {
Logger.error('✌️ Failed to restart gRPC worker:', error);
process.exit(1);
});
} else {
// For HTTP worker, just restart it
const newWorker = this.forkWorker(metadata.serviceType);
this.httpWorker = newWorker;
Logger.info(`✌️ Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`);
}
}
this.workerMetadataMap.delete(worker.id);
}
});
this.setupMasterShutdown();
}
private waitForWorkerReady(worker: Worker, timeoutMs: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const messageHandler = (msg: any) => {
if (msg === 'ready') {
worker.removeListener('message', messageHandler);
clearTimeout(timeoutId);
resolve();
}
};
worker.on('message', messageHandler);
// Timeout after specified milliseconds
const timeoutId = setTimeout(() => {
worker.removeListener('message', messageHandler);
reject(new Error(`Worker failed to start within ${timeoutMs / 1000} seconds`));
}, timeoutMs);
});
}
private forkWorker(serviceType: string): Worker {
const worker = cluster.fork({ SERVICE_TYPE: serviceType });
this.workerMetadataMap.set(worker.id, {
id: worker.id,
pid: worker.process.pid!,
serviceType,
startTime: new Date(),
});
return worker;
}
private async initializeDatabase() {
await require('./loaders/db').default();
const dbLoader = await import('./loaders/db');
await dbLoader.default();
}
private setupMiddlewares() {
this.app.use(helmet());
this.app.use(helmet({
contentSecurityPolicy: false,
}));
this.app.use(cors(config.cors));
this.app.use(compression());
this.app.use(monitoringMiddleware);
}
private async initializeServices() {
await this.grpcServerService?.initialize();
await require('./loaders/app').default({ app: this.app });
const server = await this.httpServerService?.initialize(
this.app,
config.port,
);
await require('./loaders/server').default({ server });
}
private setupGracefulShutdown() {
private setupMasterShutdown() {
const shutdown = async () => {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
Logger.info('Shutting down services...');
const workers = Object.values(cluster.workers || {});
const workerPromises: Promise<void>[] = [];
workers.forEach((worker) => {
if (worker) {
const exitPromise = new Promise<void>((resolve) => {
worker.once('exit', () => {
Logger.info(`✌️ Worker ${worker.process.pid} exited`);
resolve();
});
try {
worker.send('shutdown');
} catch (error) {
Logger.warn(
`✌️ Failed to send shutdown to worker ${worker.process.pid}:`,
error,
);
}
});
workerPromises.push(exitPromise);
}
});
try {
await Promise.all([
this.grpcServerService?.shutdown(),
this.httpServerService?.shutdown(),
await Promise.race([
Promise.all(workerPromises),
new Promise<void>((resolve) => {
setTimeout(() => {
Logger.warn('✌️ Worker shutdown timeout reached');
resolve();
}, 10000);
}),
]);
process.exit(0);
} catch (error) {
Logger.error('Error during shutdown:', error);
Logger.error('✌️ Error during worker shutdown:', error);
process.exit(1);
}
};
@@ -87,10 +207,100 @@ class Application {
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
private async startWorkerProcess() {
const serviceType = process.env.SERVICE_TYPE;
if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
Logger.error('✌️ Invalid SERVICE_TYPE:', serviceType);
process.exit(1);
}
Logger.info(`✌️ ${serviceType} worker started (PID: ${process.pid})`);
try {
if (serviceType === 'http') {
await this.startHttpService();
} else {
await this.startGrpcService();
}
process.send?.('ready');
} catch (error) {
Logger.error(`✌️ ${serviceType} worker failed:`, error);
process.exit(1);
}
}
private async startHttpService() {
this.setupMiddlewares();
const { HttpServerService } = await import('./services/http');
this.httpServerService = Container.get(HttpServerService);
const appLoader = await import('./loaders/app');
await appLoader.default({ app: this.app });
const server = await this.httpServerService.initialize(
this.app,
config.port,
);
const serverLoader = await import('./loaders/server');
await (serverLoader.default as any)({ server });
this.setupWorkerShutdown('http');
}
private async startGrpcService() {
const { GrpcServerService } = await import('./services/grpc');
this.grpcServerService = Container.get(GrpcServerService);
await this.grpcServerService.initialize();
this.setupWorkerShutdown('grpc');
}
private setupWorkerShutdown(serviceType: string) {
process.on('message', async (msg) => {
if (msg === 'shutdown') {
this.gracefulShutdown(serviceType);
} else if (msg === 'reregister-crons' && serviceType === 'http') {
// Re-register cron jobs when gRPC worker restarts
try {
Logger.info('✌️ Received reregister-crons message, re-registering cron jobs...');
const CronService = (await import('./services/cron')).default;
const cronService = Container.get(CronService);
await cronService.autosave_crontab();
Logger.info('✌️ Cron jobs re-registered successfully');
} catch (error) {
Logger.error('✌️ Failed to re-register cron jobs:', error);
}
}
});
const shutdown = () => this.gracefulShutdown(serviceType);
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
private async gracefulShutdown(serviceType: string) {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
try {
if (serviceType === 'http') {
await this.httpServerService?.shutdown();
} else {
await this.grpcServerService?.shutdown();
}
process.exit(0);
} catch (error) {
Logger.error(`✌️ [${serviceType}] Error during shutdown:`, error);
process.exit(1);
}
}
}
const app = new Application();
app.start().catch((error) => {
Logger.error('Application failed to start:', error);
Logger.error('🙅‍♀️ Application failed to start:', error);
process.exit(1);
});
+24
View File
@@ -25,3 +25,27 @@ export const SAMPLE_FILES = [
];
export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME;
export const NotificationModeStringMap = {
0: 'gotify',
1: 'goCqHttpBot',
2: 'serverChan',
3: 'pushDeer',
4: 'bark',
5: 'chat',
6: 'telegramBot',
7: 'dingtalkBot',
8: 'weWorkBot',
9: 'weWorkApp',
10: 'aibotk',
11: 'iGot',
12: 'pushPlus',
13: 'wePlusBot',
14: 'email',
15: 'pushMe',
16: 'feishu',
17: 'webhook',
18: 'chronocat',
19: 'ntfy',
20: 'wxPusherBot',
} as const;
+5 -2
View File
@@ -29,7 +29,7 @@ interface Config {
}
const config: Config = {
port: parseInt(process.env.BACK_PORT || '5600', 10),
port: parseInt(process.env.BACK_PORT || '5700', 10),
grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10),
nodeEnv: process.env.NODE_ENV || 'development',
isDevelopment: process.env.NODE_ENV === 'development',
@@ -41,7 +41,7 @@ const config: Config = {
prefix: '/api',
},
jwt: {
secret: process.env.JWT_SECRET || createRandomString(16, 32),
secret: process.env.JWT_SECRET || 'whyour-secret',
expiresIn: process.env.JWT_EXPIRES_IN,
},
cors: {
@@ -86,6 +86,7 @@ const dbPath = path.join(dataPath, 'db/');
const uploadPath = path.join(dataPath, 'upload/');
const sshdPath = path.join(dataPath, 'ssh.d/');
const systemLogPath = path.join(dataPath, 'syslog/');
const dependenceCachePath = path.join(dataPath, 'dep_cache/');
const envFile = path.join(preloadPath, 'env.sh');
const jsEnvFile = path.join(preloadPath, 'env.js');
@@ -174,4 +175,6 @@ export default {
sqliteFile,
sshdPath,
systemLogPath,
dependenceCachePath,
maxTokensPerPlatform: 10, // Maximum number of concurrent sessions per platform
};
+21
View File
@@ -417,6 +417,27 @@ export async function getPid(cmd: string) {
return pid ? Number(pid) : undefined;
}
export async function getAllPids(cmd: string): Promise<number[]> {
const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}'`;
const pidsStr = await promiseExec(taskCommand);
if (!pidsStr) return [];
return pidsStr
.split('\n')
.map((p) => Number(p.trim()))
.filter((p) => !isNaN(p) && p > 0);
}
export async function killAllTasks(cmd: string): Promise<void> {
const pids = await getAllPids(cmd);
for (const pid of pids) {
try {
await killTask(pid);
} catch (error) {
// Ignore errors if process already terminated
}
}
}
interface IVersion {
version: string;
changeLogLink: string;
+7 -1
View File
@@ -21,6 +21,8 @@ export class Crontab {
extra_schedules?: Array<{ schedule: string }>;
task_before?: string;
task_after?: string;
log_name?: string;
allow_multiple_instances?: 1 | 0;
constructor(options: Crontab) {
this.name = options.name;
@@ -45,6 +47,8 @@ export class Crontab {
this.extra_schedules = options.extra_schedules;
this.task_before = options.task_before;
this.task_after = options.task_after;
this.log_name = options.log_name;
this.allow_multiple_instances = options.allow_multiple_instances || 0;
}
}
@@ -55,7 +59,7 @@ export enum CrontabStatus {
'disabled',
}
export interface CronInstance extends Model<Crontab, Crontab>, Crontab { }
export interface CronInstance extends Model<Crontab, Crontab>, Crontab {}
export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
name: {
unique: 'compositeIndex',
@@ -84,4 +88,6 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
extra_schedules: DataTypes.JSON,
task_before: DataTypes.STRING,
task_after: DataTypes.STRING,
log_name: DataTypes.STRING,
allow_multiple_instances: DataTypes.NUMBER,
});
+4 -1
View File
@@ -1,5 +1,5 @@
import { DataTypes, Model } from 'sequelize';
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
export class Env {
value?: string;
@@ -9,6 +9,7 @@ export class Env {
position?: number;
name?: string;
remarks?: string;
isPinned?: 1 | 0;
constructor(options: Env) {
this.value = options.value;
@@ -21,6 +22,7 @@ export class Env {
this.position = options.position;
this.name = options.name;
this.remarks = options.remarks || '';
this.isPinned = options.isPinned || 0;
}
}
@@ -42,4 +44,5 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
position: DataTypes.NUMBER,
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING,
isPinned: DataTypes.NUMBER,
});
+5 -2
View File
@@ -1,5 +1,3 @@
import { IncomingHttpHeaders } from 'http';
export enum NotificationMode {
'gotify' = 'gotify',
'goCqHttpBot' = 'goCqHttpBot',
@@ -144,12 +142,17 @@ export class WebhookNotification extends NotificationBaseInfo {
export class LarkNotification extends NotificationBaseInfo {
public larkKey = '';
public larkSecret = '';
}
export class NtfyNotification extends NotificationBaseInfo {
public ntfyUrl = '';
public ntfyTopic = '';
public ntfyPriority = '';
public ntfyToken = '';
public ntfyUsername = '';
public ntfyPassword = '';
public ntfyActions = '';
}
export class WxPusherBotNotification extends NotificationBaseInfo {
+15 -1
View File
@@ -38,6 +38,7 @@ export interface SystemConfigInfo {
pythonMirror?: string;
linuxMirror?: string;
timezone?: string;
globalSshKey?: string;
}
export interface LoginLogInfo {
@@ -48,6 +49,19 @@ export interface LoginLogInfo {
status?: LoginStatus;
}
export interface TokenInfo {
value: string;
timestamp: number;
ip: string;
address: string;
platform: string;
/**
* Token expiration time in seconds since Unix epoch.
* If undefined, the token uses JWT's built-in expiration.
*/
expiration?: number;
}
export interface AuthInfo {
username: string;
password: string;
@@ -58,7 +72,7 @@ export interface AuthInfo {
platform: string;
isTwoFactorChecking: boolean;
token: string;
tokens: Record<string, string>;
tokens: Record<string, string | TokenInfo[]>;
twoFactorActivated: boolean;
twoFactorSecret: string;
avatar: string;
+32 -37
View File
@@ -19,43 +19,38 @@ export default async () => {
await CrontabViewModel.sync();
// 初始化新增字段
try {
await sequelize.query(
'alter table CrontabViews add column filterRelation VARCHAR(255)',
);
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column proxy VARCHAR(255)',
);
} catch (error) {}
try {
await sequelize.query('alter table CrontabViews add column type NUMBER');
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column autoAddCron NUMBER',
);
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column autoDelCron NUMBER',
);
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column sub_id NUMBER');
} catch (error) {}
try {
await sequelize.query(
'alter table Crontabs add column extra_schedules JSON',
);
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column task_before TEXT');
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column task_after TEXT');
} catch (error) {}
const migrations = [
{
table: 'CrontabViews',
column: 'filterRelation',
type: 'VARCHAR(255)',
},
{ table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' },
{ table: 'CrontabViews', column: 'type', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' },
{ table: 'Crontabs', column: 'sub_id', type: 'NUMBER' },
{ table: 'Crontabs', column: 'extra_schedules', type: 'JSON' },
{ table: 'Crontabs', column: 'task_before', type: 'TEXT' },
{ table: 'Crontabs', column: 'task_after', type: 'TEXT' },
{ table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' },
{
table: 'Crontabs',
column: 'allow_multiple_instances',
type: 'NUMBER',
},
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
];
for (const migration of migrations) {
try {
await sequelize.query(
`alter table ${migration.table} add column ${migration.column} ${migration.type}`,
);
} catch (error) {
// Column already exists or other error, continue
}
}
Logger.info('✌️ DB loaded');
} catch (error) {
+24 -6
View File
@@ -1,8 +1,9 @@
import path from 'path';
import fs from 'fs/promises';
import os from 'os';
import chokidar from 'chokidar';
import config from '../config/index';
import { fileExist, promiseExec, rmPath } from '../config/util';
import Logger from './logger';
async function linkToNodeModule(src: string, dst?: string) {
const target = path.join(config.rootPath, 'node_modules', dst || src);
@@ -13,12 +14,22 @@ async function linkToNodeModule(src: string, dst?: string) {
if (!stats) {
await fs.symlink(source, target, 'dir');
}
} catch (error) {}
} catch (error) { }
}
async function linkCommand() {
const commandPath = await promiseExec('which node');
const commandDir = path.dirname(commandPath);
const homeDir = os.homedir();
let userBinDir = path.join(homeDir, 'bin');
try {
await fs.mkdir(userBinDir, { recursive: true });
await linkCommandToDir(userBinDir);
} catch (error) {
Logger.error('Linking command failed:', error);
}
}
async function linkCommandToDir(commandDir: string) {
const linkShell = [
{
src: 'update.sh',
@@ -36,6 +47,13 @@ async function linkCommand() {
const source = path.join(config.rootPath, 'shell', link.src);
const target = path.join(commandDir, link.dest);
const tmpTarget = path.join(commandDir, link.tmp);
try {
const stats = await fs.lstat(tmpTarget);
if (stats) {
await fs.unlink(tmpTarget);
}
} catch (error) { }
await fs.symlink(source, tmpTarget);
await fs.rename(tmpTarget, target);
}
@@ -52,6 +70,6 @@ export default async (src: string = 'deps') => {
});
watcher
.on('add', (path) => linkToNodeModule(src))
.on('change', (path) => linkToNodeModule(src));
.on('add', () => linkToNodeModule(src))
.on('change', () => linkToNodeModule(src));
};
+21 -10
View File
@@ -9,6 +9,8 @@ import rewrite from 'express-urlrewrite';
import { errors } from 'celebrate';
import { serveEnv } from '../config/serverEnv';
import { IKeyvStore, shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
import path from 'path';
export default ({ app }: { app: Application }) => {
app.set('trust proxy', 'loopback');
@@ -19,12 +21,15 @@ export default ({ app }: { app: Application }) => {
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
const frontendPath = path.join(config.rootPath, 'static/dist');
app.use(express.static(frontendPath));
app.use(
expressjwt({
secret: config.jwt.secret,
algorithms: ['HS384'],
}).unless({
path: [...config.apiWhiteList, /^\/open\//],
path: [...config.apiWhiteList, /^\/(?!api\/).*/],
}),
);
@@ -39,6 +44,10 @@ export default ({ app }: { app: Application }) => {
});
app.use(async (req: Request, res, next) => {
if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) {
return next();
}
const headerToken = getToken(req);
if (req.path.startsWith('/open/')) {
const apps = await shareStore.getApps();
@@ -69,11 +78,8 @@ export default ({ app }: { app: Application }) => {
}
const authInfo = await shareStore.getAuthInfo();
if (authInfo && headerToken) {
const { token = '', tokens = {} } = authInfo;
if (headerToken === token || tokens[req.platform] === headerToken) {
return next();
}
if (isValidToken(authInfo, headerToken, req.platform)) {
return next();
}
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
@@ -110,10 +116,15 @@ export default ({ app }: { app: Application }) => {
app.use(rewrite('/open/*', '/api/$1'));
app.use(config.api.prefix, routes());
app.use((req, res, next) => {
const err: any = new Error('Not Found');
err['status'] = 404;
next(err);
app.get('*', (_, res, next) => {
const indexPath = path.join(frontendPath, 'index.html');
res.sendFile(indexPath, (err) => {
if (err) {
const err: any = new Error('Not Found');
err['status'] = 404;
next(err);
}
});
});
app.use(errors());
+2 -1
View File
@@ -89,7 +89,8 @@ export default async () => {
setTimeout(async () => {
await dependenceService.installDependenceOneByOne(docs);
require('./bootAfter').default();
const bootAfterLoader = await import('./bootAfter');
bootAfterLoader.default();
}, 5000);
};
+7
View File
@@ -2,6 +2,7 @@ import { Container } from 'typedi';
import SystemService from '../services/system';
import ScheduleService, { ScheduleTaskType } from '../services/schedule';
import SubscriptionService from '../services/subscription';
import SshKeyService from '../services/sshKey';
import config from '../config';
import { fileExist } from '../config/util';
import { join } from 'path';
@@ -10,6 +11,7 @@ export default async () => {
const systemService = Container.get(SystemService);
const scheduleService = Container.get(ScheduleService);
const subscriptionService = Container.get(SubscriptionService);
const sshKeyService = Container.get(SshKeyService);
// 生成内置token
let tokenCommand = `ts-node-transpile-only ${join(
@@ -57,6 +59,11 @@ export default async () => {
}
systemService.updateTimezone(data.info);
// Apply global SSH key if configured
if (data.info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
}
}
await subscriptionService.setSshConfig();
+11 -12
View File
@@ -4,6 +4,7 @@ import { Container } from 'typedi';
import SockService from '../services/sock';
import { getPlatform } from '../config/util';
import { shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
export default async ({ server }: { server: Server }) => {
const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} });
@@ -17,21 +18,19 @@ export default async ({ server }: { server: Server }) => {
const authInfo = await shareStore.getAuthInfo();
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
if (authInfo) {
const { token = '', tokens = {} } = authInfo;
if (headerToken === token || tokens[platform] === headerToken) {
sockService.addClient(conn);
conn.on('data', (message) => {
conn.write(message);
});
if (isValidToken(authInfo, headerToken, platform)) {
sockService.addClient(conn);
conn.on('close', function () {
sockService.removeClient(conn);
});
conn.on('data', (message) => {
conn.write(message);
});
return;
}
conn.on('close', function () {
sockService.removeClient(conn);
});
return;
}
conn.close('404');
+140 -8
View File
@@ -53,14 +53,7 @@ message Response {
optional string message = 2;
}
message SystemNotifyRequest {
string title = 1;
string content = 2;
}
message ExtraScheduleItem {
string schedule = 1;
}
message ExtraScheduleItem { string schedule = 1; }
message CronItem {
optional int32 id = 1;
@@ -104,6 +97,18 @@ message UpdateCronRequest {
message DeleteCronsRequest { repeated int32 ids = 1; }
message GetCronsRequest {
optional string searchValue = 1;
}
message GetCronByIdRequest { int32 id = 1; }
message EnableCronsRequest { repeated int32 ids = 1; }
message DisableCronsRequest { repeated int32 ids = 1; }
message RunCronsRequest { repeated int32 ids = 1; }
message CronsResponse {
int32 code = 1;
repeated CronItem data = 2;
@@ -124,6 +129,128 @@ message CronDetailResponse {
optional string message = 3;
}
enum NotificationMode {
gotify = 0;
goCqHttpBot = 1;
serverChan = 2;
pushDeer = 3;
bark = 4;
chat = 5;
telegramBot = 6;
dingtalkBot = 7;
weWorkBot = 8;
weWorkApp = 9;
aibotk = 10;
iGot = 11;
pushPlus = 12;
wePlusBot = 13;
email = 14;
pushMe = 15;
feishu = 16;
webhook = 17;
chronocat = 18;
ntfy = 19;
wxPusherBot = 20;
}
message NotificationInfo {
NotificationMode type = 1;
optional string gotifyUrl = 2;
optional string gotifyToken = 3;
optional int32 gotifyPriority = 4;
optional string goCqHttpBotUrl = 5;
optional string goCqHttpBotToken = 6;
optional string goCqHttpBotQq = 7;
optional string serverChanKey = 8;
optional string pushDeerKey = 9;
optional string pushDeerUrl = 10;
optional string synologyChatUrl = 11;
optional string barkPush = 12;
optional string barkIcon = 13;
optional string barkSound = 14;
optional string barkGroup = 15;
optional string barkLevel = 16;
optional string barkUrl = 17;
optional string barkArchive = 18;
optional string telegramBotToken = 19;
optional string telegramBotUserId = 20;
optional string telegramBotProxyHost = 21;
optional string telegramBotProxyPort = 22;
optional string telegramBotProxyAuth = 23;
optional string telegramBotApiHost = 24;
optional string dingtalkBotToken = 25;
optional string dingtalkBotSecret = 26;
optional string weWorkBotKey = 27;
optional string weWorkOrigin = 28;
optional string weWorkAppKey = 29;
optional string aibotkKey = 30;
optional string aibotkType = 31;
optional string aibotkName = 32;
optional string iGotPushKey = 33;
optional string pushPlusToken = 34;
optional string pushPlusUser = 35;
optional string pushPlusTemplate = 36;
optional string pushplusChannel = 37;
optional string pushplusWebhook = 38;
optional string pushplusCallbackUrl = 39;
optional string pushplusTo = 40;
optional string wePlusBotToken = 41;
optional string wePlusBotReceiver = 42;
optional string wePlusBotVersion = 43;
optional string emailService = 44;
optional string emailUser = 45;
optional string emailPass = 46;
optional string emailTo = 47;
optional string pushMeKey = 48;
optional string pushMeUrl = 49;
optional string chronocatURL = 50;
optional string chronocatQQ = 51;
optional string chronocatToken = 52;
optional string webhookHeaders = 53;
optional string webhookBody = 54;
optional string webhookUrl = 55;
optional string webhookMethod = 56;
optional string webhookContentType = 57;
optional string larkKey = 58;
optional string ntfyUrl = 59;
optional string ntfyTopic = 60;
optional string ntfyPriority = 61;
optional string ntfyToken = 62;
optional string ntfyUsername = 63;
optional string ntfyPassword = 64;
optional string ntfyActions = 65;
optional string wxPusherBotAppToken = 66;
optional string wxPusherBotTopicIds = 67;
optional string wxPusherBotUids = 68;
}
message SystemNotifyRequest {
string title = 1;
string content = 2;
optional NotificationInfo notificationInfo = 3;
}
service Api {
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
@@ -139,4 +266,9 @@ service Api {
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
rpc DeleteCrons(DeleteCronsRequest) returns (Response) {}
rpc GetCrons(GetCronsRequest) returns (CronsResponse) {}
rpc GetCronById(GetCronByIdRequest) returns (CronResponse) {}
rpc EnableCrons(EnableCronsRequest) returns (Response) {}
rpc DisableCrons(DisableCronsRequest) returns (Response) {}
rpc RunCrons(RunCronsRequest) returns (Response) {}
}
+2002 -81
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -16,7 +16,7 @@ const addCron = (
}
Logger.info(
'[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
name,
schedule,
@@ -26,7 +26,7 @@ const addCron = (
if (extra_schedules?.length) {
extra_schedules.forEach((x) => {
Logger.info(
'[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
name,
x.schedule,
+125 -1
View File
@@ -30,7 +30,14 @@ import {
UpdateCronRequest,
DeleteCronsRequest,
CronResponse,
GetCronsRequest,
CronsResponse,
GetCronByIdRequest,
EnableCronsRequest,
DisableCronsRequest,
RunCronsRequest,
} from '../protos/api';
import { NotificationInfo } from '../data/notify';
Container.set('logger', LoggerInstance);
@@ -227,7 +234,11 @@ export const systemNotify = async (
) => {
try {
const systemService = Container.get(SystemService);
const data = await systemService.notify(call.request);
const data = await systemService.notify({
title: call.request.title,
content: call.request.content,
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo,
});
callback(null, data);
} catch (e: any) {
callback(e);
@@ -318,3 +329,116 @@ export const deleteCrons = async (
callback(e);
}
};
export const getCrons = async (
call: ServerUnaryCall<GetCronsRequest, CronsResponse>,
callback: sendUnaryData<CronsResponse>,
) => {
try {
const cronService = Container.get(CronService);
const result = await cronService.crontabs({
searchValue: call.request.searchValue || '',
page: '0',
size: '0',
sorter: '',
filters: '',
queryString: '',
});
const data = result.data.map((x) => normalizeCronData(x as CronItem));
callback(null, {
code: 200,
data: data.filter((x): x is CronItem => x !== undefined),
});
} catch (e: any) {
callback(null, {
code: 500,
data: [],
message: e.message,
});
}
};
export const getCronById = async (
call: ServerUnaryCall<GetCronByIdRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
if (!call.request.id) {
return callback(null, {
code: 400,
data: undefined,
message: 'id parameter is required',
});
}
const cronService = Container.get(CronService);
const data = (await cronService.getDb({ id: call.request.id })) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(null, {
code: 404,
data: undefined,
message: e.message,
});
}
};
export const enableCrons = async (
call: ServerUnaryCall<EnableCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.enabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const disableCrons = async (
call: ServerUnaryCall<DisableCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.disabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const runCrons = async (
call: ServerUnaryCall<RunCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.run(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
+1 -1
View File
@@ -10,7 +10,7 @@ const delCron = (
for (const id of call.request.ids) {
if (scheduleStacks.has(id)) {
Logger.info(
'[schedule][取消定时任务], 任务ID: %s',
'[schedule][取消定时任务] 任务ID: %s',
id,
);
scheduleStacks.get(id)?.forEach(x => x.cancel());
+3 -6
View File
@@ -17,14 +17,11 @@ const check = async (
return callback(null, { status: 1 });
}
const panelErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/panel-error.log`,
);
const scheduleErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/schedule-error.log`,
const qinglongErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/qinglong-error.log`,
);
return callback(
new Error(`${scheduleErrLog || ''}\n${panelErrLog || ''}\n${res}`.trim()),
new Error(`${qinglongErrLog || ''}\n${res}`.trim()),
);
default:
+71 -24
View File
@@ -4,11 +4,12 @@ import config from '../config';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import { exec, execSync } from 'child_process';
import fs from 'fs/promises';
import cron_parser from 'cron-parser';
import { CronExpressionParser } from 'cron-parser';
import {
getFileContentByName,
fileExist,
killTask,
killAllTasks,
getUniqPath,
safeJSONParse,
isDemoEnv,
@@ -24,6 +25,7 @@ import pickBy from 'lodash/pickBy';
import omit from 'lodash/omit';
import { writeFileWithLock } from '../shared/utils';
import { ScheduleType } from '../interface/schedule';
import { logStreamManager } from '../shared/logStreamManager';
@Service()
export default class CronService {
@@ -49,9 +51,29 @@ export default class CronService {
return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule);
}
private async getLogName(cron: Crontab) {
const { log_name, command, id } = cron;
if (log_name === '/dev/null') {
return log_name;
}
let uniqPath = await getUniqPath(command, `${id}`);
if (log_name) {
const normalizedLogName = log_name.startsWith('/')
? log_name
: path.join(config.logPath, log_name);
if (normalizedLogName.startsWith(config.logPath)) {
uniqPath = log_name;
}
}
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
await fs.mkdir(logDirPath, { recursive: true });
return uniqPath;
}
public async create(payload: Crontab): Promise<Crontab> {
const tab = new Crontab(payload);
tab.saved = false;
tab.log_name = await this.getLogName(tab);
const doc = await this.insert(tab);
if (isDemoEnv()) {
@@ -82,6 +104,7 @@ export default class CronService {
const doc = await this.getDb({ id: payload.id });
const tab = new Crontab({ ...doc, ...payload });
tab.saved = false;
tab.log_name = await this.getLogName(tab);
const newDoc = await this.updateDb(tab);
if (doc.isDisabled === 1 || isDemoEnv()) {
@@ -442,12 +465,17 @@ export default class CronService {
public async stop(ids: number[]) {
const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) {
if (doc.pid) {
try {
await killTask(doc.pid);
} catch (error) {
this.logger.error(error);
}
// Kill all running instances of this task
try {
const command = this.makeCommand(doc);
await killAllTasks(command);
this.logger.info(
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
);
} catch (error) {
this.logger.error(
`[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`,
);
}
}
@@ -476,13 +504,15 @@ export default class CronService {
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
);
let { id, command, log_path } = cron;
const uniqPath = await getUniqPath(command, `${id}`);
let { id, command, log_name } = cron;
const uniqPath =
log_name === '/dev/null' || !log_name
? await getUniqPath(command, `${id}`)
: log_name;
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
if (log_path?.split('/')?.every((x) => x !== uniqPath)) {
await fs.mkdir(logDirPath, { recursive: true });
}
await fs.mkdir(logDirPath, { recursive: true });
const logPath = `${uniqPath}/${logTime}.log`;
const absolutePath = path.resolve(config.logPath, `${logPath}`);
const cp = spawn(
@@ -498,7 +528,7 @@ export default class CronService {
{ where: { id } },
);
cp.stdout.on('data', async (data) => {
await fs.appendFile(absolutePath, data.toString());
await logStreamManager.write(absolutePath, data.toString());
});
cp.stderr.on('data', async (data) => {
this.logger.info(
@@ -506,7 +536,7 @@ export default class CronService {
command,
data.toString(),
);
await fs.appendFile(absolutePath, data.toString());
await logStreamManager.write(absolutePath, data.toString());
});
cp.on('error', async (err) => {
this.logger.error(
@@ -514,7 +544,7 @@ export default class CronService {
command,
err,
);
await fs.appendFile(absolutePath, JSON.stringify(err));
await logStreamManager.write(absolutePath, JSON.stringify(err));
});
cp.on('exit', async (code) => {
@@ -523,6 +553,8 @@ export default class CronService {
JSON.stringify(params),
code,
);
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id } },
@@ -564,7 +596,9 @@ export default class CronService {
if (!doc) {
return '';
}
if (doc.log_name === '/dev/null') {
return '日志设置为忽略';
}
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
const logFileExist = doc.log_path && (await fileExist(absolutePath));
if (logFileExist) {
@@ -573,7 +607,7 @@ export default class CronService {
return typeof doc.status === 'number' &&
[CrontabStatus.queued, CrontabStatus.running].includes(doc.status)
? '运行中...'
: '任务空闲中';
: '日志不存在...';
}
}
@@ -593,7 +627,7 @@ export default class CronService {
files.map(async (x) => ({
filename: x,
directory: relativeDir.replace(config.logPath, ''),
time: (await fs.lstat(`${dir}/${x}`)).mtime.getTime(),
time: (await fs.lstat(`${dir}/${x}`)).birthtimeMs,
})),
)
).sort((a, b) => b.time - a.time);
@@ -607,9 +641,11 @@ export default class CronService {
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
command = `${TASK_PREFIX}${tab.command}`;
}
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${
tab.id
} `;
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${tab.id} `;
// Only include log_name if it has a truthy value to avoid passing null/undefined to shell
if (tab.log_name) {
commandVariable += `log_name=${tab.log_name} `;
}
if (tab.task_before) {
commandVariable += `task_before='${tab.task_before
.replace(/'/g, "'\\''")
@@ -651,12 +687,23 @@ export default class CronService {
await writeFileWithLock(config.crontabFile, crontab_string);
execSync(`crontab ${config.crontabFile}`);
try {
execSync(`crontab ${config.crontabFile}`);
} catch (error: any) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to update system crontab:', errorMsg);
}
await CrontabModel.update({ saved: true }, { where: {} });
}
public importCrontab() {
exec('crontab -l', (error, stdout, stderr) => {
exec('crontab -l', (error, stdout) => {
if (error) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to read system crontab:', errorMsg);
}
const lines = stdout.split('\n');
const namePrefix = new Date().getTime();
@@ -670,7 +717,7 @@ export default class CronService {
if (
command &&
schedule &&
cron_parser.parseExpression(schedule).hasNext()
CronExpressionParser.parse(schedule).hasNext()
) {
const name = namePrefix + '_' + index;
+14 -18
View File
@@ -28,7 +28,7 @@ export default class DependenceService {
constructor(
@Inject('logger') private logger: winston.Logger,
private sockService: SockService,
) {}
) { }
public async create(payloads: Dependence[]): Promise<Dependence[]> {
const tabs = payloads.map((x) => {
@@ -98,34 +98,32 @@ export default class DependenceService {
searchValue,
type,
status,
}: { searchValue: string; type: string; status: string },
}: {
searchValue: string;
type: keyof typeof DependenceTypes;
status: string;
},
sort: any = [],
query: any = {},
): Promise<Dependence[]> {
let condition = {
...query,
type: DependenceTypes[type as any],
};
let condition = query;
if (DependenceTypes[type]) {
condition.type = DependenceTypes[type];
}
if (status) {
condition.status = status.split(',').map(Number);
}
if (searchValue) {
const encodeText = encodeURI(searchValue);
const reg = {
condition.name = {
[Op.or]: [
{ [Op.like]: `%${searchValue}%` },
{ [Op.like]: `%${encodeText}%` },
],
};
condition = {
...condition,
name: reg,
};
}
try {
const result = await this.find(condition, sort);
return result as any;
return await this.find(condition, sort);
} catch (error) {
throw error;
}
@@ -163,11 +161,9 @@ export default class DependenceService {
taskLimit.removeQueuedDependency(doc);
const depInstallCommand = getInstallCommand(doc.type, doc.name);
const depUnInstallCommand = getUninstallCommand(doc.type, doc.name);
const installCmd = `${depInstallCommand} ${doc.name.trim()}`;
const unInstallCmd = `${depUnInstallCommand} ${doc.name.trim()}`;
const pids = await Promise.all([
getPid(installCmd),
getPid(unInstallCmd),
getPid(depInstallCommand),
getPid(depUnInstallCommand),
]);
for (const pid of pids) {
pid && (await killTask(pid));
+12 -4
View File
@@ -1,7 +1,8 @@
import { Service, Inject } from 'typedi';
import groupBy from 'lodash/groupBy';
import { FindOptions, Op } from 'sequelize';
import { Inject, Service } from 'typedi';
import winston from 'winston';
import config from '../config';
import * as fs from 'fs/promises';
import {
Env,
EnvModel,
@@ -11,8 +12,6 @@ import {
minPosition,
stepPosition,
} from '../data/env';
import groupBy from 'lodash/groupBy';
import { FindOptions, Op } from 'sequelize';
import { writeFileWithLock } from '../shared/utils';
@Service()
@@ -147,6 +146,7 @@ export default class EnvService {
}
try {
const result = await this.find(condition, [
['isPinned', 'DESC'],
['position', 'DESC'],
['createdAt', 'ASC'],
]);
@@ -190,6 +190,14 @@ export default class EnvService {
await this.set_envs();
}
public async pin(ids: number[]) {
await EnvModel.update({ isPinned: 1 }, { where: { id: ids } });
}
public async unPin(ids: number[]) {
await EnvModel.update({ isPinned: 0 }, { where: { id: ids } });
}
public async set_envs() {
const envs = await this.envs('', {
name: { [Op.not]: null },
+55 -10
View File
@@ -49,12 +49,21 @@ export default class NotificationService {
public async notify(
title: string,
content: string,
notificationInfo?: NotificationInfo,
): Promise<boolean | undefined> {
const { type, ...rest } = await this.userService.getNotificationMode();
let { type, ...rest } = await this.userService.getNotificationMode();
if (notificationInfo?.type) {
type = notificationInfo?.type;
}
if (type) {
this.title = title;
this.content = content;
this.params = rest;
let params = rest;
if (notificationInfo) {
const { type: _, ...others } = notificationInfo;
params = { ...rest, ...others };
}
this.params = params;
const notificationModeAction = this.modeMap.get(type);
try {
return await notificationModeAction?.call(this);
@@ -541,19 +550,33 @@ export default class NotificationService {
}
private async lark() {
let { larkKey } = this.params;
let { larkKey, larkSecret } = this.params;
if (!larkKey.startsWith('http')) {
larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`;
}
const body: Record<string, any> = {
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
};
// Add signature if secret is provided
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
// and signs an empty message, which differs from typical HMAC usage
if (larkSecret) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const stringToSign = `${timestamp}\n${larkSecret}`;
const hmac = crypto.createHmac('sha256', stringToSign);
const sign = hmac.digest('base64');
body.timestamp = timestamp;
body.sign = sign;
}
try {
const res = await httpClient.post(larkKey, {
...this.gotOption,
json: {
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
},
json: body,
headers: { 'Content-Type': 'application/json' },
});
if (res.StatusCode === 0 || res.code === 0) {
@@ -623,20 +646,42 @@ export default class NotificationService {
}
private async ntfy() {
const { ntfyUrl, ntfyTopic, ntfyPriority } = this.params;
const {
ntfyUrl,
ntfyTopic,
ntfyPriority,
ntfyToken,
ntfyUsername,
ntfyPassword,
ntfyActions,
} = this.params;
// 编码函数
const encodeRfc2047 = (text: string, charset: string = 'UTF-8'): string => {
const encodedText = Buffer.from(text).toString('base64');
return `=?${charset}?B?${encodedText}?=`;
};
try {
const encodedTitle = encodeRfc2047(this.title);
const headers: Record<string, string> = {
Title: encodeRfc2047(this.title),
Priority: `${ntfyPriority || '3'}`,
Icon: 'https://qn.whyour.cn/logo.png',
};
if (ntfyToken) {
headers['Authorization'] = `Bearer ${ntfyToken}`;
} else if (ntfyUsername && ntfyPassword) {
headers['Authorization'] = `Basic ${Buffer.from(
`${ntfyUsername}:${ntfyPassword}`,
).toString('base64')}`;
}
if (ntfyActions) {
headers['Actions'] = encodeRfc2047(ntfyActions);
}
const res = await httpClient.request(
`${ntfyUrl || 'https://ntfy.sh'}/${ntfyTopic}`,
{
...this.gotOption,
body: `${this.content}`,
headers: { Title: encodedTitle, Priority: `${ntfyPriority || '3'}` },
headers: headers,
method: 'POST',
},
);
+4 -4
View File
@@ -139,7 +139,7 @@ export default class ScheduleService {
) {
const _id = this.formatId(id);
this.logger.info(
'[panel][创建cron任务], 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
_id,
schedule,
name,
@@ -172,7 +172,7 @@ export default class ScheduleService {
async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id);
this.logger.info('[panel][取消定时任务], 任务名: %s', name);
this.logger.info('[panel][取消定时任务] 任务名: %s', name);
if (this.scheduleStacks.has(_id)) {
this.scheduleStacks.get(_id)?.cancel();
this.scheduleStacks.delete(_id);
@@ -187,7 +187,7 @@ export default class ScheduleService {
) {
const _id = this.formatId(id);
this.logger.info(
'[panel][创建interval任务], 任务ID: %s, 任务名: %s, 执行命令: %s',
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s',
_id,
name,
command,
@@ -232,7 +232,7 @@ export default class ScheduleService {
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id);
this.logger.info(
'[panel][取消interval任务], 任务ID: %s, 任务名: %s',
'[panel][取消interval任务] 任务ID: %s, 任务名: %s',
_id,
name,
);
+34 -2
View File
@@ -26,12 +26,13 @@ export default class SshKeyService {
if (_exist) {
config = await fs.readFile(this.sshConfigFilePath, { encoding: 'utf-8' });
} else {
await writeFileWithLock(this.sshConfigFilePath, '');
await writeFileWithLock(this.sshConfigFilePath, '', { mode: '600' });
}
if (!config.includes(this.sshConfigHeader)) {
await writeFileWithLock(
this.sshConfigFilePath,
`${this.sshConfigHeader}\n\n${config}`,
{ mode: '600' },
);
}
}
@@ -45,7 +46,6 @@ export default class SshKeyService {
path.join(this.sshPath, alias),
`${key}${os.EOL}`,
{
encoding: 'utf8',
mode: '400',
},
);
@@ -81,6 +81,10 @@ export default class SshKeyService {
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
config,
{
encoding: 'utf8',
mode: '600',
},
);
}
@@ -127,4 +131,32 @@ export default class SshKeyService {
}
}
}
public async addGlobalSSHKey(key: string, alias: string): Promise<void> {
await this.generatePrivateKeyFile(`~global_${alias}`, key);
// Create a global SSH config entry that matches all hosts
// This allows the key to be used for any Git repository
await this.generateGlobalSshConfig(`~global_${alias}`);
}
public async removeGlobalSSHKey(alias: string): Promise<void> {
await this.removePrivateKeyFile(`~global_${alias}`);
await this.removeSshConfig(`~global_${alias}`);
}
private async generateGlobalSshConfig(alias: string) {
// Create a config that matches all hosts, making this key globally available
const config = `Host *\n IdentityFile ${path.join(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n`;
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
config,
{
encoding: 'utf8',
mode: '600',
},
);
}
}
+12 -8
View File
@@ -31,6 +31,7 @@ import { formatCommand, formatUrl } from '../config/subscription';
import { CrontabModel } from '../data/cron';
import CrontabService from './cron';
import taskLimit from '../shared/pLimit';
import { logStreamManager } from '../shared/logStreamManager';
@Service()
export default class SubscriptionService {
@@ -136,7 +137,7 @@ export default class SubscriptionService {
let beforeStr = '';
try {
if (doc.sub_before) {
await fs.appendFile(absolutePath, `\n## 执行before命令...\n\n`);
await logStreamManager.write(absolutePath, `\n## 执行before命令...\n\n`);
beforeStr = await promiseExec(doc.sub_before);
}
} catch (error: any) {
@@ -144,7 +145,7 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
}
if (beforeStr) {
await fs.appendFile(absolutePath, `${beforeStr}\n`);
await logStreamManager.write(absolutePath, `${beforeStr}\n`);
}
},
onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => {
@@ -163,7 +164,7 @@ export default class SubscriptionService {
let afterStr = '';
try {
if (sub.sub_after) {
await fs.appendFile(absolutePath, `\n\n## 执行after命令...\n\n`);
await logStreamManager.write(absolutePath, `\n\n## 执行after命令...\n\n`);
afterStr = await promiseExec(sub.sub_after);
}
} catch (error: any) {
@@ -171,16 +172,19 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
}
if (afterStr) {
await fs.appendFile(absolutePath, `${afterStr}\n`);
await logStreamManager.write(absolutePath, `${afterStr}\n`);
}
await fs.appendFile(
await logStreamManager.write(
absolutePath,
`\n## 执行结束... ${endTime.format(
'YYYY-MM-DD HH:mm:ss',
)} 耗时 ${diff}${LOG_END_SYMBOL}`,
);
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: undefined },
{ where: { id: sub.id } },
@@ -195,12 +199,12 @@ export default class SubscriptionService {
onError: async (message: string) => {
const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string);
await fs.appendFile(absolutePath, `\n${message}`);
await logStreamManager.write(absolutePath, `\n${message}`);
},
onLog: async (message: string) => {
const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string);
await fs.appendFile(absolutePath, `\n${message}`);
await logStreamManager.write(absolutePath, `\n${message}`);
},
};
}
@@ -384,7 +388,7 @@ export default class SubscriptionService {
files.map(async (x) => ({
filename: x,
directory: relativeDir.replace(config.logPath, ''),
time: (await fs.lstat(`${dir}/${x}`)).mtime.getTime(),
time: (await fs.lstat(`${dir}/${x}`)).birthtimeMs,
})),
)
).sort((a, b) => b.time - a.time);
+67 -9
View File
@@ -7,7 +7,7 @@ import path from 'path';
import { Inject, Service } from 'typedi';
import winston from 'winston';
import config from '../config';
import { TASK_COMMAND } from '../config/const';
import { NotificationModeStringMap, TASK_COMMAND } from '../config/const';
import {
getPid,
killTask,
@@ -47,7 +47,7 @@ export default class SystemService {
@Inject('logger') private logger: winston.Logger,
private scheduleService: ScheduleService,
private sockService: SockService,
) {}
) { }
public async getSystemConfig() {
const doc = await this.getDb({ type: AuthDataType.systemConfig });
@@ -287,7 +287,7 @@ export default class SystemService {
);
const text = await body.text();
lastVersionContent = parseContentVersion(text);
} catch (error) {}
} catch (error) { }
if (!lastVersionContent) {
lastVersionContent = currentVersionContent;
@@ -373,8 +373,27 @@ export default class SystemService {
return { code: 200 };
}
public async notify({ title, content }: { title: string; content: string }) {
const isSuccess = await this.notificationService.notify(title, content);
public async notify({
title,
content,
notificationInfo,
}: {
title: string;
content: string;
notificationInfo?: NotificationInfo;
}) {
const typeString =
typeof notificationInfo?.type === 'number'
? NotificationModeStringMap[notificationInfo.type]
: undefined;
if (notificationInfo && typeString) {
notificationInfo.type = typeString;
}
const isSuccess = await this.notificationService.notify(
title,
content,
notificationInfo,
);
if (isSuccess) {
return { code: 200, message: '通知发送成功' };
} else {
@@ -382,11 +401,12 @@ export default class SystemService {
}
}
public async run({ command }: { command: string }, callback: TaskCallbacks) {
public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) {
if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`;
}
this.scheduleService.runTask(`real_time=true ${command}`, callback, {
const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, {
command,
id: command.replace(/ /g, '-'),
runOrigin: 'system',
@@ -415,10 +435,16 @@ export default class SystemService {
}
}
public async exportData(res: Response) {
public async exportData(res: Response, type?: string[]) {
try {
let dataDirs = ['db', 'upload'];
if (type && type.length) {
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
}
const dataPaths = dataDirs.map((dir) => `data/${dir}`);
await promiseExec(
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile} data/`,
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile
} ${dataPaths.join(' ')}`,
);
res.download(config.dataTgzFile);
} catch (error: any) {
@@ -503,4 +529,36 @@ export default class SystemService {
return { code: 400, message: '设置时区失败' };
}
}
public async updateGlobalSshKey(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
// Apply the global SSH key
const SshKeyService = require('./sshKey').default;
const Container = require('typedi').Container;
const sshKeyService = Container.get(SshKeyService);
if (info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global');
} else {
await sshKeyService.removeGlobalSSHKey('global');
}
return { code: 200, data: result };
}
public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: '参数错误' };
}
try {
const finalPath = path.join(config.dependenceCachePath, type);
await fs.promises.rm(finalPath, { recursive: true });
} catch (error) { }
return { code: 200 };
}
}
+139 -7
View File
@@ -11,6 +11,7 @@ import {
SystemModelInfo,
LoginStatus,
AuthInfo,
TokenInfo,
} from '../data/system';
import { NotificationInfo } from '../data/notify';
import NotificationService from './notify';
@@ -101,12 +102,23 @@ export default class UserService {
algorithm: 'HS384',
});
const tokenInfo: TokenInfo = {
value: token,
timestamp,
ip,
address,
platform: req.platform,
};
const updatedTokens = this.addTokenToList(
tokens,
req.platform,
tokenInfo,
);
await this.updateAuthInfo(content, {
token,
tokens: {
...tokens,
[req.platform]: token,
},
tokens: updatedTokens,
lastlogon: timestamp,
retries: 0,
lastip: ip,
@@ -180,11 +192,37 @@ export default class UserService {
}
}
public async logout(platform: string): Promise<any> {
public async logout(platform: string, tokenValue: string): Promise<any> {
if (!platform || !tokenValue) {
this.logger.warn('Invalid logout parameters - empty platform or token');
return;
}
const authInfo = await this.getAuthInfo();
// Verify the token exists before attempting to remove it
const tokenExists = this.findTokenInList(
authInfo.tokens,
platform,
tokenValue,
);
if (!tokenExists && authInfo.token !== tokenValue) {
// Token not found, but don't throw error - user may have already logged out
this.logger.info(
`Logout attempted for non-existent token on platform: ${platform}`,
);
return;
}
const updatedTokens = this.removeTokenFromList(
authInfo.tokens,
platform,
tokenValue,
);
await this.updateAuthInfo(authInfo, {
token: '',
tokens: { ...authInfo.tokens, [platform]: '' },
token: authInfo.token === tokenValue ? '' : authInfo.token,
tokens: updatedTokens,
});
}
@@ -364,6 +402,100 @@ export default class UserService {
}
}
private normalizeTokens(
tokens: Record<string, string | TokenInfo[]>,
): Record<string, TokenInfo[]> {
const normalized: Record<string, TokenInfo[]> = {};
for (const [platform, value] of Object.entries(tokens)) {
if (typeof value === 'string') {
// Legacy format: convert string token to TokenInfo array
if (value) {
normalized[platform] = [
{
value,
timestamp: Date.now(),
ip: '',
address: '',
platform,
},
];
} else {
normalized[platform] = [];
}
} else {
// Already in new format
normalized[platform] = value || [];
}
}
return normalized;
}
private addTokenToList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenInfo: TokenInfo,
maxTokensPerPlatform: number = config.maxTokensPerPlatform,
): Record<string, TokenInfo[]> {
// Validate maxTokensPerPlatform parameter
if (!Number.isInteger(maxTokensPerPlatform) || maxTokensPerPlatform < 1) {
this.logger.warn(
`Invalid maxTokensPerPlatform value: ${maxTokensPerPlatform}, using default`,
);
maxTokensPerPlatform = config.maxTokensPerPlatform;
}
const normalized = this.normalizeTokens(tokens);
if (!normalized[platform]) {
normalized[platform] = [];
}
// Add new token
normalized[platform].unshift(tokenInfo);
// Limit the number of active tokens per platform
if (normalized[platform].length > maxTokensPerPlatform) {
normalized[platform] = normalized[platform].slice(
0,
maxTokensPerPlatform,
);
}
return normalized;
}
private removeTokenFromList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): Record<string, TokenInfo[]> {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
normalized[platform] = normalized[platform].filter(
(t) => t.value !== tokenValue,
);
}
return normalized;
}
private findTokenInList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): TokenInfo | undefined {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
return normalized[platform].find((t) => t.value === tokenValue);
}
return undefined;
}
public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info;
const authInfo = await this.getAuthInfo();
+46
View File
@@ -0,0 +1,46 @@
import { AuthInfo, TokenInfo } from '../data/system';
/**
* Validates if a token exists in the authentication info.
* Supports both legacy string tokens and new TokenInfo array format.
*
* @param authInfo - The authentication information
* @param headerToken - The token to validate
* @param platform - The platform (desktop, mobile)
* @returns true if the token is valid, false otherwise
*/
export function isValidToken(
authInfo: AuthInfo | null | undefined,
headerToken: string,
platform: string,
): boolean {
if (!authInfo || !headerToken) {
return false;
}
const { token = '', tokens = {} } = authInfo;
// Check legacy token field
if (headerToken === token) {
return true;
}
// Check platform-specific tokens (support both legacy string and new TokenInfo[] format)
const platformTokens = tokens[platform];
// Handle null/undefined platformTokens
if (platformTokens === null || platformTokens === undefined) {
return false;
}
if (typeof platformTokens === 'string') {
// Legacy format: single string token
return headerToken === platformTokens;
} else if (Array.isArray(platformTokens)) {
// New format: array of TokenInfo objects
return platformTokens.some((t: TokenInfo) => t && t.value === headerToken);
}
// Unexpected type - log warning and reject
return false;
}
+110
View File
@@ -0,0 +1,110 @@
import { createWriteStream, WriteStream } from 'fs';
import { EventEmitter } from 'events';
/**
* Manages write streams for log files to improve performance by avoiding repeated file opens
*/
export class LogStreamManager extends EventEmitter {
private streams: Map<string, WriteStream> = new Map();
private pendingWrites: Map<string, Promise<void>> = new Map();
/**
* Write data to a log file using a managed stream
* @param filePath - Absolute path to the log file
* @param data - Data to write to the log file
*/
async write(filePath: string, data: string): Promise<void> {
// Wait for any pending writes to this file to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending;
}
// Create a new promise for this write operation
const writePromise = new Promise<void>((resolve, reject) => {
let stream = this.streams.get(filePath);
if (!stream) {
// Create a new write stream if one doesn't exist
stream = createWriteStream(filePath, { flags: 'a' });
this.streams.set(filePath, stream);
// Handle stream errors
stream.on('error', (error) => {
this.emit('error', { filePath, error });
// Remove the stream from the map on error
this.streams.delete(filePath);
reject(error);
});
}
// Write the data
const canContinue = stream.write(data, 'utf8', (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
// Handle backpressure
if (!canContinue) {
stream.once('drain', () => {
// Stream is ready for more data
});
}
});
this.pendingWrites.set(filePath, writePromise);
try {
await writePromise;
} finally {
this.pendingWrites.delete(filePath);
}
}
/**
* Close the stream for a specific file path
* @param filePath - Absolute path to the log file
*/
async closeStream(filePath: string): Promise<void> {
// Wait for any pending writes to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending.catch(() => {
// Ignore errors on pending writes during close
});
}
const stream = this.streams.get(filePath);
if (stream) {
return new Promise<void>((resolve) => {
stream.end(() => {
this.streams.delete(filePath);
resolve();
});
});
}
}
/**
* Close all open streams
*/
async closeAll(): Promise<void> {
const closePromises = Array.from(this.streams.keys()).map((filePath) =>
this.closeStream(filePath),
);
await Promise.all(closePromises);
}
/**
* Get the number of open streams
*/
getOpenStreamCount(): number {
return this.streams.size;
}
}
// Export a singleton instance for shared use
export const logStreamManager = new LogStreamManager();
+35
View File
@@ -2,10 +2,45 @@ import { spawn } from 'cross-spawn';
import taskLimit from './pLimit';
import Logger from '../loaders/logger';
import { ICron } from '../protos/cron';
import { CrontabModel, CrontabStatus } from '../data/cron';
import { killTask } from '../config/util';
export function runCron(cmd: string, cron: ICron): Promise<number | void> {
return taskLimit.runWithCronLimit(cron, () => {
return new Promise(async (resolve: any) => {
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
try {
const existingCron = await CrontabModel.findOne({
where: { id: Number(cron.id) },
});
// Default to single instance mode (0) for backward compatibility
const allowSingleInstances =
existingCron?.allow_multiple_instances === 0;
if (
allowSingleInstances &&
existingCron &&
existingCron.pid &&
(existingCron.status === CrontabStatus.running ||
existingCron.status === CrontabStatus.queued)
) {
Logger.info(
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
);
await killTask(existingCron.pid);
// Update the status to idle after killing
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: Number(cron.id) } },
);
}
} catch (error) {
Logger.error(
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
);
}
Logger.info(
`[schedule][开始执行任务] 参数 ${JSON.stringify({
...cron,
+47 -2
View File
@@ -1,6 +1,8 @@
import { Joi } from 'celebrate';
import cron_parser from 'cron-parser';
import { CronExpressionParser } from 'cron-parser';
import { ScheduleType } from '../interface/schedule';
import path from 'path';
import config from '../config';
const validateSchedule = (value: string, helpers: any) => {
if (
@@ -11,7 +13,7 @@ const validateSchedule = (value: string, helpers: any) => {
}
try {
if (cron_parser.parseExpression(value).hasNext()) {
if (CronExpressionParser.parse(value).hasNext()) {
return value;
}
} catch (e) {
@@ -37,4 +39,47 @@ export const commonCronSchema = {
extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null),
log_name: Joi.string()
.optional()
.allow('')
.allow(null)
.custom((value, helpers) => {
if (!value) return value;
// Check if it's an absolute path
if (value.startsWith('/')) {
// Allow /dev/null as special case
if (value === '/dev/null') {
return value;
}
// For other absolute paths, ensure they are within the safe log directory
const normalizedValue = path.normalize(value);
const normalizedLogPath = path.normalize(config.logPath);
if (!normalizedValue.startsWith(normalizedLogPath)) {
return helpers.error('string.unsafePath');
}
return value;
}
if (
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
value,
)
) {
return helpers.error('string.pattern.base');
}
if (value.length > 100) {
return helpers.error('string.max');
}
return value;
})
.messages({
'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符',
'string.max': '日志名称不能超过100个字符',
'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null',
}),
allow_multiple_instances: Joi.number().optional().valid(0, 1).allow(null),
};
+1 -2
View File
@@ -39,7 +39,6 @@ RUN set -x \
tzdata \
perl \
openssl \
nginx \
nodejs \
jq \
openssh \
@@ -84,6 +83,6 @@ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:5600/api/health || exit 1
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+1 -2
View File
@@ -39,7 +39,6 @@ RUN set -x \
tzdata \
perl \
openssl \
nginx \
nodejs \
jq \
openssh \
@@ -84,6 +83,6 @@ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:5600/api/health || exit 1
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+27 -14
View File
@@ -1,43 +1,56 @@
#!/bin/bash
export PATH="$HOME/bin:$PATH"
dir_shell=/ql/shell
. $dir_shell/share.sh
. $dir_shell/env.sh
export_ql_envs() {
export BACK_PORT="${ql_port}"
export GRPC_PORT="${ql_grpc_port}"
}
log_with_style() {
local level="$1"
local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
}
log_with_style "INFO" "🚀 1. 检测配置文件..."
# Fix DNS resolution issues in Alpine Linux
# Alpine uses musl libc which has known DNS resolver issues with certain domains
# Adding ndots:0 prevents unnecessary search domain appending
if [ -f /etc/alpine-release ]; then
if ! grep -q "^options ndots:0" /etc/resolv.conf 2>/dev/null; then
echo "options ndots:0" >> /etc/resolv.conf
log_with_style "INFO" "🔧 0. 已配置 DNS 解析优化 (ndots:0)"
fi
fi
log_with_style "INFO" "🚀 1. 检测配置文件..."
load_ql_envs
export_ql_envs
. $dir_shell/env.sh
import_config "$@"
make_dir /etc/nginx/conf.d
make_dir /run/nginx
init_nginx
fix_config
pm2 l &>/dev/null
# Try to initialize PM2, but don't fail if it doesn't work
pm2 l &>/dev/null || log_with_style "WARN" "PM2 初始化可能失败,将在启动时尝试使用备用方案"
log_with_style "INFO" "🔄 2. 启动 nginx..."
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
log_with_style "INFO" "⚙️ 3. 启动 pm2 服务..."
log_with_style "INFO" "⚙️ 2. 启动 pm2 服务..."
reload_pm2
if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 4. 启动 bot..."
log_with_style "INFO" "🤖 3. 启动 bot..."
nohup ql bot >$dir_log/bot.log 2>&1 &
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 5. 执行自定义脚本..."
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 &
fi
log_with_style "SUCCESS" "🎉 容器启动成功!"
log_with_style "SUCCESS" "🎉 容器启动成功!"
crond -f >/dev/null
-61
View File
@@ -1,61 +0,0 @@
upstream baseApi {
server 0.0.0.0:5600;
}
map $http_upgrade $connection_upgrade {
default keep-alive;
'websocket' upgrade;
}
server {
IPV4_CONFIG
IPV6_CONFIG
ssl_session_timeout 5m;
location QL_BASE_URLapi/ {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://baseApi/api/;
proxy_buffering off;
proxy_redirect default;
proxy_connect_timeout 1800;
proxy_send_timeout 1800;
proxy_read_timeout 1800;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location QL_BASE_URLopen/ {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://baseApi/open/;
proxy_buffering off;
proxy_redirect default;
proxy_connect_timeout 1800;
proxy_send_timeout 1800;
proxy_read_timeout 1800;
}
gzip on;
gzip_static on;
gzip_types text/plain application/json application/javascript application/x-javascript text/css application/xml text/javascript;
gzip_proxied any;
gzip_vary on;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.0;
QL_ROOT_CONFIG
location QL_BASE_URL_LOCATION {
QL_ALIAS_CONFIG
index index.html index.htm;
try_files $uri QL_BASE_URLindex.html;
}
location ~ .*\.(html)$ {
add_header Cache-Control no-cache;
}
}
-45
View File
@@ -1,45 +0,0 @@
user root;
worker_processes auto;
pcre_jit on;
error_log /var/log/nginx/error.log warn;
include /etc/nginx/modules/*.conf;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
client_max_body_size 4096m;
client_body_buffer_size 20m;
keepalive_timeout 65;
sendfile on;
tcp_nodelay on;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:2m;
gzip on;
gzip_static on;
gzip_types text/plain application/json application/javascript application/x-javascript text/css application/xml text/javascript;
gzip_proxied any;
gzip_vary on;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.0;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
include /etc/nginx/conf.d/*.conf;
}
+13 -3
View File
@@ -1,5 +1,15 @@
{
"watch": ["back", ".env"],
"watch": [
"back",
".env"
],
"ext": "js,ts,json",
"exec": "ts-node -P ./back/tsconfig.json ./back/app.ts"
}
"env": {
"NODE_ENV": "development",
"TS_NODE_PROJECT": "./back/tsconfig.json"
},
"verbose": true,
"execMap": {
"ts": "node --require ts-node/register"
}
}
+7 -8
View File
@@ -1,14 +1,14 @@
{
"private": true,
"packageManager": "pnpm@8.3.1",
"scripts": {
"start": "concurrently -n w: npm:start:*",
"start:back": "nodemon",
"start:back": "nodemon ./back/app.ts",
"start:front": "max dev",
"build:front": "max build",
"build:back": "tsc -p back/tsconfig.json",
"panel": "npm run build:back && node static/build/app.js",
"gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true,snakeToCamel=false",
"gen:api": "python3 -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. ./back/protos/api.proto",
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
"postinstall": "max setup 2>/dev/null || true",
"test": "umi-test",
@@ -55,14 +55,14 @@
}
},
"dependencies": {
"@grpc/grpc-js": "^1.12.3",
"@grpc/proto-loader": "^0.7.13",
"@grpc/grpc-js": "^1.14.0",
"@grpc/proto-loader": "^0.8.0",
"@otplib/preset-default": "^12.0.1",
"body-parser": "^1.20.3",
"celebrate": "^15.0.3",
"chokidar": "^4.0.1",
"cors": "^2.8.5",
"cron-parser": "^4.9.0",
"cron-parser": "^5.4.0",
"cross-spawn": "^7.0.6",
"dayjs": "^1.11.13",
"dotenv": "^16.4.6",
@@ -81,11 +81,10 @@
"node-schedule": "^2.1.0",
"nodemailer": "^6.9.16",
"p-queue-cjs": "7.3.4",
"@bufbuild/protobuf": "^2.2.3",
"@bufbuild/protobuf": "^2.10.0",
"ps-tree": "^1.2.0",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.5",
"serve-handler": "^6.1.6",
"sockjs": "^0.3.24",
"sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3",
"toad-scheduler": "^3.0.1",
@@ -99,7 +98,7 @@
"@keyv/sqlite": "^4.0.1",
"proper-lockfile": "^4.1.2",
"compression": "^1.7.4",
"helmet": "^6.0.1"
"helmet": "^8.1.0"
},
"devDependencies": {
"moment": "2.30.1",
+38 -76
View File
@@ -9,14 +9,14 @@ overrides:
dependencies:
'@bufbuild/protobuf':
specifier: ^2.2.3
version: 2.2.3
specifier: ^2.10.0
version: 2.10.0
'@grpc/grpc-js':
specifier: ^1.12.3
version: 1.12.3
specifier: ^1.14.0
version: 1.14.0
'@grpc/proto-loader':
specifier: ^0.7.13
version: 0.7.13
specifier: ^0.8.0
version: 0.8.0
'@keyv/sqlite':
specifier: ^4.0.1
version: 4.0.1
@@ -39,8 +39,8 @@ dependencies:
specifier: ^2.8.5
version: 2.8.5
cron-parser:
specifier: ^4.9.0
version: 4.9.0
specifier: ^5.4.0
version: 5.4.0
cross-spawn:
specifier: ^7.0.6
version: 7.0.6
@@ -63,8 +63,8 @@ dependencies:
specifier: ^2.0.3
version: 2.0.3
helmet:
specifier: ^6.0.1
version: 6.2.0
specifier: ^8.1.0
version: 8.1.0
hpagent:
specifier: ^1.2.0
version: 1.2.0
@@ -116,9 +116,6 @@ dependencies:
sequelize:
specifier: ^6.37.5
version: 6.37.5(@whyour/sqlite3@1.0.3)
serve-handler:
specifier: ^6.1.6
version: 6.1.6
sockjs:
specifier: ^0.3.24
version: 0.3.24
@@ -1389,8 +1386,8 @@ packages:
resolution: {integrity: sha512-h0OYmPR3A5Dfbetra/GzxBAzQk8sH7LhRkRUTdagX6nrtlUgJGYCTv4bBK33jsTQw9HDd8PE2x1Ma+iRKEDUsw==}
dev: true
/@bufbuild/protobuf@2.2.3:
resolution: {integrity: sha512-tFQoXHJdkEOSwj5tRIZSPNUuXK3RaR7T1nUrPgbYX1pUbvqqaaZAsfo+NXBPsz5rZMSKVFrgK1WL8Q/MSLvprg==}
/@bufbuild/protobuf@2.10.0:
resolution: {integrity: sha512-fdRs9PSrBF7QUntpZpq6BTw58fhgGJojgg39m9oFOJGZT+nip9b0so5cYY1oWl5pvemDLr0cPPsH46vwThEbpQ==}
/@chenshuai2144/sketch-color@1.0.9(react@18.3.1):
resolution: {integrity: sha512-obzSy26cb7Pm7OprWyVpgMpIlrZpZ0B7vbrU0RMbvRg0YAI890S5Xy02Aj1Nhl4+KTbi1lVYHt6HQP8Hm9s+1w==}
@@ -2585,22 +2582,22 @@ packages:
dev: false
optional: true
/@grpc/grpc-js@1.12.3:
resolution: {integrity: sha512-iaxAZnANdCwMNpJlyhkI1W1jQZIDZKFNtU2OpQDdgd+pBcU3t7G+PT7svobkW4WSZTdis+CVV6y8KIwu83HDYQ==}
/@grpc/grpc-js@1.14.0:
resolution: {integrity: sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==}
engines: {node: '>=12.10.0'}
dependencies:
'@grpc/proto-loader': 0.7.13
'@grpc/proto-loader': 0.8.0
'@js-sdsl/ordered-map': 4.4.2
dev: false
/@grpc/proto-loader@0.7.13:
resolution: {integrity: sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==}
/@grpc/proto-loader@0.8.0:
resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==}
engines: {node: '>=6'}
hasBin: true
dependencies:
lodash.camelcase: 4.3.0
long: 5.2.3
protobufjs: 7.4.0
protobufjs: 7.5.4
yargs: 17.7.2
dev: false
@@ -3876,7 +3873,7 @@ packages:
resolution: {integrity: sha512-ONIn/nSNQA57yRge3oaMQESef/6QhoeX7llWeDli0UZIfz8TQMkfNPTXA8VnnyeA1WUjG2pGqdjEIueYonMdfQ==}
deprecated: This is a stub types definition. helmet provides its own type definitions, so you do not need this installed.
dependencies:
helmet: 6.2.0
helmet: 8.1.0
dev: true
/@types/hoist-non-react-statics@3.3.5:
@@ -5962,11 +5959,6 @@ packages:
streamsearch: 1.1.0
dev: false
/bytes@3.0.0:
resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==}
engines: {node: '>= 0.8'}
dev: false
/bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -6384,11 +6376,6 @@ packages:
resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==}
dev: true
/content-disposition@0.5.2:
resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==}
engines: {node: '>= 0.6'}
dev: false
/content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
@@ -6537,6 +6524,13 @@ packages:
luxon: 3.5.0
dev: false
/cron-parser@5.4.0:
resolution: {integrity: sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA==}
engines: {node: '>=18'}
dependencies:
luxon: 3.7.2
dev: false
/croner@7.0.8:
resolution: {integrity: sha512-4E27J9ZQV9prM9ggU18QGPYPMSblbA9JuGv4Ff3Gk6supX4RszNGQxBgiFBL6wb/L9HuSMpFbQpduMiDRo+z5Q==}
engines: {node: '>=6.0'}
@@ -8480,9 +8474,9 @@ packages:
hasBin: true
dev: true
/helmet@6.2.0:
resolution: {integrity: sha512-DWlwuXLLqbrIOltR6tFQXShj/+7Cyp0gLi6uAb8qMdFh/YBBFbKSgQ6nbXmScYd8emMctuthmgIa7tUfo9Rtyg==}
engines: {node: '>=14.0.0'}
/helmet@8.1.0:
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
engines: {node: '>=18.0.0'}
/history@5.3.0:
resolution: {integrity: sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==}
@@ -9805,6 +9799,11 @@ packages:
engines: {node: '>=12'}
dev: false
/luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
dev: false
/make-dir@2.1.0:
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
engines: {node: '>=6'}
@@ -9961,11 +9960,6 @@ packages:
brorand: 1.1.0
dev: true
/mime-db@1.33.0:
resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==}
engines: {node: '>= 0.6'}
dev: false
/mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
@@ -9974,13 +9968,6 @@ packages:
resolution: {integrity: sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==}
engines: {node: '>= 0.6'}
/mime-types@2.1.18:
resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==}
engines: {node: '>= 0.6'}
dependencies:
mime-db: 1.33.0
dev: false
/mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
@@ -10776,10 +10763,6 @@ packages:
engines: {node: '>=0.10.0'}
requiresBuild: true
/path-is-inside@1.0.2:
resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==}
dev: false
/path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -10814,10 +10797,6 @@ packages:
resolution: {integrity: sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w==}
dev: true
/path-to-regexp@3.3.0:
resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==}
dev: false
/path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
dev: false
@@ -11603,8 +11582,8 @@ packages:
signal-exit: 3.0.7
dev: false
/protobufjs@7.4.0:
resolution: {integrity: sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==}
/protobufjs@7.5.4:
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
engines: {node: '>=12.0.0'}
requiresBuild: true
dependencies:
@@ -11808,11 +11787,6 @@ packages:
safe-buffer: 5.2.1
dev: true
/range-parser@1.2.0:
resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==}
engines: {node: '>= 0.6'}
dev: false
/range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -13412,18 +13386,6 @@ packages:
randombytes: 2.1.0
dev: true
/serve-handler@6.1.6:
resolution: {integrity: sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==}
dependencies:
bytes: 3.0.0
content-disposition: 0.5.2
mime-types: 2.1.18
minimatch: 3.1.2
path-is-inside: 1.0.2
path-to-regexp: 3.3.0
range-parser: 1.2.0
dev: false
/serve-static@1.16.2:
resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==}
engines: {node: '>= 0.8.0'}
@@ -14383,14 +14345,14 @@ packages:
/ts-proto-descriptors@2.0.0:
resolution: {integrity: sha512-wHcTH3xIv11jxgkX5OyCSFfw27agpInAd6yh89hKG6zqIXnjW9SYqSER2CVQxdPj4czeOhGagNvZBEbJPy7qkw==}
dependencies:
'@bufbuild/protobuf': 2.2.3
'@bufbuild/protobuf': 2.10.0
dev: true
/ts-proto@2.6.1:
resolution: {integrity: sha512-4LTT99MkwkF1+fIA0b2mZu/58Qlpq3Q1g53TwEMZZgR1w/uX00PoVT4Z8aKJxMw0LeKQD4s9NrJYsF27Clckrg==}
hasBin: true
dependencies:
'@bufbuild/protobuf': 2.2.3
'@bufbuild/protobuf': 2.10.0
case-anything: 2.1.13
ts-poet: 6.9.0
ts-proto-descriptors: 2.0.0
+8
View File
@@ -226,9 +226,17 @@ export QMSG_TYPE=""
## ntfy_url 填写ntfy地址,如https://ntfy.sh
## ntfy_topic 填写ntfy的消息应用topic
## ntfy_priority 填写推送消息优先级,默认为3
## ntfy_token 填写推送token,可选
## ntfy_username 填写推送用户名称,可选
## ntfy_password 填写推送用户密码,可选
## ntfy_actions 填写推送用户动作,可选
export NTFY_URL=""
export NTFY_TOPIC=""
export NTFY_PRIORITY="3"
export NTFY_TOKEN=""
export NTFY_USERNAME=""
export NTFY_PASSWORD=""
export NTFY_ACTIONS=""
## 21. wxPusher
## 官方文档: https://wxpusher.zjiecode.com/docs/
+33 -3
View File
@@ -52,6 +52,7 @@ const push_config = {
DD_BOT_TOKEN: '', // 钉钉机器人的 DD_BOT_TOKEN
FSKEY: '', // 飞书机器人的 FSKEY
FSSECRET: '', // 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥
// 推送到个人QQhttp://127.0.0.1/send_private_msg
// 群:http://127.0.0.1/send_group_msg
@@ -140,6 +141,10 @@ const push_config = {
NTFY_URL: '', // ntfy地址,如https://ntfy.sh,默认为https://ntfy.sh
NTFY_TOPIC: '', // ntfy的消息应用topic
NTFY_PRIORITY: '3', // 推送消息优先级,默认为3
NTFY_TOKEN: '', // 推送token,可选
NTFY_USERNAME: '', // 推送用户名称,可选
NTFY_PASSWORD: '', // 推送用户密码,可选
NTFY_ACTIONS: '', // 推送用户动作,可选
// 官方文档: https://wxpusher.zjiecode.com/docs/
// 管理后台: https://wxpusher.zjiecode.com/admin/
@@ -985,11 +990,26 @@ function aibotkNotify(text, desp) {
function fsBotNotify(text, desp) {
return new Promise((resolve) => {
const { FSKEY } = push_config;
const { FSKEY, FSSECRET } = push_config;
if (FSKEY) {
const body = { msg_type: 'text', content: { text: `${text}\n\n${desp}` } };
// Add signature if secret is provided
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
// and signs an empty message, which differs from typical HMAC usage
if (FSSECRET) {
const crypto = require('crypto');
const timestamp = Math.floor(Date.now() / 1000).toString();
const stringToSign = `${timestamp}\n${FSSECRET}`;
const hmac = crypto.createHmac('sha256', stringToSign);
const sign = hmac.digest('base64');
body.timestamp = timestamp;
body.sign = sign;
}
const options = {
url: `https://open.feishu.cn/open-apis/bot/v2/hook/${FSKEY}`,
json: { msg_type: 'text', content: { text: `${text}\n\n${desp}` } },
json: body,
headers: {
'Content-Type': 'application/json',
},
@@ -1258,7 +1278,7 @@ function ntfyNotify(text, desp) {
}
return new Promise((resolve) => {
const { NTFY_URL, NTFY_TOPIC, NTFY_PRIORITY } = push_config;
const { NTFY_URL, NTFY_TOPIC, NTFY_PRIORITY, NTFY_TOKEN, NTFY_USERNAME, NTFY_PASSWORD, NTFY_ACTIONS } = push_config;
if (NTFY_TOPIC) {
const options = {
url: `${NTFY_URL || 'https://ntfy.sh'}/${NTFY_TOPIC}`,
@@ -1266,9 +1286,19 @@ function ntfyNotify(text, desp) {
headers: {
Title: `${encodeRFC2047(text)}`,
Priority: NTFY_PRIORITY || '3',
Icon: 'https://qn.whyour.cn/logo.png',
},
timeout,
};
if (NTFY_TOKEN) {
options.headers['Authorization'] = `Bearer ${NTFY_TOKEN}`;
} else if (NTFY_USERNAME && NTFY_PASSWORD) {
options.headers['Authorization'] = `Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`;
}
if (NTFY_ACTIONS) {
options.headers['Actions'] = encodeRFC2047(NTFY_ACTIONS);
}
$.post(options, (err, resp, data) => {
try {
if (err) {
+27 -1
View File
@@ -49,6 +49,7 @@ push_config = {
'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN
'FSKEY': '', # 飞书机器人的 FSKEY
'FSSECRET': '', # 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥
'GOBOT_URL': '', # go-cqhttp
# 推送到个人QQhttp://127.0.0.1/send_private_msg
@@ -126,6 +127,10 @@ push_config = {
'NTFY_URL': '', # ntfy地址,如https://ntfy.sh
'NTFY_TOPIC': '', # ntfy的消息应用topic
'NTFY_PRIORITY':'3', # 推送消息优先级,默认为3
'NTFY_TOKEN': '', # 推送token,可选
'NTFY_USERNAME': '', # 推送用户名称,可选
'NTFY_PASSWORD': '', # 推送用户密码,可选
'NTFY_ACTIONS': '', # 推送用户动作,可选
'WXPUSHER_APP_TOKEN': '', # wxpusher 的 appToken 官方文档: https://wxpusher.zjiecode.com/docs/ 管理后台: https://wxpusher.zjiecode.com/admin/
'WXPUSHER_TOPIC_IDS': '', # wxpusher 的 主题ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
@@ -229,6 +234,20 @@ def feishu_bot(title: str, content: str) -> None:
url = f'https://open.feishu.cn/open-apis/bot/v2/hook/{push_config.get("FSKEY")}'
data = {"msg_type": "text", "content": {"text": f"{title}\n\n{content}"}}
# Add signature if secret is provided
# Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
# and signs an empty message, which differs from typical HMAC usage
if push_config.get("FSSECRET"):
timestamp = str(int(time.time()))
string_to_sign = f'{timestamp}\n{push_config.get("FSSECRET")}'
hmac_code = hmac.new(
string_to_sign.encode("utf-8"), digestmod=hashlib.sha256
).digest()
sign = base64.b64encode(hmac_code).decode("utf-8")
data["timestamp"] = timestamp
data["sign"] = sign
response = requests.post(url, data=json.dumps(data)).json()
if response.get("StatusCode") == 0 or response.get("code") == 0:
@@ -806,7 +825,14 @@ def ntfy(title: str, content: str) -> None:
encoded_title = encode_rfc2047(title)
data = content.encode(encoding="utf-8")
headers = {"Title": encoded_title, "Priority": priority} # 使用编码后的 title
headers = {"Title": encoded_title, "Priority": priority, "Icon": "https://qn.whyour.cn/logo.png"} # 使用编码后的 title
if push_config.get("NTFY_TOKEN"):
headers['Authorization'] = "Bearer " + push_config.get("NTFY_TOKEN")
elif push_config.get("NTFY_USERNAME") and push_config.get("NTFY_PASSWORD"):
authStr = push_config.get("NTFY_USERNAME") + ":" + push_config.get("NTFY_PASSWORD")
headers['Authorization'] = "Basic " + base64.b64encode(authStr.encode('utf-8')).decode('utf-8')
if push_config.get("NTFY_ACTIONS"):
headers['Actions'] = encode_rfc2047(push_config.get("NTFY_ACTIONS"))
url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC")
response = requests.post(url, data=data, headers=headers)
+28
View File
@@ -12,4 +12,32 @@ QLAPI.getEnvs({ searchValue: 'dddd' }).then((x) => {
QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => {
console.log('systemNotify', x);
});
// 查询定时任务 (Query cron tasks)
QLAPI.getCrons({ searchValue: 'test' }).then((x) => {
console.log('getCrons', x);
});
// 通过ID查询定时任务 (Get cron by ID)
QLAPI.getCronById({ id: 1 }).then((x) => {
console.log('getCronById', x);
}).catch((err) => {
console.log('getCronById error', err);
});
// 启用定时任务 (Enable cron tasks)
QLAPI.enableCrons({ ids: [1, 2] }).then((x) => {
console.log('enableCrons', x);
});
// 禁用定时任务 (Disable cron tasks)
QLAPI.disableCrons({ ids: [1, 2] }).then((x) => {
console.log('disableCrons', x);
});
// 手动执行定时任务 (Run cron tasks manually)
QLAPI.runCrons({ ids: [1] }).then((x) => {
console.log('runCrons', x);
});
console.log('test desc');
+8 -48
View File
@@ -41,14 +41,9 @@ add_cron_api() {
fi
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons?t=$currentTimeStamp" \
-H "Accept: application/json" \
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \
--compressed
)
@@ -76,15 +71,10 @@ update_cron_api() {
fi
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
-X 'PUT' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"id\":\"$id\"}" \
--compressed
)
@@ -108,15 +98,10 @@ update_cron_command_api() {
fi
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
-X 'PUT' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{\"command\":\"${command//\"/\\\"}\",\"id\":\"$id\"}" \
--compressed
)
@@ -133,15 +118,10 @@ del_cron_api() {
local ids="$1"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
-X 'DELETE' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "[$ids]" \
--compressed
)
@@ -163,15 +143,10 @@ update_cron() {
local runningTime="${6:-0}"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons/status?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons/status?t=$currentTimeStamp" \
-X 'PUT' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{\"ids\":[$ids],\"status\":\"$status\",\"pid\":\"$pid\",\"log_path\":\"$logPath\",\"last_execution_time\":$lastExecutingTime,\"last_running_time\":$runningTime}" \
--compressed
)
@@ -190,15 +165,10 @@ notify_api() {
local content="$2"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/system/notify?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/system/notify?t=$currentTimeStamp" \
-X 'PUT' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{\"title\":\"${title//\"/\\\"}\",\"content\":\"${content//\"/\\\"}\"}" \
--compressed
)
@@ -215,14 +185,9 @@ find_cron_api() {
local params="$1"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons/detail?$params&t=$currentTimeStamp" \
-H "Accept: application/json" \
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons/detail?$params&t=$currentTimeStamp" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--compressed
)
data=$(echo "$api" | jq -r .data)
@@ -239,15 +204,10 @@ update_auth_config() {
local tip="$2"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/system/auth/reset?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \
-X 'PUT' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{$body}" \
--compressed
)
+5 -22
View File
@@ -20,34 +20,18 @@ copy_dep() {
echo -e "---> 复制一份 $file_notify_js_sample$file_notify_js\n"
cp -fv $file_notify_js_sample $file_notify_js
echo -e "---> 通知文件复制完成\n"
echo -e "---> 2. 复制nginx配置文件\n"
init_nginx
echo -e "---> 配置文件复制完成\n"
}
pm2_log() {
echo -e "---> pm2日志"
local panelOut="/root/.pm2/logs/panel-out.log"
local panelError="/root/.pm2/logs/panel-error.log"
local panelOut="/root/.pm2/logs/qinglong-out.log"
local panelError="/root/.pm2/logs/qinglong-error.log"
tail -n 300 "$panelOut"
tail -n 300 "$panelError"
}
check_nginx() {
local nginxPid=$(ps -eo pid,command | grep nginx | grep -v grep)
echo -e "=====> 检测nginx服务\n$nginxPid"
if [[ $nginxPid ]]; then
echo -e "\n=====> nginx服务正常\n"
nginx -s reload
else
echo -e "\n=====> nginx服务异常,重新启动nginx\n"
nginx -c /etc/nginx/nginx.conf
fi
}
check_ql() {
local api=$(curl -s --noproxy "*" "http://0.0.0.0:5700")
local api=$(curl -s --noproxy "*" "http://0.0.0.0:${ql_port}")
echo -e "\n=====> 检测面板\n\n$api\n"
if [[ $api =~ "<div id=\"root\"></div>" ]]; then
echo -e "=====> 面板服务启动正常\n"
@@ -58,10 +42,10 @@ check_pm2() {
pm2_log
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/api/system?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/api/system?t=$currentTimeStamp" \
-H 'Accept: */*' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \
-H 'Referer: http://0.0.0.0:5700/crontab' \
-H "Referer: http://0.0.0.0:${ql_port}/crontab" \
-H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \
--compressed
)
@@ -78,7 +62,6 @@ main() {
reset_env
copy_dep
check_ql
check_nginx
check_pm2
reload_pm2
echo -e "\n=====> 检测结束\n"
+6 -1
View File
@@ -5,7 +5,7 @@ const { join } = require('path');
class GrpcClient {
static #config = {
protoPath: join(process.env.QL_DIR, 'back/protos/api.proto'),
serverAddress: '0.0.0.0:5500',
serverAddress: `0.0.0.0:${process.env.GRPC_PORT || '5500'}`,
protoOptions: {
keepCase: true,
longs: String,
@@ -33,6 +33,11 @@ class GrpcClient {
'createCron',
'updateCron',
'deleteCrons',
'getCrons',
'getCronById',
'enableCrons',
'disableCrons',
'runCrons',
];
#client;
+2 -4
View File
@@ -87,12 +87,10 @@ function run() {
console.log('执行前置命令结束\n');
}
} catch (error) {
if (!error.message.includes('spawnSync /bin/sh E2BIG')) {
if (!error.message.includes('spawnSync /bin/bash E2BIG')) {
console.log(`\ue926 run task before error: `, error);
} else {
console.log(
`\ue926 The environment variable is too large. It is recommended to use task_before.js instead of task_before.sh\n`,
);
// environment variable is too large
}
if (task_before) {
console.log('执行前置命令结束\n');
+2 -4
View File
@@ -98,10 +98,8 @@ def run():
error_message = str(error)
if "Argument list too long" not in error_message:
print(f"\ue926 run task before error: {error}")
else:
print(
"\ue926 The environment variable is too large. It is recommended to use task_before.py instead of task_before.sh\n"
)
# else:
# environment variable is too large
if task_before:
print("执行前置命令结束\n")
except Exception as error:
+3 -7
View File
@@ -2,13 +2,9 @@
echo -e "开始发布"
echo -e "切换master分支"
git checkout master
echo -e "合并develop代码"
git merge origin/develop
echo -e "提交master代码"
git push
git branch -D master
git checkout -b master
git push --set-upstream origin master -f
echo -e "更新cdn文件"
ts-node-transpile-only sample/tool.ts
+32 -55
View File
@@ -48,8 +48,6 @@ export file_notify_py=$dir_scripts/notify.py
export file_notify_js=$dir_scripts/sendNotify.js
export file_test_js=$dir_scripts/ql_sample.js
export file_test_py=$dir_scripts/ql_sample.py
export nginx_app_conf=$dir_root/docker/front.conf
export nginx_conf=$dir_root/docker/nginx.conf
export dep_notify_py=$dir_dep/notify.py
export dep_notify_js=$dir_dep/sendNotify.js
@@ -61,15 +59,10 @@ list_own_user=$dir_list_tmp/own_user.list
list_own_add=$dir_list_tmp/own_add.list
list_own_drop=$dir_list_tmp/own_drop.list
## 软连接及其原始文件对应关系
link_name=(
task
ql
)
original_name=(
task.sh
update.sh
)
init_env() {
local pnpm_global_path=$(pnpm root -g 2>/dev/null)
@@ -86,15 +79,20 @@ init_env() {
export PYTHONUNBUFFERED=1
}
load_ql_envs() {
ql_base_url=${QlBaseUrl:-"/"}
ql_port=${QlPort:-"5700"}
ql_grpc_port=${QlGrpcPort:-"5500"}
current_branch=${QL_BRANCH:-""}
}
import_config() {
[[ -f $file_config_user ]] && . $file_config_user
ql_base_url=${QlBaseUrl:-"/"}
ql_port=${QlPort:-"5700"}
load_ql_envs
command_timeout_time=${CommandTimeoutTime:-""}
file_extensions=${RepoFileExtensions:-"js py"}
proxy_url=${ProxyUrl:-""}
current_branch=${QL_BRANCH:-""}
if [[ -n "${DefaultCronRule}" ]]; then
default_cron="${DefaultCronRule}"
@@ -211,10 +209,6 @@ fix_config() {
cp -f $file_test_py_sample $file_test_py
fi
if [[ -s /etc/nginx/conf.d/default.conf ]]; then
cat /dev/null >/etc/nginx/conf.d/default.conf
fi
if [[ ! -s $dep_notify_js ]]; then
cp -f $file_notify_js_sample $dep_notify_js
fi
@@ -278,14 +272,35 @@ random_range() {
delete_pm2() {
cd $dir_root
pm2 delete ecosystem.config.js
# Try to delete PM2 processes, but don't fail if PM2 is not available
pm2 delete ecosystem.config.js 2>/dev/null || true
# Also try to kill any directly spawned node processes
pkill -f "node.*static/build/app.js" 2>/dev/null || true
}
reload_pm2() {
cd $dir_root
restore_env_vars
pm2 flush &>/dev/null
pm2 startOrGracefulReload ecosystem.config.js --update-env
# Try to start PM2, but handle failures gracefully
if pm2 flush &>/dev/null && pm2 startOrGracefulReload ecosystem.config.js --update-env; then
return 0
else
local exit_code=$?
echo "警告: PM2 启动失败 (退出码: $exit_code),可能是由于硬件不兼容"
echo "正在尝试直接使用 Node.js 启动服务..."
# Kill any existing node processes for qinglong
pkill -f "node.*static/build/app.js" 2>/dev/null || true
# Start node directly in the background
nohup node static/build/app.js > $dir_log/qinglong.log 2>&1 &
local node_pid=$!
echo "已使用 Node.js 直接启动服务 (PID: $node_pid)"
echo "注意: 使用此模式时,部分 PM2 管理功能将不可用"
return 0
fi
}
diff_time() {
@@ -334,44 +349,6 @@ format_timestamp() {
fi
}
init_nginx() {
cp -f $nginx_conf /etc/nginx/nginx.conf
cp -f $nginx_app_conf /etc/nginx/conf.d/front.conf
local location_url="/"
local aliasStr=""
local rootStr=""
if [[ $ql_base_url != "/" ]]; then
if [[ $ql_base_url != /* ]]; then
ql_base_url="/$ql_base_url"
fi
if [[ $ql_base_url != */ ]]; then
ql_base_url="$ql_base_url/"
fi
location_url="^~${ql_base_url%*/}"
aliasStr="alias ${dir_static}/dist;"
if ! grep -q "<base href=\"$ql_base_url\">" "${dir_static}/dist/index.html"; then
awk -v text="<base href=\"$ql_base_url\">" '/<link/ && !inserted {print text; inserted=1} 1' "${dir_static}/dist/index.html" >temp.html
mv temp.html "${dir_static}/dist/index.html"
fi
else
rootStr="root ${dir_static}/dist;"
fi
sed -i "s,QL_ALIAS_CONFIG,${aliasStr},g" /etc/nginx/conf.d/front.conf
sed -i "s,QL_ROOT_CONFIG,${rootStr},g" /etc/nginx/conf.d/front.conf
sed -i "s,QL_BASE_URL_LOCATION,${location_url},g" /etc/nginx/conf.d/front.conf
sed -i "s,QL_BASE_URL,${ql_base_url},g" /etc/nginx/conf.d/front.conf
local ipv6=$(ip a | grep inet6)
local ipv6Str=""
if [[ $ipv6 ]]; then
ipv6Str="listen [::]:${ql_port} ipv6only=on;"
fi
local ipv4Str="listen ${ql_port};"
sed -i "s,IPV6_CONFIG,${ipv6Str},g" /etc/nginx/conf.d/front.conf
sed -i "s,IPV4_CONFIG,${ipv4Str},g" /etc/nginx/conf.d/front.conf
}
get_env_array() {
exported_variables=()
while IFS= read -r line; do
+19 -10
View File
@@ -46,18 +46,22 @@ handle_log_path() {
time=$(date "+$mtime_format")
log_time=$(format_log_time "$mtime_format" "$time")
log_dir_tmp="${file_param##*/}"
if [[ $file_param =~ "/" ]]; then
if [[ $file_param == /* ]]; then
log_dir_tmp_path="${file_param:1}"
else
log_dir_tmp_path="${file_param}"
if [[ -z $log_name ]]; then
log_dir_tmp="${file_param##*/}"
if [[ $file_param =~ "/" ]]; then
if [[ $file_param == /* ]]; then
log_dir_tmp_path="${file_param:1}"
else
log_dir_tmp_path="${file_param}"
fi
fi
log_dir_tmp_path="${log_dir_tmp_path%/*}"
log_dir_tmp_path="${log_dir_tmp_path##*/}"
[[ $log_dir_tmp_path ]] && log_dir_tmp="${log_dir_tmp_path}_${log_dir_tmp}"
log_dir="${log_dir_tmp%.*}${suffix}"
else
log_dir="$log_name"
fi
log_dir_tmp_path="${log_dir_tmp_path%/*}"
log_dir_tmp_path="${log_dir_tmp_path##*/}"
[[ $log_dir_tmp_path ]] && log_dir_tmp="${log_dir_tmp_path}_${log_dir_tmp}"
log_dir="${log_dir_tmp%.*}${suffix}"
log_path="$log_dir/$log_time.log"
if [[ ${real_log_path:=} ]]; then
@@ -73,6 +77,11 @@ handle_log_path() {
if [[ "${real_time:=}" == "true" ]]; then
cmd=""
fi
if [[ "${log_dir:=}" == "/dev/null" ]]; then
cmd=">> /dev/null"
log_path="/dev/null"
fi
}
format_params() {
+1
View File
@@ -3,6 +3,7 @@
dir_shell=$QL_DIR/shell
. $dir_shell/share.sh
. $dir_shell/api.sh
load_ql_envs
. $dir_shell/env.sh
send_mark=$dir_shell/send_mark
+12 -5
View File
@@ -7,11 +7,18 @@ export function rootContainer(container: any) {
'en': require('./locales/en-US.json'),
'zh': require('./locales/zh-CN.json'),
};
let currentLocale = intl.determineLocale({
urlLocaleKey: 'lang',
cookieLocaleKey: 'lang',
localStorageLocaleKey: 'lang',
}).slice(0, 2);
let currentLocale: string;
try {
currentLocale = intl.determineLocale({
urlLocaleKey: 'lang',
cookieLocaleKey: 'lang',
localStorageLocaleKey: 'lang',
}).slice(0, 2);
} catch (e: unknown) {
// Handle decodeURIComponent errors from malformed cookies
console.warn('Failed to determine locale from cookies:', e);
currentLocale = '';
}
if (!currentLocale || !Object.keys(locales).includes(currentLocale)) {
currentLocale = 'zh';
+16 -11
View File
@@ -1,6 +1,6 @@
import intl from 'react-intl-universal';
import React, { useRef, useState, useEffect } from 'react';
import { Tooltip, Typography } from 'antd';
import { Tooltip, Typography, message } from 'antd';
import { CopyOutlined, CheckOutlined } from '@ant-design/icons';
import { CopyToClipboard } from 'react-copy-to-clipboard';
@@ -10,16 +10,21 @@ const Copy = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false);
const copyIdRef = useRef<number>();
const copyText = (e?: React.MouseEvent) => {
const handleCopy = (text: string, result: boolean) => {
if (result) {
setCopied(true);
message.success(intl.get('复制成功'));
cleanCopyId();
copyIdRef.current = window.setTimeout(() => {
setCopied(false);
}, 3000);
}
};
const handleClick = (e?: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
setCopied(true);
cleanCopyId();
copyIdRef.current = window.setTimeout(() => {
setCopied(false);
}, 3000);
};
const cleanCopyId = () => {
@@ -27,8 +32,8 @@ const Copy = ({ text }: { text: string }) => {
};
return (
<Link onClick={copyText} style={{ marginLeft: 1 }}>
<CopyToClipboard text={text}>
<Link onClick={handleClick} style={{ marginLeft: 4 }}>
<CopyToClipboard text={text} onCopy={handleCopy}>
<Tooltip
key="copy"
title={copied ? intl.get('复制成功') : intl.get('复制')}
+2
View File
@@ -113,6 +113,8 @@ export default function () {
const responseStatus = error.response.status;
if (responseStatus !== 401) {
history.push('/error');
} else {
window.location.reload();
}
})
.finally(() => setInitLoading(false));
+51 -4
View File
@@ -104,7 +104,7 @@
"序号": "Number",
"备注": "Remarks",
"更新时间": "Update Time",
"创建时间": "Creation Time",
"创建时间": "Created Time",
"确认删除依赖": "Confirm to delete the dependency",
"确认重新安装": "Confirm to reinstall",
"确认取消安装": "Confirm to cancel install",
@@ -252,7 +252,7 @@
"登录日志": "Login Logs",
"其他设置": "Other Settings",
"关于": "About",
"成功": "Success",
"成功": "Successfully",
"失败": "Failure",
"登录时间": "Login Time",
"登录地址": "Login Address",
@@ -389,13 +389,18 @@
"消息接收人": "message recipient",
"调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "Version, you can specify 'pro' for the Professional version and 'personal' for the Personal version. If left blank, it will default to the Professional version.",
"飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "Feishu group bot: https://www.feishu.cn/hc/zh-CN/articles/360024984973",
"飞书群组机器人加签密钥,安全设置中开启签名校验后获得": "Feishu group bot signature secret, obtained after enabling signature verification in security settings",
"邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "Email service name, e.g., 126, 163, Gmail, QQ, etc. Supported list: https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json",
"邮箱地址": "Email Address",
"SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "The SMTP login password may also be a special passphrase, depending on the specific email service provider's instructions",
"PushMe的Keyhttps://push.i-i.me/": "PushMe key, https://push.i-i.me/",
"自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口": "The self built PushMeServer message interface address, for example: http://127.0.0.1:3010 If left blank, use the official message interface",
"ntfy的url地址,例如 https://ntfy.sh": "The URL address of ntfy, for example, https://ntfy.sh.",
"ntfy的消息应用topic": "The topic for ntfy's messaging application.",
"ntfy应用topic": "The topic for ntfy's application.",
"ntfy应用token": "The token for ntfy's application, see https://docs.ntfy.sh/config/#access-tokens",
"ntfy应用用户名": "The username for ntfy's application, see https://docs.ntfy.sh/config/#users-and-roles",
"ntfy应用密码": "The password for ntfy's application, see https://docs.ntfy.sh/config/#users-and-roles",
"ntfy用户动作": "The user actions for ntfy's application, up to three actions, see https://docs.ntfy.sh/publish/?h=actions#action-buttons",
"wxPusherBot的appToken": "wxPusherBot's appToken, obtain according to docs https://wxpusher.zjiecode.com/docs/",
"wxPusherBot的topicIds": "wxPusherBot's topicIds, at least one of topicIds or uids must be configured",
"wxPusherBot的uids": "wxPusherBot's uids, at least one of topicIds or uids must be configured",
@@ -506,5 +511,47 @@
"强制打开可能会导致编辑器显示异常": "Force opening may cause display issues in the editor",
"确认离开": "Confirm Leave",
"当前文件未保存,确认离开吗": "Current file is not saved, are you sure to leave?",
"收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址": "Receiving email address, multiple semicolon separated, sent to the sending email address by default"
"收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址": "Receiving email address, multiple semicolon separated, sent to the sending email address by default",
"选择备份模块": "Select backup module",
"开始备份": "Start backup",
"基础数据": "Basic data",
"脚本文件": "Script files",
"日志文件": "Log files",
"依赖缓存": "Dependency cache",
"远程脚本缓存": "Remote script cache",
"远程仓库缓存": "Remote repository cache",
"SSH 文件缓存": "SSH file cache",
"清除依赖缓存": "Clean dependency cache",
"清除成功": "Clean successful",
"日志名称": "Log Name",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports absolute paths like /dev/null",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports /dev/null to discard logs, other absolute paths must be within log directory",
"请输入自定义日志文件夹名称": "Please enter a custom log folder name",
"请输入自定义日志文件夹名称或绝对路径": "Please enter a custom log folder name or absolute path",
"请输入自定义日志文件夹名称或 /dev/null": "Please enter a custom log folder name or /dev/null",
"日志名称只能包含字母、数字、下划线和连字符": "Log name can only contain letters, numbers, underscores and hyphens",
"日志名称不能超过100个字符": "Log name cannot exceed 100 characters",
"未启用": "Not enabled",
"默认为 CPU 个数": "Default is the number of CPUs",
"Minimum is 4": "Minimum is 4",
"实例模式": "Instance Mode",
"单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "Single instance mode: automatically stop old task before starting new scheduled task; Multi-instance mode: allow multiple task instances to run simultaneously",
"请选择实例模式": "Please select instance mode",
"单实例": "Single Instance",
"多实例": "Multi-Instance",
"SSH密钥": "SSH Keys",
"别名": "Alias",
"编辑SSH密钥": "Edit SSH Key",
"创建SSH密钥": "Create SSH Key",
"更新SSH密钥成功": "SSH key updated successfully",
"创建SSH密钥成功": "SSH key created successfully",
"请输入SSH密钥别名": "Please enter SSH key alias",
"请输入SSH私钥": "Please enter SSH private key",
"请输入SSH私钥内容(以 -----BEGIN 开头)": "Please enter SSH private key content (starts with -----BEGIN)",
"确认删除SSH密钥": "Confirm to delete SSH key",
"批量": "Batch",
"全局SSH私钥": "Global SSH Private Key",
"用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories",
"请输入完整的SSH私钥内容": "Please enter the complete SSH private key content"
}
+49 -3
View File
@@ -389,13 +389,18 @@
"消息接收人": "消息接收人",
"调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版",
"飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973",
"飞书群组机器人加签密钥,安全设置中开启签名校验后获得": "飞书群组机器人加签密钥,安全设置中开启签名校验后获得",
"邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json",
"邮箱地址": "邮箱地址",
"SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定",
"PushMe的Keyhttps://push.i-i.me/": "PushMe的Keyhttps://push.i-i.me/",
"自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口": "自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口",
"ntfy的url地址,例如 https://ntfy.sh": "ntfy的url地址,例如 https://ntfy.sh",
"ntfy的消息应用topic": "ntfy的消息应用topic",
"ntfy应用topic": "ntfy应用topic",
"ntfy应用token": "ntfy应用token,参考 https://docs.ntfy.sh/config/#access-tokens",
"ntfy应用用户名": "ntfy应用用户名,参考 https://docs.ntfy.sh/config/#users-and-roles",
"ntfy应用密码": "ntfy应用密码,参考 https://docs.ntfy.sh/config/#users-and-roles",
"ntfy用户动作": "ntfy用户动作,最多三个动作,参考 https://docs.ntfy.sh/publish/?h=actions#action-buttons",
"wxPusherBot的appToken": "wxPusherBot的appToken, 按照文档获取 https://wxpusher.zjiecode.com/docs/",
"wxPusherBot的topicIds": "wxPusherBot的topicIds, topicIds 和 uids 至少配置一个才行",
"wxPusherBot的uids": "wxPusherBot的uids, topicIds 和 uids 至少配置一个才行",
@@ -506,6 +511,47 @@
"强制打开可能会导致编辑器显示异常": "强制打开可能会导致编辑器显示异常",
"确认离开": "确认离开",
"当前文件未保存,确认离开吗": "当前文件未保存,确认离开吗",
"收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址": "收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址"
"收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址": "收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址",
"选择备份模块": "选择备份模块",
"开始备份": "开始备份",
"基础数据": "基础数据",
"脚本文件": "脚本文件",
"日志文件": "日志文件",
"依赖缓存": "依赖缓存",
"远程脚本缓存": "远程脚本缓存",
"远程仓库缓存": "远程仓库缓存",
"SSH 文件缓存": "SSH 文件缓存",
"清除依赖缓存": "清除依赖缓存",
"清除成功": "清除成功",
"日志名称": "日志名称",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内",
"请输入自定义日志文件夹名称": "请输入自定义日志文件夹名称",
"请输入自定义日志文件夹名称或绝对路径": "请输入自定义日志文件夹名称或绝对路径",
"请输入自定义日志文件夹名称或 /dev/null": "请输入自定义日志文件夹名称或 /dev/null",
"日志名称只能包含字母、数字、下划线和连字符": "日志名称只能包含字母、数字、下划线和连字符",
"日志名称不能超过100个字符": "日志名称不能超过100个字符",
"未启用": "未启用",
"默认为 CPU 个数": "默认为 CPU 个数",
"最小是 4": "最小是 4",
"实例模式": "实例模式",
"单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例",
"请选择实例模式": "请选择实例模式",
"单实例": "单实例",
"多实例": "多实例",
"SSH密钥": "SSH密钥",
"别名": "别名",
"编辑SSH密钥": "编辑SSH密钥",
"创建SSH密钥": "创建SSH密钥",
"更新SSH密钥成功": "更新SSH密钥成功",
"创建SSH密钥成功": "创建SSH密钥成功",
"请输入SSH密钥别名": "请输入SSH密钥别名",
"请输入SSH私钥": "请输入SSH私钥",
"请输入SSH私钥内容(以 -----BEGIN 开头)": "请输入SSH私钥内容(以 -----BEGIN 开头)",
"确认删除SSH密钥": "确认删除SSH密钥",
"批量": "批量",
"全局SSH私钥": "全局SSH私钥",
"用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥",
"请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容"
}
+32 -17
View File
@@ -66,6 +66,7 @@ const SHOW_TAB_COUNT = 10;
const Crontab = () => {
const { headerStyle, isPhone, theme } = useOutletContext<SharedContext>();
const [allSubscriptions, setAllSubscriptions] = useState<any[]>([]);
const columns: ColumnProps<ICrontab>[] = [
{
title: intl.get('名称'),
@@ -247,8 +248,8 @@ const Crontab = () => {
>
{record.last_execution_time
? dayjs(record.last_execution_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)
'YYYY-MM-DD HH:mm:ss',
)
: '-'}
</span>
);
@@ -272,6 +273,12 @@ const Crontab = () => {
title: intl.get('关联订阅'),
width: 185,
render: (text, record: any) => record?.subscription?.name || '-',
key: 'sub_id',
dataIndex: 'sub_id',
filters: allSubscriptions.map((sub) => ({
text: sub.name || sub.alias,
value: sub.id,
})),
},
{
title: intl.get('操作'),
@@ -361,11 +368,10 @@ const Crontab = () => {
const getCrons = () => {
setLoading(true);
const { page, size, sorter, filters } = pageConf;
let url = `${
config.apiPrefix
}crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify(
filters,
)}`;
let url = `${config.apiPrefix
}crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify(
filters,
)}`;
if (sorter && sorter.column && sorter.order) {
url += `&sorter=${JSON.stringify({
field: sorter.column.key,
@@ -523,9 +529,8 @@ const Crontab = () => {
const enabledOrDisabledCron = (record: any, index: number) => {
Modal.confirm({
title: `确认${
record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
content: (
<>
{intl.get('确认')}
@@ -540,8 +545,7 @@ const Crontab = () => {
onOk() {
request
.put(
`${config.apiPrefix}crons/${
record.isDisabled === 1 ? 'enable' : 'disable'
`${config.apiPrefix}crons/${record.isDisabled === 1 ? 'enable' : 'disable'
}`,
[record.id],
)
@@ -565,9 +569,8 @@ const Crontab = () => {
const pinOrUnPinCron = (record: any, index: number) => {
Modal.confirm({
title: `确认${
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
content: (
<>
{intl.get('确认')}
@@ -582,8 +585,7 @@ const Crontab = () => {
onOk() {
request
.put(
`${config.apiPrefix}crons/${
record.isPinned === 1 ? 'unpin' : 'pin'
`${config.apiPrefix}crons/${record.isPinned === 1 ? 'unpin' : 'pin'
}`,
[record.id],
)
@@ -799,8 +801,20 @@ const Crontab = () => {
}
}, [viewConf, enabledCronViews]);
const getAllSubscriptions = () => {
request
.get(`${config.apiPrefix}subscriptions`)
.then(({ code, data }) => {
if (code === 200) {
setAllSubscriptions(data || []);
}
})
.catch(() => {});
};
useEffect(() => {
getCronViews();
getAllSubscriptions();
}, []);
const viewAction = (key: string) => {
@@ -1014,6 +1028,7 @@ const Crontab = () => {
)}
<Table
columns={columns}
sortDirections={['descend', 'ascend']}
pagination={{
current: pageConf.page,
pageSize: pageConf.size,
+23 -23
View File
@@ -1,5 +1,5 @@
import intl from 'react-intl-universal';
import React, { useEffect, useRef, useState } from 'react';
import intl from "react-intl-universal";
import React, { useEffect, useRef, useState } from "react";
import {
Modal,
message,
@@ -8,17 +8,17 @@ import {
Statistic,
Button,
Typography,
} from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
} from "antd";
import { request } from "@/utils/http";
import config from "@/utils/config";
import {
Loading3QuartersOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import { PageLoading } from '@ant-design/pro-layout';
import { logEnded } from '@/utils';
import { CrontabStatus } from './type';
import Ansi from 'ansi-to-react';
} from "@ant-design/icons";
import { PageLoading } from "@ant-design/pro-layout";
import { logEnded } from "@/utils";
import { CrontabStatus } from "./type";
import Ansi from "ansi-to-react";
const { Countdown } = Statistic;
@@ -33,7 +33,7 @@ const CronLogModal = ({
data?: string;
logUrl?: string;
}) => {
const [value, setValue] = useState<string>(intl.get('启动中...'));
const [value, setValue] = useState<string>(intl.get("启动中..."));
const [loading, setLoading] = useState<any>(true);
const [executing, setExecuting] = useState<any>(true);
const [isPhone, setIsPhone] = useState(false);
@@ -49,15 +49,15 @@ const CronLogModal = ({
.then(({ code, data }) => {
if (
code === 200 &&
localStorage.getItem('logCron') === uniqPath &&
localStorage.getItem("logCron") === uniqPath &&
data !== value
) {
const log = data as string;
setValue(log || intl.get('暂无日志'));
setValue(log || intl.get("暂无日志"));
const hasNext = Boolean(
log && !logEnded(log) && !log.includes('任务未运行'),
log && !logEnded(log) && !log.includes("日志不存在") && !log.includes("日志设置为忽略"),
);
if (!hasNext && !logEnded(value) && value !== intl.get('启动中...')) {
if (!hasNext && !logEnded(value) && value !== intl.get("启动中...")) {
setTimeout(() => {
autoScroll();
});
@@ -85,13 +85,13 @@ const CronLogModal = ({
setTimeout(() => {
document
.querySelector('#log-flag')!
.scrollIntoView({ behavior: 'smooth' });
.querySelector("#log-flag")
?.scrollIntoView({ behavior: "smooth" });
}, 600);
};
const cancel = () => {
localStorage.removeItem('logCron');
localStorage.removeItem("logCron");
handleCancel();
};
@@ -107,7 +107,7 @@ const CronLogModal = ({
const titleElement = () => {
return (
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: "flex", alignItems: "center" }}>
{(executing || loading) && <Loading3QuartersOutlined spin />}
{!executing && !loading && <CheckCircleOutlined />}
<Typography.Text ellipsis={true} style={{ marginLeft: 5 }}>
@@ -144,7 +144,7 @@ const CronLogModal = ({
onCancel={() => cancel()}
footer={[
<Button type="primary" onClick={() => cancel()}>
{intl.get('知道了')}
{intl.get("知道了")}
</Button>,
]}
>
@@ -156,9 +156,9 @@ const CronLogModal = ({
style={
isPhone
? {
fontFamily: 'Source Code Pro',
zoom: 0.83,
}
fontFamily: "Source Code Pro",
zoom: 0.83,
}
: {}
}
>
+47 -3
View File
@@ -3,7 +3,7 @@ import config from '@/utils/config';
import { request } from '@/utils/http';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Form, Input, Modal, Select, Space, message } from 'antd';
import cronParse from 'cron-parser';
import { CronExpressionParser } from 'cron-parser';
import { useEffect, useState } from 'react';
import intl from 'react-intl-universal';
import { getScheduleType, scheduleTypeMap } from './const';
@@ -91,7 +91,7 @@ const CronModal = ({
{ required: true },
{
validator: (_, value) => {
if (!value || cronParse.parseExpression(value).hasNext()) {
if (!value || CronExpressionParser.parse(value).hasNext()) {
return Promise.resolve();
}
return Promise.reject(intl.get('Cron表达式格式有误'));
@@ -180,6 +180,51 @@ const CronModal = ({
<Form.Item name="labels" label={intl.get('标签')}>
<EditableTagGroup />
</Form.Item>
<Form.Item
name="allow_multiple_instances"
label={intl.get('实例模式')}
tooltip={intl.get(
'单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例',
)}
>
<Select placeholder={intl.get('请选择实例模式')}>
<Select.Option value={0}>{intl.get('单实例')}</Select.Option>
<Select.Option value={1}>{intl.get('多实例')}</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="log_name"
label={intl.get('日志名称')}
tooltip={intl.get(
'自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内',
)}
rules={[
{
validator: (_, value) => {
if (!value) return Promise.resolve();
if (value === '/dev/null') return Promise.resolve();
if (value.length > 100) {
return Promise.reject(intl.get('日志名称不能超过100个字符'));
}
if (
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
value,
)
) {
return Promise.reject(
intl.get('日志名称只能包含字母、数字、下划线和连字符'),
);
}
return Promise.resolve();
},
},
]}
>
<Input
placeholder={intl.get('请输入自定义日志文件夹名称或 /dev/null')}
maxLength={200}
/>
</Form.Item>
<Form.Item
name="task_before"
label={intl.get('执行前')}
@@ -312,4 +357,3 @@ const CronLabelModal = ({
};
export { CronLabelModal, CronModal as default };
+1
View File
@@ -37,6 +37,7 @@ export interface ICrontab {
nextRunTime: Date;
sub_id: number;
extra_schedules?: Array<{ schedule: string }>;
allow_multiple_instances?: 1 | 0;
}
export enum ScheduleType {
+111 -36
View File
@@ -1,47 +1,42 @@
import intl from 'react-intl-universal';
import React, {
useCallback,
useRef,
useState,
useEffect,
useMemo,
} from 'react';
import useTableScrollHeight from '@/hooks/useTableScrollHeight';
import { SharedContext } from '@/layouts';
import config from '@/utils/config';
import { request } from '@/utils/http';
import { exportJson } from '@/utils/index';
import {
Button,
message,
Modal,
Table,
Tag,
Space,
Typography,
Tooltip,
Input,
UploadProps,
Upload,
} from 'antd';
import {
EditOutlined,
DeleteOutlined,
SyncOutlined,
CheckCircleOutlined,
DeleteOutlined,
EditOutlined,
PushpinFilled,
PushpinOutlined,
StopOutlined,
UploadOutlined,
} from '@ant-design/icons';
import config from '@/utils/config';
import { PageContainer } from '@ant-design/pro-layout';
import { request } from '@/utils/http';
import EnvModal from './modal';
import EditNameModal from './editNameModal';
import { useOutletContext } from '@umijs/max';
import {
Button,
Input,
Modal,
Space,
Table,
Tag,
Tooltip,
Typography,
Upload,
UploadProps,
message,
} from 'antd';
import dayjs from 'dayjs';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import './index.less';
import { exportJson } from '@/utils/index';
import { useOutletContext } from '@umijs/max';
import { SharedContext } from '@/layouts';
import useTableScrollHeight from '@/hooks/useTableScrollHeight';
import Copy from '../../components/copy';
import intl from 'react-intl-universal';
import { useVT } from 'virtualizedtableforantd4';
import dayjs from 'dayjs';
import Copy from '../../components/copy';
import EditNameModal from './editNameModal';
import './index.less';
import EnvModal from './modal';
const { Paragraph } = Typography;
const { Search } = Input;
@@ -59,11 +54,15 @@ enum StatusColor {
enum OperationName {
'启用',
'禁用',
'置顶',
'取消置顶',
}
enum OperationPath {
'enable',
'disable',
'pin',
'unpin',
}
const type = 'DragableBodyRow';
@@ -181,7 +180,7 @@ const Env = () => {
{
title: intl.get('操作'),
key: 'action',
width: 120,
width: 160,
render: (text: string, record: any, index: number) => {
const isPc = !isPhone;
return (
@@ -208,6 +207,23 @@ const Env = () => {
)}
</a>
</Tooltip>
<Tooltip
title={
isPc
? record.isPinned === 1
? intl.get('取消置顶')
: intl.get('置顶')
: ''
}
>
<a onClick={() => pinOrUnpinEnv(record, index)}>
{record.isPinned === 1 ? (
<PushpinFilled />
) : (
<PushpinOutlined />
)}
</a>
</Tooltip>
<Tooltip title={isPc ? intl.get('删除') : ''}>
<a onClick={() => deleteEnv(record, index)}>
<DeleteOutlined />
@@ -305,6 +321,51 @@ const Env = () => {
setIsModalVisible(true);
};
const pinOrUnpinEnv = (record: any, index: number) => {
Modal.confirm({
title: `确认${
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
content: (
<>
{intl.get('确认')}
{record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')}
Env{' '}
<Paragraph
style={{ wordBreak: 'break-all', display: 'inline' }}
ellipsis={{ rows: 6, expandable: true }}
type="warning"
copyable
>
{record.name}: {record.value}
</Paragraph>{' '}
{intl.get('吗')}
</>
),
onOk() {
request
.put(
`${config.apiPrefix}envs/${
record.isPinned === 1 ? 'unpin' : 'pin'
}`,
[record.id],
)
.then(({ code, data }) => {
if (code === 200) {
message.success(
`${
record.isPinned === 1
? intl.get('取消置顶')
: intl.get('置顶')
}${intl.get('成功')}`,
);
getEnvs();
}
});
},
});
};
const deleteEnv = (record: any, index: number) => {
Modal.confirm({
title: intl.get('确认删除'),
@@ -589,6 +650,20 @@ const Env = () => {
>
{intl.get('批量禁用')}
</Button>
<Button
type="primary"
onClick={() => operateEnvs(2)}
style={{ marginLeft: 8, marginBottom: 5 }}
>
{intl.get('批量置顶')}
</Button>
<Button
type="primary"
onClick={() => operateEnvs(3)}
style={{ marginLeft: 8, marginRight: 8 }}
>
{intl.get('批量取消置顶')}
</Button>
<span style={{ marginLeft: 8 }}>
{intl.get('已选择')}
<a>{selectedRowIds?.length}</a>
+1 -1
View File
@@ -16,7 +16,7 @@ const SaveModal = ({
const handleOk = async (values: any) => {
setLoading(true);
const payload = { ...file, ...values, originFilename: file.title };
const payload = { ...values, originFilename: file.title, content: file.content };
request
.post(`${config.apiPrefix}scripts`, payload)
.then(({ code, data }) => {
+41 -41
View File
@@ -1,23 +1,23 @@
import { disableBody } from '@/utils';
import config from '@/utils/config';
import { request } from '@/utils/http';
import WebSocketManager from '@/utils/websocket';
import Ansi from 'ansi-to-react';
import { Button, Modal, Statistic, message } from 'antd';
import { useCallback, useEffect, useRef, useState } from 'react';
import intl from 'react-intl-universal';
import { disableBody } from "@/utils";
import config from "@/utils/config";
import { request } from "@/utils/http";
import WebSocketManager from "@/utils/websocket";
import Ansi from "ansi-to-react";
import { Button, Modal, Statistic, message } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
import intl from "react-intl-universal";
const { Countdown } = Statistic;
const CheckUpdate = ({ systemInfo }: any) => {
const [updateLoading, setUpdateLoading] = useState(false);
const [value, setValue] = useState('');
const [value, setValue] = useState("");
const modalRef = useRef<any>();
const checkUpgrade = () => {
if (updateLoading) return;
setUpdateLoading(true);
message.loading(intl.get('检查更新中...'), 0);
message.loading(intl.get("检查更新中..."), 0);
request
.put(`${config.apiPrefix}system/update-check`)
.then(({ code, data }) => {
@@ -42,22 +42,22 @@ const CheckUpdate = ({ systemInfo }: any) => {
const showForceUpdateModal = (data: any) => {
Modal.confirm({
width: 500,
title: intl.get('更新'),
title: intl.get("更新"),
content: (
<>
<div>{intl.get('已经是最新版了!')}</div>
<div>{intl.get("已经是最新版了!")}</div>
<div style={{ fontSize: 12, fontWeight: 400, marginTop: 5 }}>
{intl.get('青龙')} {data.lastVersion}{' '}
{intl.get('是目前检测到的最新可用版本了。')}
{intl.get("青龙")} {data.lastVersion}{" "}
{intl.get("是目前检测到的最新可用版本了。")}
</div>
</>
),
okText: intl.get('重新下载'),
okText: intl.get("重新下载"),
onOk() {
showUpdatingModal();
request
.put(`${config.apiPrefix}system/update`)
.then((_data: any) => {})
.then((_data: any) => { })
.catch((error: any) => {
console.log(error);
});
@@ -71,10 +71,10 @@ const CheckUpdate = ({ systemInfo }: any) => {
width: 500,
title: (
<>
<div>{intl.get('更新可用')}</div>
<div>{intl.get("更新可用")}</div>
<div style={{ fontSize: 12, fontWeight: 400, marginTop: 5 }}>
{intl.get('新版本')} {lastVersion}{' '}
{intl.get('可用,你使用的版本为')} {systemInfo.version}
{intl.get("新版本")} {lastVersion}{" "}
{intl.get("可用,你使用的版本为")} {systemInfo.version}
</div>
</>
),
@@ -83,13 +83,13 @@ const CheckUpdate = ({ systemInfo }: any) => {
<Ansi>{lastLog}</Ansi>
</pre>
),
okText: intl.get('下载更新'),
cancelText: intl.get('以后再说'),
okText: intl.get("下载更新"),
cancelText: intl.get("以后再说"),
onOk() {
showUpdatingModal();
request
.put(`${config.apiPrefix}system/update`)
.then((_data: any) => {})
.then((_data: any) => { })
.catch((error: any) => {
console.log(error);
});
@@ -98,14 +98,14 @@ const CheckUpdate = ({ systemInfo }: any) => {
};
const showUpdatingModal = () => {
setValue('');
setValue("");
modalRef.current = Modal.info({
width: 600,
maskClosable: false,
closable: false,
keyboard: false,
okButtonProps: { disabled: true },
title: intl.get('下载更新中...'),
title: intl.get("下载更新中..."),
centered: true,
content: (
<pre>
@@ -122,13 +122,13 @@ const CheckUpdate = ({ systemInfo }: any) => {
message.success({
content: (
<span>
{intl.get('系统将在')}
{intl.get("系统将在")}
<Countdown
className="inline-countdown"
format="ss"
value={Date.now() + 1000 * 30}
/>
{intl.get('秒后自动刷新')}
{intl.get("秒后自动刷新")}
</span>
),
duration: 30,
@@ -147,12 +147,12 @@ const CheckUpdate = ({ systemInfo }: any) => {
Modal.confirm({
width: 600,
maskClosable: false,
title: intl.get('确认重启'),
title: intl.get("确认重启"),
centered: true,
content: intl.get('系统安装包下载成功,确认重启'),
okText: intl.get('重启'),
content: intl.get("系统安装包下载成功,确认重启"),
okText: intl.get("重启"),
onOk() {
reloadSystem('system');
reloadSystem("system");
},
onCancel() {
modalRef.current.update({
@@ -166,7 +166,7 @@ const CheckUpdate = ({ systemInfo }: any) => {
useEffect(() => {
if (!value) return;
const updateFailed = value.includes('失败,请检查');
const updateFailed = value.includes("失败,请检查");
modalRef.current.update({
maskClosable: updateFailed,
@@ -185,19 +185,19 @@ const CheckUpdate = ({ systemInfo }: any) => {
const handleMessage = useCallback((payload: any) => {
let { message: _message } = payload;
const updateFailed = _message.includes('失败,请检查');
const updateFailed = _message.includes("失败,请检查");
if (updateFailed) {
message.error(intl.get('更新失败,请检查网络及日志或稍后再试'));
message.error(intl.get("更新失败,请检查网络及日志或稍后再试"));
}
setTimeout(() => {
document
.querySelector('#log-identifier')!
.scrollIntoView({ behavior: 'smooth' });
.querySelector("#log-identifier")
?.scrollIntoView({ behavior: "smooth" });
}, 600);
if (_message.includes('更新包下载成功')) {
if (_message.includes("更新包下载成功")) {
setTimeout(() => {
showReloadModal();
}, 1000);
@@ -208,24 +208,24 @@ const CheckUpdate = ({ systemInfo }: any) => {
useEffect(() => {
const ws = WebSocketManager.getInstance();
ws.subscribe('updateSystemVersion', handleMessage);
ws.subscribe("updateSystemVersion", handleMessage);
return () => {
ws.unsubscribe('updateSystemVersion', handleMessage);
ws.unsubscribe("updateSystemVersion", handleMessage);
};
}, []);
return (
<>
<Button type="primary" onClick={checkUpgrade}>
{intl.get('检查更新')}
{intl.get("检查更新")}
</Button>
<Button
type="primary"
onClick={() => reloadSystem('reload')}
onClick={() => reloadSystem("reload")}
style={{ marginLeft: 8 }}
>
{intl.get('重新启动')}
{intl.get("重新启动")}
</Button>
</>
);
+52 -1
View File
@@ -1,6 +1,6 @@
import intl from 'react-intl-universal';
import React, { useState, useEffect, useRef } from 'react';
import { Button, InputNumber, Form, message, Input, Alert } from 'antd';
import { Button, InputNumber, Form, message, Input, Alert, Select } from 'antd';
import config from '@/utils/config';
import { request } from '@/utils/http';
import './index.less';
@@ -25,6 +25,7 @@ const Dependence = () => {
const [form] = Form.useForm();
const [log, setLog] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [cleanType, setCleanType] = useState<string>('node');
const getSystemConfig = () => {
request
@@ -84,6 +85,24 @@ const Dependence = () => {
}
};
const cleanDependenceCache = (type: string) => {
setLoading(true);
setLog('');
request
.put(`${config.apiPrefix}system/config/dependence-clean`, {
type,
})
.then(({ code, data }) => {
if (code === 200) {
message.success(intl.get('清除成功'));
}
})
.catch((error: any) => {
console.log(error);
})
.finally(() => setLoading(false));
};
useEffect(() => {
const ws = WebSocketManager.getInstance();
ws.subscribe('updateNodeMirror', handleMessage);
@@ -222,6 +241,38 @@ const Dependence = () => {
</Button>
</Input.Group>
</Form.Item>
<Form.Item
label={intl.get('清除依赖缓存')}
name="clean"
tooltip={{
title: intl.get('清除依赖缓存'),
placement: 'topLeft',
}}
>
<Input.Group compact>
<Select
defaultValue={'node'}
style={{ width: 100 }}
onChange={(value) => {
setCleanType(value);
}}
options={[
{ label: 'node', value: 'node' },
{ label: 'python3', value: 'python3' },
]}
/>
<Button
type="primary"
loading={loading}
onClick={() => {
cleanDependenceCache(cleanType);
}}
style={{ width: 100 }}
>
{intl.get('确认')}
</Button>
</Input.Group>
</Form.Item>
</Form>
<pre
style={{
+1
View File
@@ -49,6 +49,7 @@ const Setting = () => {
reloadTheme,
systemInfo,
} = useOutletContext<SharedContext>();
console.log('user',user)
const columns = [
{
title: intl.get('名称'),
+230 -136
View File
@@ -10,6 +10,7 @@ import {
Upload,
Modal,
Select,
Checkbox,
} from 'antd';
import * as DarkReader from '@umijs/ssr-darkreader';
import config from '@/utils/config';
@@ -29,8 +30,22 @@ const dataMap = {
'log-remove-frequency': 'logRemoveFrequency',
'cron-concurrency': 'cronConcurrency',
timezone: 'timezone',
'global-ssh-key': 'globalSshKey',
};
const exportModules = [
{ value: 'base', label: intl.get('基础数据'), disabled: true },
{ value: 'config', label: intl.get('配置文件') },
{ value: 'scripts', label: intl.get('脚本文件') },
{ value: 'log', label: intl.get('日志文件') },
{ value: 'deps', label: intl.get('依赖文件') },
{ value: 'syslog', label: intl.get('系统日志') },
{ value: 'dep_cache', label: intl.get('依赖缓存') },
{ value: 'raw', label: intl.get('远程脚本缓存') },
{ value: 'repo', label: intl.get('远程仓库缓存') },
{ value: 'ssh.d', label: intl.get('SSH 文件缓存') },
];
const Other = ({
systemInfo,
reloadTheme,
@@ -40,11 +55,14 @@ const Other = ({
logRemoveFrequency?: number | null;
cronConcurrency?: number | null;
timezone?: string | null;
globalSshKey?: string | null;
}>();
const [form] = Form.useForm();
const [exportLoading, setExportLoading] = useState(false);
const showUploadProgress = useProgress(intl.get('上传'));
const showDownloadProgress = useProgress(intl.get('下载'));
const [visible, setVisible] = useState(false);
const [selectedModules, setSelectedModules] = useState<string[]>(['base']);
const {
enable: enableDarkMode,
@@ -110,7 +128,7 @@ const Other = ({
request
.put<Blob>(
`${config.apiPrefix}system/data/export`,
{},
{ type: selectedModules },
{
responseType: 'blob',
timeout: 86400000,
@@ -127,7 +145,10 @@ const Other = ({
.catch((error: any) => {
console.log(error);
})
.finally(() => setExportLoading(false));
.finally(() => {
setExportLoading(false);
setVisible(false);
});
};
const showReloadModal = () => {
@@ -178,160 +199,233 @@ const Other = ({
}, []);
return (
<Form layout="vertical" form={form}>
<Form.Item
label={intl.get('主题')}
name="theme"
initialValue={defaultTheme}
>
<Radio.Group
onChange={themeChange}
value={defaultTheme}
optionType="button"
buttonStyle="solid"
<>
<Form layout="vertical" form={form}>
<Form.Item
label={intl.get('主题')}
name="theme"
initialValue={defaultTheme}
>
<Radio.Button
value="light"
style={{ width: 70, textAlign: 'center' }}
<Radio.Group
onChange={themeChange}
value={defaultTheme}
optionType="button"
buttonStyle="solid"
>
{intl.get('亮色')}
</Radio.Button>
<Radio.Button value="dark" style={{ width: 66, textAlign: 'center' }}>
{intl.get('暗色')}
</Radio.Button>
<Radio.Button
value="auto"
style={{ width: 129, textAlign: 'center' }}
>
{intl.get('跟随系统')}
</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item
label={intl.get('日志删除频率')}
name="frequency"
tooltip={intl.get('每x天自动删除x天以前的日志')}
>
<Input.Group compact>
<InputNumber
addonBefore={intl.get('每')}
addonAfter={intl.get('')}
style={{ width: 180 }}
min={0}
value={systemConfig?.logRemoveFrequency}
onChange={(value) => {
setSystemConfig({ ...systemConfig, logRemoveFrequency: value });
}}
/>
<Radio.Button
value="light"
style={{ width: 70, textAlign: 'center' }}
>
{intl.get('亮色')}
</Radio.Button>
<Radio.Button
value="dark"
style={{ width: 66, textAlign: 'center' }}
>
{intl.get('暗色')}
</Radio.Button>
<Radio.Button
value="auto"
style={{ width: 129, textAlign: 'center' }}
>
{intl.get('跟随系统')}
</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item
label={intl.get('日志删除频率')}
name="frequency"
tooltip={intl.get('每x天自动删除x天以前的日志')}
>
<Input.Group compact>
<InputNumber
addonBefore={intl.get('每')}
addonAfter={intl.get('天')}
style={{ width: 180 }}
placeholder={intl.get('未启用')}
min={0}
value={systemConfig?.logRemoveFrequency}
onChange={(value) => {
setSystemConfig({ ...systemConfig, logRemoveFrequency: value });
}}
/>
<Button
type="primary"
onClick={() => {
updateSystemConfig('log-remove-frequency');
}}
style={{ width: 84 }}
>
{intl.get('确认')}
</Button>
</Input.Group>
</Form.Item>
<Form.Item label={intl.get('定时任务并发数')} name="frequency">
<Input.Group compact>
<InputNumber
style={{ width: 180 }}
min={4}
value={systemConfig?.cronConcurrency}
placeholder={intl.get('默认为 CPU 个数')}
onChange={(value) => {
setSystemConfig({ ...systemConfig, cronConcurrency: value });
}}
/>
<Button
type="primary"
onClick={() => {
updateSystemConfig('cron-concurrency');
}}
style={{ width: 84 }}
>
{intl.get('确认')}
</Button>
</Input.Group>
</Form.Item>
<Form.Item label={intl.get('时区')} name="timezone">
<Input.Group compact>
<Select
value={systemConfig?.timezone}
style={{ width: 180 }}
onChange={(value) => {
setSystemConfig({ ...systemConfig, timezone: value });
}}
options={TIMEZONES.map((timezone) => ({
value: timezone,
label: timezone,
}))}
showSearch
filterOption={(input, option) =>
option?.value?.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
/>
<Button
type="primary"
onClick={() => {
updateSystemConfig('timezone');
}}
style={{ width: 84 }}
>
{intl.get('确认')}
</Button>
</Input.Group>
</Form.Item>
<Form.Item
label={intl.get('全局SSH私钥')}
name="globalSshKey"
tooltip={intl.get('用于访问所有私有仓库的全局SSH私钥')}
>
<Input.Group compact>
<Input.TextArea
value={systemConfig?.globalSshKey || ''}
style={{ width: 264 }}
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder={intl.get('请输入完整的SSH私钥内容')}
onChange={(e) => {
setSystemConfig({ ...systemConfig, globalSshKey: e.target.value });
}}
/>
</Input.Group>
<Button
type="primary"
onClick={() => {
updateSystemConfig('log-remove-frequency');
updateSystemConfig('global-ssh-key');
}}
style={{ width: 84 }}
style={{ width: 264, marginTop: 8 }}
>
{intl.get('确认')}
</Button>
</Input.Group>
</Form.Item>
<Form.Item label={intl.get('定时任务并发数')} name="frequency">
<Input.Group compact>
<InputNumber
style={{ width: 180 }}
min={1}
value={systemConfig?.cronConcurrency}
onChange={(value) => {
setSystemConfig({ ...systemConfig, cronConcurrency: value });
}}
/>
<Button
type="primary"
onClick={() => {
updateSystemConfig('cron-concurrency');
}}
style={{ width: 84 }}
>
{intl.get('确认')}
</Button>
</Input.Group>
</Form.Item>
<Form.Item label={intl.get('时区')} name="timezone">
<Input.Group compact>
</Form.Item>
<Form.Item label={intl.get('语言')} name="lang">
<Select
value={systemConfig?.timezone}
style={{ width: 180 }}
onChange={(value) => {
setSystemConfig({ ...systemConfig, timezone: value });
}}
options={TIMEZONES.map((timezone) => ({
value: timezone,
label: timezone,
}))}
showSearch
filterOption={(input, option) =>
option?.value?.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
defaultValue={localStorage.getItem('lang') || ''}
style={{ width: 264 }}
onChange={handleLangChange}
options={[
{ value: '', label: intl.get('跟随系统') },
{ value: 'zh', label: '简体中文' },
{ value: 'en', label: 'English' },
]}
/>
</Form.Item>
<Form.Item label={intl.get('数据备份还原')} name="frequency">
<Button
type="primary"
onClick={() => {
updateSystemConfig('timezone');
setSelectedModules(['base']);
setVisible(true);
}}
style={{ width: 84 }}
loading={exportLoading}
>
{intl.get('确认')}
{exportLoading ? intl.get('生成数据中...') : intl.get('备份')}
</Button>
</Input.Group>
</Form.Item>
<Form.Item label={intl.get('语言')} name="lang">
<Select
defaultValue={localStorage.getItem('lang') || ''}
style={{ width: 264 }}
onChange={handleLangChange}
options={[
{ value: '', label: intl.get('跟随系统') },
{ value: 'zh', label: '简体中文' },
{ value: 'en', label: 'English' },
]}
/>
</Form.Item>
<Form.Item label={intl.get('数据备份还原')} name="frequency">
<Button type="primary" onClick={exportData} loading={exportLoading}>
{exportLoading ? intl.get('生成数据中...') : intl.get('备份')}
</Button>
<Upload
method="put"
showUploadList={false}
maxCount={1}
action={`${config.apiPrefix}system/data/import`}
onChange={({ file, event }) => {
if (event?.percent) {
showUploadProgress(
Math.min(parseFloat(event?.percent.toFixed(1)), 99),
);
}
if (file.status === 'done') {
showUploadProgress(100);
showReloadModal();
}
if (file.status === 'error') {
message.error('上传失败');
}
<Upload
method="put"
showUploadList={false}
maxCount={1}
action={`${config.apiPrefix}system/data/import`}
onChange={({ file, event }) => {
if (event?.percent) {
showUploadProgress(
Math.min(parseFloat(event?.percent.toFixed(1)), 99),
);
}
if (file.status === 'done') {
showUploadProgress(100);
showReloadModal();
}
if (file.status === 'error') {
message.error('上传失败');
}
}}
name="data"
headers={{
Authorization: `Bearer ${localStorage.getItem(config.authKey)}`,
}}
>
<Button icon={<UploadOutlined />} style={{ marginLeft: 8 }}>
{intl.get('还原数据')}
</Button>
</Upload>
</Form.Item>
<Form.Item label={intl.get('检查更新')} name="update">
<CheckUpdate systemInfo={systemInfo} />
</Form.Item>
</Form>
<Modal
title={intl.get('选择备份模块')}
open={visible}
onOk={exportData}
onCancel={() => setVisible(false)}
okText={intl.get('开始备份')}
cancelText={intl.get('取消')}
okButtonProps={{ loading: exportLoading }} // 绑定加载状态到按钮
>
<Checkbox.Group
value={selectedModules}
onChange={(v) => {
setSelectedModules(v as string[]);
}}
name="data"
headers={{
Authorization: `Bearer ${localStorage.getItem(config.authKey)}`,
style={{
width: '100%',
display: 'flex',
flexWrap: 'wrap',
gap: '8px 16px',
}}
>
<Button icon={<UploadOutlined />} style={{ marginLeft: 8 }}>
{intl.get('还原数据')}
</Button>
</Upload>
</Form.Item>
<Form.Item label={intl.get('检查更新')} name="update">
<CheckUpdate systemInfo={systemInfo} />
</Form.Item>
</Form>
{exportModules.map((module) => (
<Checkbox
key={module.value}
value={module.value}
disabled={module.disabled}
style={{ marginLeft: 0 }}
>
{module.label}
</Checkbox>
))}
</Checkbox.Group>
</Modal>
</>
);
};
+4 -4
View File
@@ -12,7 +12,7 @@ import {
} from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import cron_parser from 'cron-parser';
import { CronExpressionParser } from 'cron-parser';
import isNil from 'lodash/isNil';
const { Option } = Select;
@@ -224,8 +224,8 @@ const SubscriptionModal = ({
type === 'raw'
? 'file'
: url.startsWith('http')
? 'public-repo'
: 'private-repo';
? 'public-repo'
: 'private-repo';
form.setFieldsValue({
type: _type,
@@ -381,7 +381,7 @@ const SubscriptionModal = ({
if (
scheduleType === 'interval' ||
!value ||
cron_parser.parseExpression(value).hasNext()
CronExpressionParser.parse(value).hasNext()
) {
return Promise.resolve();
} else {
+11 -1
View File
@@ -128,10 +128,14 @@ export default {
},
{
label: 'ntfyTopic',
tip: intl.get('ntfy的消息应用topic'),
tip: intl.get('ntfy应用topic'),
required: true,
},
{ label: 'ntfyPriority', tip: intl.get('推送消息的优先级') },
{ label: 'ntfyToken', tip: intl.get('ntfy应用token') },
{ label: 'ntfyUsername', tip: intl.get('ntfy应用用户名') },
{ label: 'ntfyPassword', tip: intl.get('ntfy应用密码') },
{ label: 'ntfyActions', tip: intl.get('ntfy用户动作') },
],
chat: [
{
@@ -391,6 +395,12 @@ export default {
),
required: true,
},
{
label: 'larkSecret',
tip: intl.get(
'飞书群组机器人加签密钥,安全设置中开启签名校验后获得',
),
},
],
email: [
{
+6 -6
View File
@@ -1,6 +1,6 @@
import intl from 'react-intl-universal';
import { LANG_MAP, LOG_END_SYMBOL } from './const';
import cron_parser from 'cron-parser';
import { CronExpressionParser } from 'cron-parser';
import { ICrontab } from '@/pages/crontab/type';
export default function browserType() {
@@ -155,9 +155,9 @@ export default function browserType() {
shell === 'none'
? {}
: {
shell, // wechat qq uc 360 2345 sougou liebao maxthon
shellVs,
},
shell, // wechat qq uc 360 2345 sougou liebao maxthon
shellVs,
},
);
console.log(
@@ -333,11 +333,11 @@ export function getCommandScript(
export function parseCrontab(schedule: string): Date | null {
try {
const time = cron_parser.parseExpression(schedule);
const time = CronExpressionParser.parse(schedule);
if (time) {
return time.next().toDate();
}
} catch (error) {}
} catch (error) { }
return null;
}
+46 -11
View File
@@ -1,12 +1,47 @@
version: 2.19.1
changeLogLink: https://t.me/jiao_long/430
publishTime: 2025-05-24 16:00
version: 2.20.0
changeLogLink: https://t.me/jiao_long/432
publishTime: 2025-12-10 01:05
changeLog: |
1. 修复依赖是否安装检查逻辑
2. 修复文件下载 path 参数
3. 修复 python 查询逻辑
4. 修复任务视图状态筛选
5. 修复创建脚本可能失败
6. 修复重置用户名失败
7. 修复无法识别 python 依赖安装的命令
8. 其他缺陷修复
1. 定时任务(cron / task)相关的大量修复 & 增强
修复 cron 解析错误(修复 parse cron / 升级 cron-parser
修复集群模式下定时任务可能不执行(race condition
定时任务支持订阅筛选
定时任务支持排序调整
定时任务支持自定义日志文件或无日志
修复任务实例默认值
任务支持单实例 / 多实例模式
修复 task 命令软链可能失败问题
2. 日志系统相关的大更新
修复日志目录逻辑
修复 pm2 日志目录
优化日志写入(stream pooling
3. 环境变量(env)系统的改进与修复
修复环境变量复制到剪贴板时可能失败
添加环境变量“置顶”功能
修复 QlPort 与 QlGrpcPort 环境变量在 host network 模式下被忽略
增加全局 SSH 私钥配置
4. Docker / 非 root 用户 / Alpine 兼容性增强
新增非 root Docker 用户支持,自动初始化命令
修复 Alpine 容器 DNS 解析失败(设置 ndots:0
修复 PM2 在 ARM 路由器(Node.js 不兼容)上的启动失败
移除 nginx(可能是考虑更轻量的镜像运行)
5. API 安全与校验增强
Dependencies GET endpoint 增加校验
Script API routes 增加输入校验
修复 JWT 认证问题
Feishu 机器人通知增加签名校验
QLAPI 增加 cron task 管理功能
修复 URIError(错误 cookie 导致白屏)
6. 系统设置
新增多终端/多平台的并发登录会话支持