Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andwhyour 4926f6f625 Fix env.js loading error by preventing catch-all from matching API routes
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-24 08:42:58 +00:00
copilot-swe-agent[bot] f73059ec71 Initial plan 2025-12-24 08:39:06 +00:00
whyour 4f6c93cc1c 更新 workflow 2025-12-24 01:03:21 +08:00
whyour e326d89571 修复 apiWhiteList 路径 2025-12-23 00:58:09 +08:00
whyour 5f0dafa010 修复 cron-parser import,websocket basepath 2025-12-23 00:28:16 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
dc0b3f2eb2 Fix QlBaseUrl: use URL rewrite for base path support (#2876)
* Initial plan

* Add QlBaseUrl support to backend routes

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

* Fix whitelist check to use base-URL-aware paths

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

* Update websocket and frontend to support base URL

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

* Address code review feedback: fix JWT regex and path construction

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

* Fix path construction: use req.path directly for whitelist check

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

* Add clarifying comments and improve code readability

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

* Apply code review suggestions: improve clarity and simplify logic

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

* Simplify baseUrl implementation using URL rewrite

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-12-22 23:44:29 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
3db716763d Fix cron-parser v5 bundling incompatibility causing validation failures (#2877)
* Initial plan

* Fix: Use default import for cron-parser to ensure browser compatibility

Changed from named export `{ CronExpressionParser }` to default export `cronParser` and access `CronExpressionParser` through it. This ensures compatibility with webpack/UmiJS bundling for browser environments while maintaining backend functionality.

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-12-22 23:43:54 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
fae226745e Add missing larkSecret field to gRPC NotificationInfo proto (#2880)
* Initial plan

* Add larkSecret field to NotificationInfo proto definition

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-12-22 23:38:42 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
9330650163 Fix TG_PROXY_AUTH concatenation in notify.js - add missing @ separator (#2882)
* Initial plan

* Fix TG_PROXY_AUTH handling in notify.js to match notify.py logic

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

* Apply prettier formatting to notify.js

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-12-22 23:05:06 +08:00
CopilotGitHubwhyourwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
073de76a4a Fix validation error when saving scripts in debug window (v2.20.0 regression) (#2862)
* 更新版本 2.20.0

* Initial plan

* Fix validation error when saving scripts by allowing unknown fields in POST /scripts

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

* Revert version.yaml to 2.19.2 - should not include version bump in bug fix PR

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

---------

Co-authored-by: whyour <imwhyour@gmail.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-12-22 22:43:48 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
c61d1aa828 Fix enum value 0 causing type filter to fail for NodeJS dependencies (#2869)
* Initial plan

* Fix: Prevent Python3 dependencies from appearing in NodeJs tab

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-12-15 18:21:14 +08:00
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
72 changed files with 2110 additions and 858 deletions
+34 -25
View File
@@ -9,15 +9,13 @@ on:
- "develop" - "develop"
tags: tags:
- "v*" - "v*"
schedule:
- cron: "00 20 * * *"
workflow_dispatch: workflow_dispatch:
jobs: jobs:
code_gitlab: code_gitlab:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: Yikun/hub-mirror-action@master - uses: Yikun/hub-mirror-action@master
@@ -32,7 +30,7 @@ jobs:
code_gitee: code_gitee:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: Yikun/hub-mirror-action@master - uses: Yikun/hub-mirror-action@master
@@ -47,12 +45,12 @@ jobs:
build-static: build-static:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: pnpm/action-setup@v3 - uses: pnpm/action-setup@v4
with: with:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v4 - uses: actions/setup-node@v6
with: with:
cache: "pnpm" cache: "pnpm"
@@ -78,12 +76,12 @@ jobs:
git config --local user.email 'github-actions[bot]@users.noreply.github.com' git config --local user.email 'github-actions[bot]@users.noreply.github.com'
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')" git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH} git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
static_gitlab: static_gitlab:
needs: build-static needs: build-static
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: Yikun/hub-mirror-action@master - uses: Yikun/hub-mirror-action@master
@@ -99,7 +97,7 @@ jobs:
needs: build-static needs: build-static
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: Yikun/hub-mirror-action@master - uses: Yikun/hub-mirror-action@master
@@ -112,6 +110,7 @@ jobs:
force_update: true force_update: true
build: build:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static needs: build-static
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
@@ -121,14 +120,21 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: pnpm/action-setup@v3 - uses: pnpm/action-setup@v4
with: with:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v4 - uses: actions/setup-node@v6
with: with:
cache: "pnpm" 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 - name: Setup timezone
uses: szenius/set-timezone@v2.0 uses: szenius/set-timezone@v2.0
with: with:
@@ -154,19 +160,13 @@ jobs:
images: | images: |
${{ github.repository }} ${{ github.repository }}
ghcr.io/${{ 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: | flavor: |
latest=false latest=false
tags: | tags: |
type=schedule,pattern=nightly type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
type=edge
type=ref,event=pr
type=ref,event=branch,enable=${{ github.ref != format('refs/heads/{0}', 'master') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} 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={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -208,14 +208,21 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: pnpm/action-setup@v3 - uses: pnpm/action-setup@v4
with: with:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v4 - uses: actions/setup-node@v6
with: with:
cache: "pnpm" 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 - name: Setup timezone
uses: szenius/set-timezone@v2.0 uses: szenius/set-timezone@v2.0
with: with:
@@ -254,7 +261,9 @@ jobs:
context: . context: .
file: ./docker/310.Dockerfile file: ./docker/310.Dockerfile
push: true 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-from: type=registry,ref=whyour/qinglong:cache-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max
+2
View File
@@ -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. 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 ```bash
docker pull whyour/qinglong:latest docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian docker pull whyour/qinglong:debian
+2
View File
@@ -43,6 +43,8 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
`latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像 `latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像
**⚠️ 重要提示**: 如果您需要以**非 root 用户**运行 Docker,请使用 `debian` 镜像。Alpine 的 `crond` 需要 root 权限。
```bash ```bash
docker pull whyour/qinglong:latest docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian docker pull whyour/qinglong:debian
+1 -1
View File
@@ -16,7 +16,7 @@ export default (app: Router) => {
searchValue: Joi.string().optional().allow(''), searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''), type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''), status: Joi.string().optional().allow(''),
}), }).unknown(true),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
+41 -7
View File
@@ -1,12 +1,12 @@
import { Router, Request, Response, NextFunction } from 'express'; import { Joi, celebrate } from 'celebrate';
import { Container } from 'typedi'; import { NextFunction, Request, Response, Router } from 'express';
import EnvService from '../services/env';
import { Logger } from 'winston';
import { celebrate, Joi } from 'celebrate';
import multer from 'multer';
import config from '../config';
import fs from 'fs'; 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 { safeJSONParse } from '../config/util';
import EnvService from '../services/env';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ 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( route.post(
'/upload', '/upload',
upload.single('env'), upload.single('env'),
+6 -4
View File
@@ -29,7 +29,7 @@ export default (app: Router) => {
celebrate({ celebrate({
query: Joi.object({ query: Joi.object({
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
}), }).unknown(true),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
@@ -79,7 +79,7 @@ export default (app: Router) => {
query: Joi.object({ query: Joi.object({
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
file: Joi.string().required(), file: Joi.string().required(),
}), }).unknown(true),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
@@ -103,7 +103,7 @@ export default (app: Router) => {
}), }),
query: Joi.object({ query: Joi.object({
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
}), }).unknown(true),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
@@ -129,7 +129,8 @@ export default (app: Router) => {
content: Joi.string().optional().allow(''), content: Joi.string().optional().allow(''),
originFilename: Joi.string().optional().allow(''), originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''), directory: Joi.string().optional().allow(''),
}), file: Joi.string().optional().allow(''),
}).unknown(true),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
@@ -175,6 +176,7 @@ export default (app: Router) => {
path, path,
`${originFilename.replace(/\//g, '')}`, `${originFilename.replace(/\//g, '')}`,
); );
await fs.mkdir(path, { recursive: true });
const filePath = join(path, `${filename.replace(/\//g, '')}`); const filePath = join(path, `${filename.replace(/\//g, '')}`);
const fileExists = await fileExist(filePath); const fileExists = await fileExist(filePath);
if (fileExists) { if (fileExists) {
+3 -3
View File
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import SubscriptionService from '../services/subscription'; import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import cron_parser from 'cron-parser'; import CronExpressionParser from 'cron-parser';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
@@ -60,7 +60,7 @@ export default (app: Router) => {
try { try {
if ( if (
!req.body.schedule || !req.body.schedule ||
cron_parser.parseExpression(req.body.schedule).hasNext() CronExpressionParser.parse(req.body.schedule).hasNext()
) { ) {
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.create(req.body); const data = await subscriptionService.create(req.body);
@@ -193,7 +193,7 @@ export default (app: Router) => {
if ( if (
!req.body.schedule || !req.body.schedule ||
typeof req.body.schedule === 'object' || typeof req.body.schedule === 'object' ||
cron_parser.parseExpression(req.body.schedule).hasNext() CronExpressionParser.parse(req.body.schedule).hasNext()
) { ) {
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.update(req.body); const data = await subscriptionService.update(req.body);
+23 -2
View File
@@ -14,6 +14,7 @@ import {
} from '../config/util'; } from '../config/util';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import multer from 'multer'; import multer from 'multer';
import { logStreamManager } from '../shared/logStreamManager';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -276,17 +277,19 @@ export default (app: Router) => {
res.setHeader('QL-Task-Log', `${logPath}`); res.setHeader('QL-Task-Log', `${logPath}`);
}, },
onEnd: async (cp, endTime, diff) => { onEnd: async (cp, endTime, diff) => {
// Close the stream after task completion
await logStreamManager.closeStream(await handleLogPath(logPath));
res.end(); res.end();
}, },
onError: async (message: string) => { onError: async (message: string) => {
res.write(message); res.write(message);
const absolutePath = await handleLogPath(logPath); const absolutePath = await handleLogPath(logPath);
await fs.appendFile(absolutePath, message); await logStreamManager.write(absolutePath, message);
}, },
onLog: async (message: string) => { onLog: async (message: string) => {
res.write(message); res.write(message);
const absolutePath = await handleLogPath(logPath); const absolutePath = await handleLogPath(logPath);
await fs.appendFile(absolutePath, message); await logStreamManager.write(absolutePath, message);
}, },
}, },
); );
@@ -423,6 +426,24 @@ 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( route.put(
'/config/dependence-clean', '/config/dependence-clean',
celebrate({ celebrate({
+3 -2
View File
@@ -8,7 +8,7 @@ import path from 'path';
import { v4 as uuidV4 } from 'uuid'; import { v4 as uuidV4 } from 'uuid';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import config from '../config'; import config from '../config';
import { isDemoEnv } from '../config/util'; import { isDemoEnv, getToken } from '../config/util';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -56,7 +56,8 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const userService = Container.get(UserService); const userService = Container.get(UserService);
await userService.logout(req.platform); const token = getToken(req);
await userService.logout(req.platform, token);
res.send({ code: 200 }); res.send({ code: 200 });
} catch (e) { } catch (e) {
return next(e); return next(e);
+80 -16
View File
@@ -24,6 +24,7 @@ class Application {
private grpcServerService?: GrpcServerService; private grpcServerService?: GrpcServerService;
private isShuttingDown = false; private isShuttingDown = false;
private workerMetadataMap = new Map<number, WorkerMetadata>(); private workerMetadataMap = new Map<number, WorkerMetadata>();
private httpWorker?: Worker;
constructor() { constructor() {
this.app = express(); this.app = express();
@@ -53,21 +54,54 @@ class Application {
} }
private startMasterProcess() { private startMasterProcess() {
this.forkWorker('http'); // Fork gRPC worker first and wait for it to be ready
this.forkWorker('grpc'); 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) => { cluster.on('exit', (worker, code, signal) => {
const metadata = this.workerMetadataMap.get(worker.id); const metadata = this.workerMetadataMap.get(worker.id);
if (metadata) { if (metadata) {
if (!this.isShuttingDown) { if (!this.isShuttingDown) {
Logger.error( Logger.error(
`${metadata.serviceType} worker ${worker.process.pid} died (${signal || code `✌️ ${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
}). Restarting...`, }). Restarting...`,
); );
const newWorker = this.forkWorker(metadata.serviceType); // If gRPC worker died, restart it and wait for it to be ready
Logger.info( if (metadata.serviceType === 'grpc') {
`Restarted ${metadata.serviceType} worker (New PID: ${newWorker.process.pid})`, 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.workerMetadataMap.delete(worker.id);
@@ -77,6 +111,25 @@ class Application {
this.setupMasterShutdown(); 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 { private forkWorker(serviceType: string): Worker {
const worker = cluster.fork({ SERVICE_TYPE: serviceType }); const worker = cluster.fork({ SERVICE_TYPE: serviceType });
@@ -116,7 +169,7 @@ class Application {
if (worker) { if (worker) {
const exitPromise = new Promise<void>((resolve) => { const exitPromise = new Promise<void>((resolve) => {
worker.once('exit', () => { worker.once('exit', () => {
Logger.info(`Worker ${worker.process.pid} exited`); Logger.info(`✌️ Worker ${worker.process.pid} exited`);
resolve(); resolve();
}); });
@@ -124,7 +177,7 @@ class Application {
worker.send('shutdown'); worker.send('shutdown');
} catch (error) { } catch (error) {
Logger.warn( Logger.warn(
`Failed to send shutdown to worker ${worker.process.pid}:`, `✌️ Failed to send shutdown to worker ${worker.process.pid}:`,
error, error,
); );
} }
@@ -139,14 +192,14 @@ class Application {
Promise.all(workerPromises), Promise.all(workerPromises),
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
setTimeout(() => { setTimeout(() => {
Logger.warn('Worker shutdown timeout reached'); Logger.warn('✌️ Worker shutdown timeout reached');
resolve(); resolve();
}, 10000); }, 10000);
}), }),
]); ]);
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
Logger.error('Error during worker shutdown:', error); Logger.error('✌️ Error during worker shutdown:', error);
process.exit(1); process.exit(1);
} }
}; };
@@ -158,7 +211,7 @@ class Application {
private async startWorkerProcess() { private async startWorkerProcess() {
const serviceType = process.env.SERVICE_TYPE; const serviceType = process.env.SERVICE_TYPE;
if (!serviceType || !['http', 'grpc'].includes(serviceType)) { if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
Logger.error('Invalid SERVICE_TYPE:', serviceType); Logger.error('✌️ Invalid SERVICE_TYPE:', serviceType);
process.exit(1); process.exit(1);
} }
@@ -173,7 +226,7 @@ class Application {
process.send?.('ready'); process.send?.('ready');
} catch (error) { } catch (error) {
Logger.error(`${serviceType} worker failed:`, error); Logger.error(`✌️ ${serviceType} worker failed:`, error);
process.exit(1); process.exit(1);
} }
} }
@@ -206,9 +259,20 @@ class Application {
} }
private setupWorkerShutdown(serviceType: string) { private setupWorkerShutdown(serviceType: string) {
process.on('message', (msg) => { process.on('message', async (msg) => {
if (msg === 'shutdown') { if (msg === 'shutdown') {
this.gracefulShutdown(serviceType); 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);
}
} }
}); });
@@ -229,7 +293,7 @@ class Application {
} }
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
Logger.error(`[${serviceType}] Error during shutdown:`, error); Logger.error(`✌️ [${serviceType}] Error during shutdown:`, error);
process.exit(1); process.exit(1);
} }
} }
@@ -237,6 +301,6 @@ class Application {
const app = new Application(); const app = new Application();
app.start().catch((error) => { app.start().catch((error) => {
Logger.error('Application failed to start:', error); Logger.error('🙅‍♀️ Application failed to start:', error);
process.exit(1); process.exit(1);
}); });
+15
View File
@@ -64,6 +64,19 @@ if (!process.env.QL_DIR) {
const lastVersionFile = `https://qn.whyour.cn/version.yaml`; const lastVersionFile = `https://qn.whyour.cn/version.yaml`;
// Get and normalize QlBaseUrl
let baseUrl = process.env.QlBaseUrl || '';
if (baseUrl) {
// Ensure it starts with /
if (!baseUrl.startsWith('/')) {
baseUrl = `/${baseUrl}`;
}
// Remove trailing slash for consistency in route definitions
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
}
const rootPath = process.env.QL_DIR as string; const rootPath = process.env.QL_DIR as string;
const envFound = dotenv.config({ path: path.join(rootPath, '.env') }); const envFound = dotenv.config({ path: path.join(rootPath, '.env') });
@@ -116,6 +129,7 @@ if (envFound.error) {
export default { export default {
...config, ...config,
jwt: config.jwt, jwt: config.jwt,
baseUrl,
rootPath, rootPath,
tmpPath, tmpPath,
dataPath, dataPath,
@@ -176,4 +190,5 @@ export default {
sshdPath, sshdPath,
systemLogPath, systemLogPath,
dependenceCachePath, 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; 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 { interface IVersion {
version: string; version: string;
changeLogLink: string; changeLogLink: string;
+7 -1
View File
@@ -21,6 +21,8 @@ export class Crontab {
extra_schedules?: Array<{ schedule: string }>; extra_schedules?: Array<{ schedule: string }>;
task_before?: string; task_before?: string;
task_after?: string; task_after?: string;
log_name?: string;
allow_multiple_instances?: 1 | 0;
constructor(options: Crontab) { constructor(options: Crontab) {
this.name = options.name; this.name = options.name;
@@ -45,6 +47,8 @@ export class Crontab {
this.extra_schedules = options.extra_schedules; this.extra_schedules = options.extra_schedules;
this.task_before = options.task_before; this.task_before = options.task_before;
this.task_after = options.task_after; 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', 'disabled',
} }
export interface CronInstance extends Model<Crontab, Crontab>, Crontab { } export interface CronInstance extends Model<Crontab, Crontab>, Crontab {}
export const CrontabModel = sequelize.define<CronInstance>('Crontab', { export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
name: { name: {
unique: 'compositeIndex', unique: 'compositeIndex',
@@ -84,4 +88,6 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
extra_schedules: DataTypes.JSON, extra_schedules: DataTypes.JSON,
task_before: DataTypes.STRING, task_before: DataTypes.STRING,
task_after: 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 { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
export class Env { export class Env {
value?: string; value?: string;
@@ -9,6 +9,7 @@ export class Env {
position?: number; position?: number;
name?: string; name?: string;
remarks?: string; remarks?: string;
isPinned?: 1 | 0;
constructor(options: Env) { constructor(options: Env) {
this.value = options.value; this.value = options.value;
@@ -21,6 +22,7 @@ export class Env {
this.position = options.position; this.position = options.position;
this.name = options.name; this.name = options.name;
this.remarks = options.remarks || ''; this.remarks = options.remarks || '';
this.isPinned = options.isPinned || 0;
} }
} }
@@ -42,4 +44,5 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
position: DataTypes.NUMBER, position: DataTypes.NUMBER,
name: { type: DataTypes.STRING, unique: 'compositeIndex' }, name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING, remarks: DataTypes.STRING,
isPinned: DataTypes.NUMBER,
}); });
+1
View File
@@ -142,6 +142,7 @@ export class WebhookNotification extends NotificationBaseInfo {
export class LarkNotification extends NotificationBaseInfo { export class LarkNotification extends NotificationBaseInfo {
public larkKey = ''; public larkKey = '';
public larkSecret = '';
} }
export class NtfyNotification extends NotificationBaseInfo { export class NtfyNotification extends NotificationBaseInfo {
+15 -1
View File
@@ -38,6 +38,7 @@ export interface SystemConfigInfo {
pythonMirror?: string; pythonMirror?: string;
linuxMirror?: string; linuxMirror?: string;
timezone?: string; timezone?: string;
globalSshKey?: string;
} }
export interface LoginLogInfo { export interface LoginLogInfo {
@@ -48,6 +49,19 @@ export interface LoginLogInfo {
status?: LoginStatus; 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 { export interface AuthInfo {
username: string; username: string;
password: string; password: string;
@@ -58,7 +72,7 @@ export interface AuthInfo {
platform: string; platform: string;
isTwoFactorChecking: boolean; isTwoFactorChecking: boolean;
token: string; token: string;
tokens: Record<string, string>; tokens: Record<string, string | TokenInfo[]>;
twoFactorActivated: boolean; twoFactorActivated: boolean;
twoFactorSecret: string; twoFactorSecret: string;
avatar: string; avatar: string;
+32 -37
View File
@@ -19,43 +19,38 @@ export default async () => {
await CrontabViewModel.sync(); await CrontabViewModel.sync();
// 初始化新增字段 // 初始化新增字段
try { const migrations = [
await sequelize.query( {
'alter table CrontabViews add column filterRelation VARCHAR(255)', table: 'CrontabViews',
); column: 'filterRelation',
} catch (error) {} type: 'VARCHAR(255)',
try { },
await sequelize.query( { table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' },
'alter table Subscriptions add column proxy VARCHAR(255)', { table: 'CrontabViews', column: 'type', type: 'NUMBER' },
); { table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' },
} catch (error) {} { table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' },
try { { table: 'Crontabs', column: 'sub_id', type: 'NUMBER' },
await sequelize.query('alter table CrontabViews add column type NUMBER'); { table: 'Crontabs', column: 'extra_schedules', type: 'JSON' },
} catch (error) {} { table: 'Crontabs', column: 'task_before', type: 'TEXT' },
try { { table: 'Crontabs', column: 'task_after', type: 'TEXT' },
await sequelize.query( { table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' },
'alter table Subscriptions add column autoAddCron NUMBER', {
); table: 'Crontabs',
} catch (error) {} column: 'allow_multiple_instances',
try { type: 'NUMBER',
await sequelize.query( },
'alter table Subscriptions add column autoDelCron NUMBER', { table: 'Envs', column: 'isPinned', type: 'NUMBER' },
); ];
} catch (error) {}
try { for (const migration of migrations) {
await sequelize.query('alter table Crontabs add column sub_id NUMBER'); try {
} catch (error) {} await sequelize.query(
try { `alter table ${migration.table} add column ${migration.column} ${migration.type}`,
await sequelize.query( );
'alter table Crontabs add column extra_schedules JSON', } catch (error) {
); // Column already exists or other error, continue
} 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) {}
Logger.info('✌️ DB loaded'); Logger.info('✌️ DB loaded');
} catch (error) { } catch (error) {
+17 -5
View File
@@ -1,8 +1,9 @@
import path from 'path'; import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
import os from 'os';
import chokidar from 'chokidar'; import chokidar from 'chokidar';
import config from '../config/index'; import config from '../config/index';
import { fileExist, promiseExec, rmPath } from '../config/util'; import Logger from './logger';
async function linkToNodeModule(src: string, dst?: string) { async function linkToNodeModule(src: string, dst?: string) {
const target = path.join(config.rootPath, 'node_modules', dst || src); const target = path.join(config.rootPath, 'node_modules', dst || src);
@@ -17,8 +18,18 @@ async function linkToNodeModule(src: string, dst?: string) {
} }
async function linkCommand() { async function linkCommand() {
const commandPath = await promiseExec('which node'); const homeDir = os.homedir();
const commandDir = path.dirname(commandPath); 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 = [ const linkShell = [
{ {
src: 'update.sh', src: 'update.sh',
@@ -42,6 +53,7 @@ async function linkCommand() {
await fs.unlink(tmpTarget); await fs.unlink(tmpTarget);
} }
} catch (error) { } } catch (error) { }
await fs.symlink(source, tmpTarget); await fs.symlink(source, tmpTarget);
await fs.rename(tmpTarget, target); await fs.rename(tmpTarget, target);
} }
@@ -58,6 +70,6 @@ export default async (src: string = 'deps') => {
}); });
watcher watcher
.on('add', (path) => linkToNodeModule(src)) .on('add', () => linkToNodeModule(src))
.on('change', (path) => linkToNodeModule(src)); .on('change', () => linkToNodeModule(src));
}; };
+15 -6
View File
@@ -9,11 +9,19 @@ import rewrite from 'express-urlrewrite';
import { errors } from 'celebrate'; import { errors } from 'celebrate';
import { serveEnv } from '../config/serverEnv'; import { serveEnv } from '../config/serverEnv';
import { IKeyvStore, shareStore } from '../shared/store'; import { IKeyvStore, shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
import path from 'path'; import path from 'path';
export default ({ app }: { app: Application }) => { export default ({ app }: { app: Application }) => {
app.set('trust proxy', 'loopback'); app.set('trust proxy', 'loopback');
app.use(cors()); app.use(cors());
// Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness
if (config.baseUrl) {
app.use(rewrite(`${config.baseUrl}/*`, '/$1'));
}
app.get(`${config.api.prefix}/env.js`, serveEnv); app.get(`${config.api.prefix}/env.js`, serveEnv);
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath)); app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
@@ -77,11 +85,8 @@ export default ({ app }: { app: Application }) => {
} }
const authInfo = await shareStore.getAuthInfo(); const authInfo = await shareStore.getAuthInfo();
if (authInfo && headerToken) { if (isValidToken(authInfo, headerToken, req.platform)) {
const { token = '', tokens = {} } = authInfo; return next();
if (headerToken === token || tokens[req.platform] === headerToken) {
return next();
}
} }
const errorCode = headerToken ? 'invalid_token' : 'credentials_required'; const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
@@ -118,7 +123,11 @@ export default ({ app }: { app: Application }) => {
app.use(rewrite('/open/*', '/api/$1')); app.use(rewrite('/open/*', '/api/$1'));
app.use(config.api.prefix, routes()); app.use(config.api.prefix, routes());
app.get('*', (_, res, next) => { app.get('*', (req, res, next) => {
// Don't serve index.html for API routes
if (req.path.startsWith('/api/')) {
return next();
}
const indexPath = path.join(frontendPath, 'index.html'); const indexPath = path.join(frontendPath, 'index.html');
res.sendFile(indexPath, (err) => { res.sendFile(indexPath, (err) => {
if (err) { if (err) {
+7
View File
@@ -2,6 +2,7 @@ import { Container } from 'typedi';
import SystemService from '../services/system'; import SystemService from '../services/system';
import ScheduleService, { ScheduleTaskType } from '../services/schedule'; import ScheduleService, { ScheduleTaskType } from '../services/schedule';
import SubscriptionService from '../services/subscription'; import SubscriptionService from '../services/subscription';
import SshKeyService from '../services/sshKey';
import config from '../config'; import config from '../config';
import { fileExist } from '../config/util'; import { fileExist } from '../config/util';
import { join } from 'path'; import { join } from 'path';
@@ -10,6 +11,7 @@ export default async () => {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
const scheduleService = Container.get(ScheduleService); const scheduleService = Container.get(ScheduleService);
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const sshKeyService = Container.get(SshKeyService);
// 生成内置token // 生成内置token
let tokenCommand = `ts-node-transpile-only ${join( let tokenCommand = `ts-node-transpile-only ${join(
@@ -57,6 +59,11 @@ export default async () => {
} }
systemService.updateTimezone(data.info); systemService.updateTimezone(data.info);
// Apply global SSH key if configured
if (data.info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
}
} }
await subscriptionService.setSshConfig(); await subscriptionService.setSshConfig();
+13 -13
View File
@@ -4,9 +4,11 @@ import { Container } from 'typedi';
import SockService from '../services/sock'; import SockService from '../services/sock';
import { getPlatform } from '../config/util'; import { getPlatform } from '../config/util';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
import config from '../config';
export default async ({ server }: { server: Server }) => { export default async ({ server }: { server: Server }) => {
const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} }); const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } });
const sockService = Container.get(SockService); const sockService = Container.get(SockService);
echo.on('connection', async (conn) => { echo.on('connection', async (conn) => {
@@ -17,21 +19,19 @@ export default async ({ server }: { server: Server }) => {
const authInfo = await shareStore.getAuthInfo(); const authInfo = await shareStore.getAuthInfo();
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop'; const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, ''); 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) => { if (isValidToken(authInfo, headerToken, platform)) {
conn.write(message); sockService.addClient(conn);
});
conn.on('close', function () { conn.on('data', (message) => {
sockService.removeClient(conn); conn.write(message);
}); });
return; conn.on('close', function () {
} sockService.removeClient(conn);
});
return;
} }
conn.close('404'); conn.close('404');
+18
View File
@@ -97,6 +97,18 @@ message UpdateCronRequest {
message DeleteCronsRequest { repeated int32 ids = 1; } 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 { message CronsResponse {
int32 code = 1; int32 code = 1;
repeated CronItem data = 2; repeated CronItem data = 2;
@@ -219,6 +231,7 @@ message NotificationInfo {
optional string webhookContentType = 57; optional string webhookContentType = 57;
optional string larkKey = 58; optional string larkKey = 58;
optional string larkSecret = 69;
optional string ntfyUrl = 59; optional string ntfyUrl = 59;
optional string ntfyTopic = 60; optional string ntfyTopic = 60;
@@ -254,4 +267,9 @@ service Api {
rpc CreateCron(CreateCronRequest) returns (CronResponse) {} rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {} rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
rpc DeleteCrons(DeleteCronsRequest) returns (Response) {} 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) {}
} }
+490 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.17.3 // protoc v3.21.12
// source: back/protos/api.proto // source: back/protos/api.proto
/* eslint-disable */ /* eslint-disable */
@@ -281,6 +281,26 @@ export interface DeleteCronsRequest {
ids: number[]; ids: number[];
} }
export interface GetCronsRequest {
searchValue?: string | undefined;
}
export interface GetCronByIdRequest {
id: number;
}
export interface EnableCronsRequest {
ids: number[];
}
export interface DisableCronsRequest {
ids: number[];
}
export interface RunCronsRequest {
ids: number[];
}
export interface CronsResponse { export interface CronsResponse {
code: number; code: number;
data: CronItem[]; data: CronItem[];
@@ -362,6 +382,7 @@ export interface NotificationInfo {
webhookMethod?: string | undefined; webhookMethod?: string | undefined;
webhookContentType?: string | undefined; webhookContentType?: string | undefined;
larkKey?: string | undefined; larkKey?: string | undefined;
larkSecret?: string | undefined;
ntfyUrl?: string | undefined; ntfyUrl?: string | undefined;
ntfyTopic?: string | undefined; ntfyTopic?: string | undefined;
ntfyPriority?: string | undefined; ntfyPriority?: string | undefined;
@@ -2207,6 +2228,332 @@ export const DeleteCronsRequest: MessageFns<DeleteCronsRequest> = {
}, },
}; };
function createBaseGetCronsRequest(): GetCronsRequest {
return { searchValue: undefined };
}
export const GetCronsRequest: MessageFns<GetCronsRequest> = {
encode(message: GetCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.searchValue !== undefined) {
writer.uint32(10).string(message.searchValue);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): GetCronsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetCronsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.searchValue = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): GetCronsRequest {
return { searchValue: isSet(object.searchValue) ? globalThis.String(object.searchValue) : undefined };
},
toJSON(message: GetCronsRequest): unknown {
const obj: any = {};
if (message.searchValue !== undefined) {
obj.searchValue = message.searchValue;
}
return obj;
},
create<I extends Exact<DeepPartial<GetCronsRequest>, I>>(base?: I): GetCronsRequest {
return GetCronsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<GetCronsRequest>, I>>(object: I): GetCronsRequest {
const message = createBaseGetCronsRequest();
message.searchValue = object.searchValue ?? undefined;
return message;
},
};
function createBaseGetCronByIdRequest(): GetCronByIdRequest {
return { id: 0 };
}
export const GetCronByIdRequest: MessageFns<GetCronByIdRequest> = {
encode(message: GetCronByIdRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.id !== 0) {
writer.uint32(8).int32(message.id);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): GetCronByIdRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetCronByIdRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 8) {
break;
}
message.id = reader.int32();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): GetCronByIdRequest {
return { id: isSet(object.id) ? globalThis.Number(object.id) : 0 };
},
toJSON(message: GetCronByIdRequest): unknown {
const obj: any = {};
if (message.id !== 0) {
obj.id = Math.round(message.id);
}
return obj;
},
create<I extends Exact<DeepPartial<GetCronByIdRequest>, I>>(base?: I): GetCronByIdRequest {
return GetCronByIdRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<GetCronByIdRequest>, I>>(object: I): GetCronByIdRequest {
const message = createBaseGetCronByIdRequest();
message.id = object.id ?? 0;
return message;
},
};
function createBaseEnableCronsRequest(): EnableCronsRequest {
return { ids: [] };
}
export const EnableCronsRequest: MessageFns<EnableCronsRequest> = {
encode(message: EnableCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
writer.uint32(10).fork();
for (const v of message.ids) {
writer.int32(v);
}
writer.join();
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): EnableCronsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseEnableCronsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag === 8) {
message.ids.push(reader.int32());
continue;
}
if (tag === 10) {
const end2 = reader.uint32() + reader.pos;
while (reader.pos < end2) {
message.ids.push(reader.int32());
}
continue;
}
break;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): EnableCronsRequest {
return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] };
},
toJSON(message: EnableCronsRequest): unknown {
const obj: any = {};
if (message.ids?.length) {
obj.ids = message.ids.map((e) => Math.round(e));
}
return obj;
},
create<I extends Exact<DeepPartial<EnableCronsRequest>, I>>(base?: I): EnableCronsRequest {
return EnableCronsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<EnableCronsRequest>, I>>(object: I): EnableCronsRequest {
const message = createBaseEnableCronsRequest();
message.ids = object.ids?.map((e) => e) || [];
return message;
},
};
function createBaseDisableCronsRequest(): DisableCronsRequest {
return { ids: [] };
}
export const DisableCronsRequest: MessageFns<DisableCronsRequest> = {
encode(message: DisableCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
writer.uint32(10).fork();
for (const v of message.ids) {
writer.int32(v);
}
writer.join();
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): DisableCronsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDisableCronsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag === 8) {
message.ids.push(reader.int32());
continue;
}
if (tag === 10) {
const end2 = reader.uint32() + reader.pos;
while (reader.pos < end2) {
message.ids.push(reader.int32());
}
continue;
}
break;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): DisableCronsRequest {
return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] };
},
toJSON(message: DisableCronsRequest): unknown {
const obj: any = {};
if (message.ids?.length) {
obj.ids = message.ids.map((e) => Math.round(e));
}
return obj;
},
create<I extends Exact<DeepPartial<DisableCronsRequest>, I>>(base?: I): DisableCronsRequest {
return DisableCronsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<DisableCronsRequest>, I>>(object: I): DisableCronsRequest {
const message = createBaseDisableCronsRequest();
message.ids = object.ids?.map((e) => e) || [];
return message;
},
};
function createBaseRunCronsRequest(): RunCronsRequest {
return { ids: [] };
}
export const RunCronsRequest: MessageFns<RunCronsRequest> = {
encode(message: RunCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
writer.uint32(10).fork();
for (const v of message.ids) {
writer.int32(v);
}
writer.join();
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): RunCronsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseRunCronsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag === 8) {
message.ids.push(reader.int32());
continue;
}
if (tag === 10) {
const end2 = reader.uint32() + reader.pos;
while (reader.pos < end2) {
message.ids.push(reader.int32());
}
continue;
}
break;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): RunCronsRequest {
return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] };
},
toJSON(message: RunCronsRequest): unknown {
const obj: any = {};
if (message.ids?.length) {
obj.ids = message.ids.map((e) => Math.round(e));
}
return obj;
},
create<I extends Exact<DeepPartial<RunCronsRequest>, I>>(base?: I): RunCronsRequest {
return RunCronsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RunCronsRequest>, I>>(object: I): RunCronsRequest {
const message = createBaseRunCronsRequest();
message.ids = object.ids?.map((e) => e) || [];
return message;
},
};
function createBaseCronsResponse(): CronsResponse { function createBaseCronsResponse(): CronsResponse {
return { code: 0, data: [], message: undefined }; return { code: 0, data: [], message: undefined };
} }
@@ -2601,6 +2948,7 @@ function createBaseNotificationInfo(): NotificationInfo {
webhookMethod: undefined, webhookMethod: undefined,
webhookContentType: undefined, webhookContentType: undefined,
larkKey: undefined, larkKey: undefined,
larkSecret: undefined,
ntfyUrl: undefined, ntfyUrl: undefined,
ntfyTopic: undefined, ntfyTopic: undefined,
ntfyPriority: undefined, ntfyPriority: undefined,
@@ -2790,6 +3138,9 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.larkKey !== undefined) { if (message.larkKey !== undefined) {
writer.uint32(466).string(message.larkKey); writer.uint32(466).string(message.larkKey);
} }
if (message.larkSecret !== undefined) {
writer.uint32(554).string(message.larkSecret);
}
if (message.ntfyUrl !== undefined) { if (message.ntfyUrl !== undefined) {
writer.uint32(474).string(message.ntfyUrl); writer.uint32(474).string(message.ntfyUrl);
} }
@@ -3294,6 +3645,14 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.larkKey = reader.string(); message.larkKey = reader.string();
continue; continue;
} }
case 69: {
if (tag !== 554) {
break;
}
message.larkSecret = reader.string();
continue;
}
case 59: { case 59: {
if (tag !== 474) { if (tag !== 474) {
break; break;
@@ -3451,6 +3810,7 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
webhookMethod: isSet(object.webhookMethod) ? globalThis.String(object.webhookMethod) : undefined, webhookMethod: isSet(object.webhookMethod) ? globalThis.String(object.webhookMethod) : undefined,
webhookContentType: isSet(object.webhookContentType) ? globalThis.String(object.webhookContentType) : undefined, webhookContentType: isSet(object.webhookContentType) ? globalThis.String(object.webhookContentType) : undefined,
larkKey: isSet(object.larkKey) ? globalThis.String(object.larkKey) : undefined, larkKey: isSet(object.larkKey) ? globalThis.String(object.larkKey) : undefined,
larkSecret: isSet(object.larkSecret) ? globalThis.String(object.larkSecret) : undefined,
ntfyUrl: isSet(object.ntfyUrl) ? globalThis.String(object.ntfyUrl) : undefined, ntfyUrl: isSet(object.ntfyUrl) ? globalThis.String(object.ntfyUrl) : undefined,
ntfyTopic: isSet(object.ntfyTopic) ? globalThis.String(object.ntfyTopic) : undefined, ntfyTopic: isSet(object.ntfyTopic) ? globalThis.String(object.ntfyTopic) : undefined,
ntfyPriority: isSet(object.ntfyPriority) ? globalThis.String(object.ntfyPriority) : undefined, ntfyPriority: isSet(object.ntfyPriority) ? globalThis.String(object.ntfyPriority) : undefined,
@@ -3644,6 +4004,9 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.larkKey !== undefined) { if (message.larkKey !== undefined) {
obj.larkKey = message.larkKey; obj.larkKey = message.larkKey;
} }
if (message.larkSecret !== undefined) {
obj.larkSecret = message.larkSecret;
}
if (message.ntfyUrl !== undefined) { if (message.ntfyUrl !== undefined) {
obj.ntfyUrl = message.ntfyUrl; obj.ntfyUrl = message.ntfyUrl;
} }
@@ -3740,6 +4103,7 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.webhookMethod = object.webhookMethod ?? undefined; message.webhookMethod = object.webhookMethod ?? undefined;
message.webhookContentType = object.webhookContentType ?? undefined; message.webhookContentType = object.webhookContentType ?? undefined;
message.larkKey = object.larkKey ?? undefined; message.larkKey = object.larkKey ?? undefined;
message.larkSecret = object.larkSecret ?? undefined;
message.ntfyUrl = object.ntfyUrl ?? undefined; message.ntfyUrl = object.ntfyUrl ?? undefined;
message.ntfyTopic = object.ntfyTopic ?? undefined; message.ntfyTopic = object.ntfyTopic ?? undefined;
message.ntfyPriority = object.ntfyPriority ?? undefined; message.ntfyPriority = object.ntfyPriority ?? undefined;
@@ -3976,6 +4340,51 @@ export const ApiService = {
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value), responseDeserialize: (value: Buffer) => Response.decode(value),
}, },
getCrons: {
path: "/com.ql.api.Api/GetCrons",
requestStream: false,
responseStream: false,
requestSerialize: (value: GetCronsRequest) => Buffer.from(GetCronsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => GetCronsRequest.decode(value),
responseSerialize: (value: CronsResponse) => Buffer.from(CronsResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => CronsResponse.decode(value),
},
getCronById: {
path: "/com.ql.api.Api/GetCronById",
requestStream: false,
responseStream: false,
requestSerialize: (value: GetCronByIdRequest) => Buffer.from(GetCronByIdRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => GetCronByIdRequest.decode(value),
responseSerialize: (value: CronResponse) => Buffer.from(CronResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => CronResponse.decode(value),
},
enableCrons: {
path: "/com.ql.api.Api/EnableCrons",
requestStream: false,
responseStream: false,
requestSerialize: (value: EnableCronsRequest) => Buffer.from(EnableCronsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => EnableCronsRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
disableCrons: {
path: "/com.ql.api.Api/DisableCrons",
requestStream: false,
responseStream: false,
requestSerialize: (value: DisableCronsRequest) => Buffer.from(DisableCronsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => DisableCronsRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
runCrons: {
path: "/com.ql.api.Api/RunCrons",
requestStream: false,
responseStream: false,
requestSerialize: (value: RunCronsRequest) => Buffer.from(RunCronsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => RunCronsRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
} as const; } as const;
export interface ApiServer extends UntypedServiceImplementation { export interface ApiServer extends UntypedServiceImplementation {
@@ -3993,6 +4402,11 @@ export interface ApiServer extends UntypedServiceImplementation {
createCron: handleUnaryCall<CreateCronRequest, CronResponse>; createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>; updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
deleteCrons: handleUnaryCall<DeleteCronsRequest, Response>; deleteCrons: handleUnaryCall<DeleteCronsRequest, Response>;
getCrons: handleUnaryCall<GetCronsRequest, CronsResponse>;
getCronById: handleUnaryCall<GetCronByIdRequest, CronResponse>;
enableCrons: handleUnaryCall<EnableCronsRequest, Response>;
disableCrons: handleUnaryCall<DisableCronsRequest, Response>;
runCrons: handleUnaryCall<RunCronsRequest, Response>;
} }
export interface ApiClient extends Client { export interface ApiClient extends Client {
@@ -4206,6 +4620,81 @@ export interface ApiClient extends Client {
options: Partial<CallOptions>, options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void, callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall; ): ClientUnaryCall;
getCrons(
request: GetCronsRequest,
callback: (error: ServiceError | null, response: CronsResponse) => void,
): ClientUnaryCall;
getCrons(
request: GetCronsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: CronsResponse) => void,
): ClientUnaryCall;
getCrons(
request: GetCronsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: CronsResponse) => void,
): ClientUnaryCall;
getCronById(
request: GetCronByIdRequest,
callback: (error: ServiceError | null, response: CronResponse) => void,
): ClientUnaryCall;
getCronById(
request: GetCronByIdRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: CronResponse) => void,
): ClientUnaryCall;
getCronById(
request: GetCronByIdRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: CronResponse) => void,
): ClientUnaryCall;
enableCrons(
request: EnableCronsRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
enableCrons(
request: EnableCronsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
enableCrons(
request: EnableCronsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
disableCrons(
request: DisableCronsRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
disableCrons(
request: DisableCronsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
disableCrons(
request: DisableCronsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
runCrons(
request: RunCronsRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
runCrons(
request: RunCronsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
runCrons(
request: RunCronsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
} }
export const ApiClient = makeGenericClientConstructor(ApiService, "com.ql.api.Api") as unknown as { export const ApiClient = makeGenericClientConstructor(ApiService, "com.ql.api.Api") as unknown as {
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.17.3 // protoc v3.21.12
// source: back/protos/cron.proto // source: back/protos/cron.proto
/* eslint-disable */ /* eslint-disable */
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.17.3 // protoc v3.21.12
// source: back/protos/health.proto // source: back/protos/health.proto
/* eslint-disable */ /* eslint-disable */
+119
View File
@@ -30,6 +30,12 @@ import {
UpdateCronRequest, UpdateCronRequest,
DeleteCronsRequest, DeleteCronsRequest,
CronResponse, CronResponse,
GetCronsRequest,
CronsResponse,
GetCronByIdRequest,
EnableCronsRequest,
DisableCronsRequest,
RunCronsRequest,
} from '../protos/api'; } from '../protos/api';
import { NotificationInfo } from '../data/notify'; import { NotificationInfo } from '../data/notify';
@@ -323,3 +329,116 @@ export const deleteCrons = async (
callback(e); 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);
}
};
+3 -6
View File
@@ -17,14 +17,11 @@ const check = async (
return callback(null, { status: 1 }); return callback(null, { status: 1 });
} }
const panelErrLog = await promiseExec( const qinglongErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/panel-error.log`, `tail -n 300 ~/.pm2/logs/qinglong-error.log`,
);
const scheduleErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/schedule-error.log`,
); );
return callback( return callback(
new Error(`${scheduleErrLog || ''}\n${panelErrLog || ''}\n${res}`.trim()), new Error(`${qinglongErrLog || ''}\n${res}`.trim()),
); );
default: default:
+69 -22
View File
@@ -4,11 +4,12 @@ import config from '../config';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron'; import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import { exec, execSync } from 'child_process'; import { exec, execSync } from 'child_process';
import fs from 'fs/promises'; import fs from 'fs/promises';
import cron_parser from 'cron-parser'; import CronExpressionParser from 'cron-parser';
import { import {
getFileContentByName, getFileContentByName,
fileExist, fileExist,
killTask, killTask,
killAllTasks,
getUniqPath, getUniqPath,
safeJSONParse, safeJSONParse,
isDemoEnv, isDemoEnv,
@@ -24,6 +25,7 @@ import pickBy from 'lodash/pickBy';
import omit from 'lodash/omit'; import omit from 'lodash/omit';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import { ScheduleType } from '../interface/schedule'; import { ScheduleType } from '../interface/schedule';
import { logStreamManager } from '../shared/logStreamManager';
@Service() @Service()
export default class CronService { export default class CronService {
@@ -49,9 +51,29 @@ export default class CronService {
return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule); 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> { public async create(payload: Crontab): Promise<Crontab> {
const tab = new Crontab(payload); const tab = new Crontab(payload);
tab.saved = false; tab.saved = false;
tab.log_name = await this.getLogName(tab);
const doc = await this.insert(tab); const doc = await this.insert(tab);
if (isDemoEnv()) { if (isDemoEnv()) {
@@ -82,6 +104,7 @@ export default class CronService {
const doc = await this.getDb({ id: payload.id }); const doc = await this.getDb({ id: payload.id });
const tab = new Crontab({ ...doc, ...payload }); const tab = new Crontab({ ...doc, ...payload });
tab.saved = false; tab.saved = false;
tab.log_name = await this.getLogName(tab);
const newDoc = await this.updateDb(tab); const newDoc = await this.updateDb(tab);
if (doc.isDisabled === 1 || isDemoEnv()) { if (doc.isDisabled === 1 || isDemoEnv()) {
@@ -442,12 +465,17 @@ export default class CronService {
public async stop(ids: number[]) { public async stop(ids: number[]) {
const docs = await CrontabModel.findAll({ where: { id: ids } }); const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) { for (const doc of docs) {
if (doc.pid) { // Kill all running instances of this task
try { try {
await killTask(doc.pid); const command = this.makeCommand(doc);
} catch (error) { await killAllTasks(command);
this.logger.error(error); 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)}`, `[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
); );
let { id, command, log_path } = cron; let { id, command, log_name } = cron;
const uniqPath = await getUniqPath(command, `${id}`);
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 logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${uniqPath}`); 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 logPath = `${uniqPath}/${logTime}.log`;
const absolutePath = path.resolve(config.logPath, `${logPath}`); const absolutePath = path.resolve(config.logPath, `${logPath}`);
const cp = spawn( const cp = spawn(
@@ -498,7 +528,7 @@ export default class CronService {
{ where: { id } }, { where: { id } },
); );
cp.stdout.on('data', async (data) => { cp.stdout.on('data', async (data) => {
await fs.appendFile(absolutePath, data.toString()); await logStreamManager.write(absolutePath, data.toString());
}); });
cp.stderr.on('data', async (data) => { cp.stderr.on('data', async (data) => {
this.logger.info( this.logger.info(
@@ -506,7 +536,7 @@ export default class CronService {
command, command,
data.toString(), data.toString(),
); );
await fs.appendFile(absolutePath, data.toString()); await logStreamManager.write(absolutePath, data.toString());
}); });
cp.on('error', async (err) => { cp.on('error', async (err) => {
this.logger.error( this.logger.error(
@@ -514,7 +544,7 @@ export default class CronService {
command, command,
err, err,
); );
await fs.appendFile(absolutePath, JSON.stringify(err)); await logStreamManager.write(absolutePath, JSON.stringify(err));
}); });
cp.on('exit', async (code) => { cp.on('exit', async (code) => {
@@ -523,6 +553,8 @@ export default class CronService {
JSON.stringify(params), JSON.stringify(params),
code, code,
); );
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await CrontabModel.update( await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined }, { status: CrontabStatus.idle, pid: undefined },
{ where: { id } }, { where: { id } },
@@ -564,7 +596,9 @@ export default class CronService {
if (!doc) { if (!doc) {
return ''; return '';
} }
if (doc.log_name === '/dev/null') {
return '日志设置为忽略';
}
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`); const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
const logFileExist = doc.log_path && (await fileExist(absolutePath)); const logFileExist = doc.log_path && (await fileExist(absolutePath));
if (logFileExist) { if (logFileExist) {
@@ -607,9 +641,11 @@ export default class CronService {
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) { if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
command = `${TASK_PREFIX}${tab.command}`; command = `${TASK_PREFIX}${tab.command}`;
} }
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${ let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${tab.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) { if (tab.task_before) {
commandVariable += `task_before='${tab.task_before commandVariable += `task_before='${tab.task_before
.replace(/'/g, "'\\''") .replace(/'/g, "'\\''")
@@ -651,12 +687,23 @@ export default class CronService {
await writeFileWithLock(config.crontabFile, crontab_string); 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: {} }); await CrontabModel.update({ saved: true }, { where: {} });
} }
public importCrontab() { 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 lines = stdout.split('\n');
const namePrefix = new Date().getTime(); const namePrefix = new Date().getTime();
@@ -670,7 +717,7 @@ export default class CronService {
if ( if (
command && command &&
schedule && schedule &&
cron_parser.parseExpression(schedule).hasNext() CronExpressionParser.parse(schedule).hasNext()
) { ) {
const name = namePrefix + '_' + index; const name = namePrefix + '_' + index;
+1 -1
View File
@@ -107,7 +107,7 @@ export default class DependenceService {
query: any = {}, query: any = {},
): Promise<Dependence[]> { ): Promise<Dependence[]> {
let condition = query; let condition = query;
if (DependenceTypes[type]) { if (type && DependenceTypes[type] !== undefined) {
condition.type = DependenceTypes[type]; condition.type = DependenceTypes[type];
} }
if (status) { if (status) {
+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 winston from 'winston';
import config from '../config'; import config from '../config';
import * as fs from 'fs/promises';
import { import {
Env, Env,
EnvModel, EnvModel,
@@ -11,8 +12,6 @@ import {
minPosition, minPosition,
stepPosition, stepPosition,
} from '../data/env'; } from '../data/env';
import groupBy from 'lodash/groupBy';
import { FindOptions, Op } from 'sequelize';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
@Service() @Service()
@@ -147,6 +146,7 @@ export default class EnvService {
} }
try { try {
const result = await this.find(condition, [ const result = await this.find(condition, [
['isPinned', 'DESC'],
['position', 'DESC'], ['position', 'DESC'],
['createdAt', 'ASC'], ['createdAt', 'ASC'],
]); ]);
@@ -190,6 +190,14 @@ export default class EnvService {
await this.set_envs(); 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() { public async set_envs() {
const envs = await this.envs('', { const envs = await this.envs('', {
name: { [Op.not]: null }, name: { [Op.not]: null },
+19 -5
View File
@@ -550,19 +550,33 @@ export default class NotificationService {
} }
private async lark() { private async lark() {
let { larkKey } = this.params; let { larkKey, larkSecret } = this.params;
if (!larkKey.startsWith('http')) { if (!larkKey.startsWith('http')) {
larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`; 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 { try {
const res = await httpClient.post(larkKey, { const res = await httpClient.post(larkKey, {
...this.gotOption, ...this.gotOption,
json: { json: body,
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
},
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
if (res.StatusCode === 0 || res.code === 0) { if (res.StatusCode === 0 || res.code === 0) {
+28
View File
@@ -131,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',
},
);
}
} }
+11 -7
View File
@@ -31,6 +31,7 @@ import { formatCommand, formatUrl } from '../config/subscription';
import { CrontabModel } from '../data/cron'; import { CrontabModel } from '../data/cron';
import CrontabService from './cron'; import CrontabService from './cron';
import taskLimit from '../shared/pLimit'; import taskLimit from '../shared/pLimit';
import { logStreamManager } from '../shared/logStreamManager';
@Service() @Service()
export default class SubscriptionService { export default class SubscriptionService {
@@ -136,7 +137,7 @@ export default class SubscriptionService {
let beforeStr = ''; let beforeStr = '';
try { try {
if (doc.sub_before) { 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); beforeStr = await promiseExec(doc.sub_before);
} }
} catch (error: any) { } catch (error: any) {
@@ -144,7 +145,7 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error); (error.stderr && error.stderr.toString()) || JSON.stringify(error);
} }
if (beforeStr) { if (beforeStr) {
await fs.appendFile(absolutePath, `${beforeStr}\n`); await logStreamManager.write(absolutePath, `${beforeStr}\n`);
} }
}, },
onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => { onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => {
@@ -163,7 +164,7 @@ export default class SubscriptionService {
let afterStr = ''; let afterStr = '';
try { try {
if (sub.sub_after) { 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); afterStr = await promiseExec(sub.sub_after);
} }
} catch (error: any) { } catch (error: any) {
@@ -171,16 +172,19 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error); (error.stderr && error.stderr.toString()) || JSON.stringify(error);
} }
if (afterStr) { if (afterStr) {
await fs.appendFile(absolutePath, `${afterStr}\n`); await logStreamManager.write(absolutePath, `${afterStr}\n`);
} }
await fs.appendFile( await logStreamManager.write(
absolutePath, absolutePath,
`\n## 执行结束... ${endTime.format( `\n## 执行结束... ${endTime.format(
'YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm:ss',
)} 耗时 ${diff}${LOG_END_SYMBOL}`, )} 耗时 ${diff}${LOG_END_SYMBOL}`,
); );
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await SubscriptionModel.update( await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: undefined }, { status: SubscriptionStatus.idle, pid: undefined },
{ where: { id: sub.id } }, { where: { id: sub.id } },
@@ -195,12 +199,12 @@ export default class SubscriptionService {
onError: async (message: string) => { onError: async (message: string) => {
const sub = await this.getDb({ id: doc.id }); const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string); 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) => { onLog: async (message: string) => {
const sub = await this.getDb({ id: doc.id }); const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string); const absolutePath = await handleLogPath(sub.log_path as string);
await fs.appendFile(absolutePath, `\n${message}`); await logStreamManager.write(absolutePath, `\n${message}`);
}, },
}; };
} }
+21
View File
@@ -530,6 +530,27 @@ export default class SystemService {
} }
} }
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') { public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) { if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: '参数错误' }; return { code: 400, message: '参数错误' };
+139 -7
View File
@@ -11,6 +11,7 @@ import {
SystemModelInfo, SystemModelInfo,
LoginStatus, LoginStatus,
AuthInfo, AuthInfo,
TokenInfo,
} from '../data/system'; } from '../data/system';
import { NotificationInfo } from '../data/notify'; import { NotificationInfo } from '../data/notify';
import NotificationService from './notify'; import NotificationService from './notify';
@@ -101,12 +102,23 @@ export default class UserService {
algorithm: 'HS384', 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, { await this.updateAuthInfo(content, {
token, token,
tokens: { tokens: updatedTokens,
...tokens,
[req.platform]: token,
},
lastlogon: timestamp, lastlogon: timestamp,
retries: 0, retries: 0,
lastip: ip, 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(); 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, { await this.updateAuthInfo(authInfo, {
token: '', token: authInfo.token === tokenValue ? '' : authInfo.token,
tokens: { ...authInfo.tokens, [platform]: '' }, 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>) { public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info; const { retries, twoFactorActivated, password, username } = info;
const authInfo = await this.getAuthInfo(); 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 taskLimit from './pLimit';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
import { ICron } from '../protos/cron'; 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> { export function runCron(cmd: string, cron: ICron): Promise<number | void> {
return taskLimit.runWithCronLimit(cron, () => { return taskLimit.runWithCronLimit(cron, () => {
return new Promise(async (resolve: any) => { 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( Logger.info(
`[schedule][开始执行任务] 参数 ${JSON.stringify({ `[schedule][开始执行任务] 参数 ${JSON.stringify({
...cron, ...cron,
+47 -2
View File
@@ -1,6 +1,8 @@
import { Joi } from 'celebrate'; import { Joi } from 'celebrate';
import cron_parser from 'cron-parser'; import CronExpressionParser from 'cron-parser';
import { ScheduleType } from '../interface/schedule'; import { ScheduleType } from '../interface/schedule';
import path from 'path';
import config from '../config';
const validateSchedule = (value: string, helpers: any) => { const validateSchedule = (value: string, helpers: any) => {
if ( if (
@@ -11,7 +13,7 @@ const validateSchedule = (value: string, helpers: any) => {
} }
try { try {
if (cron_parser.parseExpression(value).hasNext()) { if (CronExpressionParser.parse(value).hasNext()) {
return value; return value;
} }
} catch (e) { } catch (e) {
@@ -37,4 +39,47 @@ export const commonCronSchema = {
extra_schedules: Joi.array().optional().allow(null), extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null), task_before: Joi.string().optional().allow('').allow(null),
task_after: 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),
}; };
+26 -7
View File
@@ -1,37 +1,56 @@
#!/bin/bash #!/bin/bash
export PATH="$HOME/bin:$PATH"
dir_shell=/ql/shell dir_shell=/ql/shell
. $dir_shell/share.sh . $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() { log_with_style() {
local level="$1" local level="$1"
local message="$2" local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S') local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}" 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 "$@" import_config "$@"
fix_config 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. 启动 pm2 服务..." log_with_style "INFO" "⚙️ 2. 启动 pm2 服务..."
reload_pm2 reload_pm2
if [[ $AutoStartBot == true ]]; then if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 3. 启动 bot..." log_with_style "INFO" "🤖 3. 启动 bot..."
nohup ql bot >$dir_log/bot.log 2>&1 & nohup ql bot >$dir_log/bot.log 2>&1 &
fi fi
if [[ $EnableExtraShell == true ]]; then if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..." log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 & nohup ql extra >$dir_log/extra.log 2>&1 &
fi fi
log_with_style "SUCCESS" "🎉 容器启动成功!" log_with_style "SUCCESS" "🎉 容器启动成功!"
crond -f >/dev/null crond -f >/dev/null
+2 -1
View File
@@ -1,5 +1,6 @@
{ {
"private": true, "private": true,
"packageManager": "pnpm@8.3.1",
"scripts": { "scripts": {
"start": "concurrently -n w: npm:start:*", "start": "concurrently -n w: npm:start:*",
"start:back": "nodemon ./back/app.ts", "start:back": "nodemon ./back/app.ts",
@@ -61,7 +62,7 @@
"celebrate": "^15.0.3", "celebrate": "^15.0.3",
"chokidar": "^4.0.1", "chokidar": "^4.0.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"cron-parser": "^4.9.0", "cron-parser": "^5.4.0",
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.4.6", "dotenv": "^16.4.6",
+14 -2
View File
@@ -39,8 +39,8 @@ dependencies:
specifier: ^2.8.5 specifier: ^2.8.5
version: 2.8.5 version: 2.8.5
cron-parser: cron-parser:
specifier: ^4.9.0 specifier: ^5.4.0
version: 4.9.0 version: 5.4.0
cross-spawn: cross-spawn:
specifier: ^7.0.6 specifier: ^7.0.6
version: 7.0.6 version: 7.0.6
@@ -6524,6 +6524,13 @@ packages:
luxon: 3.5.0 luxon: 3.5.0
dev: false 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: /croner@7.0.8:
resolution: {integrity: sha512-4E27J9ZQV9prM9ggU18QGPYPMSblbA9JuGv4Ff3Gk6supX4RszNGQxBgiFBL6wb/L9HuSMpFbQpduMiDRo+z5Q==} resolution: {integrity: sha512-4E27J9ZQV9prM9ggU18QGPYPMSblbA9JuGv4Ff3Gk6supX4RszNGQxBgiFBL6wb/L9HuSMpFbQpduMiDRo+z5Q==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -9792,6 +9799,11 @@ packages:
engines: {node: '>=12'} engines: {node: '>=12'}
dev: false dev: false
/luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
dev: false
/make-dir@2.1.0: /make-dir@2.1.0:
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
engines: {node: '>=6'} engines: {node: '>=6'}
+37 -5
View File
@@ -52,6 +52,7 @@ const push_config = {
DD_BOT_TOKEN: '', // 钉钉机器人的 DD_BOT_TOKEN DD_BOT_TOKEN: '', // 钉钉机器人的 DD_BOT_TOKEN
FSKEY: '', // 飞书机器人的 FSKEY FSKEY: '', // 飞书机器人的 FSKEY
FSSECRET: '', // 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥
// 推送到个人QQhttp://127.0.0.1/send_private_msg // 推送到个人QQhttp://127.0.0.1/send_private_msg
// 群:http://127.0.0.1/send_group_msg // 群:http://127.0.0.1/send_group_msg
@@ -481,9 +482,13 @@ function tgBotNotify(text, desp) {
timeout, timeout,
}; };
if (TG_PROXY_HOST && TG_PROXY_PORT) { if (TG_PROXY_HOST && TG_PROXY_PORT) {
let proxyHost = TG_PROXY_HOST;
if (TG_PROXY_AUTH && !TG_PROXY_HOST.includes('@')) {
proxyHost = `${TG_PROXY_AUTH}@${TG_PROXY_HOST}`;
}
let agent; let agent;
agent = new ProxyAgent({ agent = new ProxyAgent({
uri: `http://${TG_PROXY_AUTH}${TG_PROXY_HOST}:${TG_PROXY_PORT}`, uri: `http://${proxyHost}:${TG_PROXY_PORT}`,
}); });
options.dispatcher = agent; options.dispatcher = agent;
} }
@@ -989,11 +994,29 @@ function aibotkNotify(text, desp) {
function fsBotNotify(text, desp) { function fsBotNotify(text, desp) {
return new Promise((resolve) => { return new Promise((resolve) => {
const { FSKEY } = push_config; const { FSKEY, FSSECRET } = push_config;
if (FSKEY) { 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 = { const options = {
url: `https://open.feishu.cn/open-apis/bot/v2/hook/${FSKEY}`, url: `https://open.feishu.cn/open-apis/bot/v2/hook/${FSKEY}`,
json: { msg_type: 'text', content: { text: `${text}\n\n${desp}` } }, json: body,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -1262,7 +1285,15 @@ function ntfyNotify(text, desp) {
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const { NTFY_URL, NTFY_TOPIC, NTFY_PRIORITY, NTFY_TOKEN, NTFY_USERNAME, NTFY_PASSWORD, NTFY_ACTIONS } = push_config; const {
NTFY_URL,
NTFY_TOPIC,
NTFY_PRIORITY,
NTFY_TOKEN,
NTFY_USERNAME,
NTFY_PASSWORD,
NTFY_ACTIONS,
} = push_config;
if (NTFY_TOPIC) { if (NTFY_TOPIC) {
const options = { const options = {
url: `${NTFY_URL || 'https://ntfy.sh'}/${NTFY_TOPIC}`, url: `${NTFY_URL || 'https://ntfy.sh'}/${NTFY_TOPIC}`,
@@ -1277,7 +1308,8 @@ function ntfyNotify(text, desp) {
if (NTFY_TOKEN) { if (NTFY_TOKEN) {
options.headers['Authorization'] = `Bearer ${NTFY_TOKEN}`; options.headers['Authorization'] = `Bearer ${NTFY_TOKEN}`;
} else if (NTFY_USERNAME && NTFY_PASSWORD) { } else if (NTFY_USERNAME && NTFY_PASSWORD) {
options.headers['Authorization'] = `Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`; options.headers['Authorization'] =
`Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`;
} }
if (NTFY_ACTIONS) { if (NTFY_ACTIONS) {
options.headers['Actions'] = encodeRFC2047(NTFY_ACTIONS); options.headers['Actions'] = encodeRFC2047(NTFY_ACTIONS);
+15
View File
@@ -49,6 +49,7 @@ push_config = {
'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN 'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN
'FSKEY': '', # 飞书机器人的 FSKEY 'FSKEY': '', # 飞书机器人的 FSKEY
'FSSECRET': '', # 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥
'GOBOT_URL': '', # go-cqhttp 'GOBOT_URL': '', # go-cqhttp
# 推送到个人QQhttp://127.0.0.1/send_private_msg # 推送到个人QQhttp://127.0.0.1/send_private_msg
@@ -233,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")}' 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}"}} 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() response = requests.post(url, data=json.dumps(data)).json()
if response.get("StatusCode") == 0 or response.get("code") == 0: if response.get("StatusCode") == 0 or response.get("code") == 0:
+28
View File
@@ -12,4 +12,32 @@ QLAPI.getEnvs({ searchValue: 'dddd' }).then((x) => {
QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => { QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => {
console.log('systemNotify', 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'); console.log('test desc');
+8 -8
View File
@@ -41,7 +41,7 @@ add_cron_api() {
fi fi
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \ --data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \
@@ -71,7 +71,7 @@ update_cron_api() {
fi fi
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -98,7 +98,7 @@ update_cron_command_api() {
fi fi
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -118,7 +118,7 @@ del_cron_api() {
local ids="$1" local ids="$1"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
-X 'DELETE' \ -X 'DELETE' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -143,7 +143,7 @@ update_cron() {
local runningTime="${6:-0}" local runningTime="${6:-0}"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons/status?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons/status?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -165,7 +165,7 @@ notify_api() {
local content="$2" local content="$2"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/open/system/notify?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/system/notify?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -185,7 +185,7 @@ find_cron_api() {
local params="$1" local params="$1"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons/detail?$params&t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons/detail?$params&t=$currentTimeStamp" \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
--compressed --compressed
@@ -204,7 +204,7 @@ update_auth_config() {
local tip="$2" local tip="$2"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/open/system/auth/reset?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
+5 -5
View File
@@ -24,14 +24,14 @@ copy_dep() {
pm2_log() { pm2_log() {
echo -e "---> pm2日志" echo -e "---> pm2日志"
local panelOut="/root/.pm2/logs/panel-out.log" local panelOut="/root/.pm2/logs/qinglong-out.log"
local panelError="/root/.pm2/logs/panel-error.log" local panelError="/root/.pm2/logs/qinglong-error.log"
tail -n 300 "$panelOut" tail -n 300 "$panelOut"
tail -n 300 "$panelError" tail -n 300 "$panelError"
} }
check_ql() { 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" echo -e "\n=====> 检测面板\n\n$api\n"
if [[ $api =~ "<div id=\"root\"></div>" ]]; then if [[ $api =~ "<div id=\"root\"></div>" ]]; then
echo -e "=====> 面板服务启动正常\n" echo -e "=====> 面板服务启动正常\n"
@@ -42,10 +42,10 @@ check_pm2() {
pm2_log pm2_log
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/api/system?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/api/system?t=$currentTimeStamp" \
-H 'Accept: */*' \ -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 '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' \ -H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \
--compressed --compressed
) )
-352
View File
@@ -1,352 +0,0 @@
# Qinglong Python API (QLAPI) Documentation
## Overview
The Qinglong Python API provides a convenient way to interact with the Qinglong system from Python scripts. The `QLAPI` object is automatically available in your Python scripts when they run within the Qinglong environment.
## Availability
The Python QLAPI is available starting from **version 2.8.0+**. If you're using an older version (e.g., v2.7.11), please upgrade to access these features.
## Prerequisites
- Qinglong version 2.8.0 or higher
- Python 3.6 or higher
- Running within Qinglong environment (scripts executed through Qinglong task system)
## Usage
The `QLAPI` object is automatically injected into your Python script's global namespace. You don't need to import anything - just use it directly:
```python
# QLAPI is automatically available
result = QLAPI.getEnvs({"searchValue": "USER"})
print(result)
```
## Available Methods
### Environment Variables Management
#### getEnvs
Get environment variables with optional search filter.
```python
# Get all environment variables
envs = QLAPI.getEnvs()
# Search for specific environment variables
envs = QLAPI.getEnvs({"searchValue": "USER"})
```
**Parameters:**
- `searchValue` (optional): String to search for in environment variable names or values
**Returns:** `EnvsResponse` with list of environment variables
#### createEnv
Create new environment variables.
```python
result = QLAPI.createEnv({
"envs": [
{
"name": "MY_VAR",
"value": "my_value",
"remarks": "My custom variable"
}
]
})
```
**Parameters:**
- `envs`: List of environment variable objects to create
**Returns:** `EnvsResponse`
#### updateEnv
Update an existing environment variable.
```python
result = QLAPI.updateEnv({
"env": {
"id": 123,
"name": "MY_VAR",
"value": "new_value",
"remarks": "Updated variable"
}
})
```
**Parameters:**
- `env`: Environment variable object with id and updated fields
**Returns:** `EnvResponse`
#### deleteEnvs
Delete environment variables by IDs.
```python
result = QLAPI.deleteEnvs({"ids": [123, 456]})
```
**Parameters:**
- `ids`: List of environment variable IDs to delete
**Returns:** `Response`
#### enableEnvs
Enable environment variables.
```python
result = QLAPI.enableEnvs({"ids": [123, 456]})
```
**Parameters:**
- `ids`: List of environment variable IDs to enable
**Returns:** `Response`
#### disableEnvs
Disable environment variables.
```python
result = QLAPI.disableEnvs({"ids": [123, 456]})
```
**Parameters:**
- `ids`: List of environment variable IDs to disable
**Returns:** `Response`
#### updateEnvNames
Update names of multiple environment variables.
```python
result = QLAPI.updateEnvNames({
"ids": [123, 456],
"name": "NEW_NAME"
})
```
**Parameters:**
- `ids`: List of environment variable IDs
- `name`: New name to set
**Returns:** `Response`
#### getEnvById
Get a specific environment variable by ID.
```python
env = QLAPI.getEnvById({"id": 123})
```
**Parameters:**
- `id`: Environment variable ID
**Returns:** `EnvResponse`
#### moveEnv
Change the position/order of an environment variable.
```python
result = QLAPI.moveEnv({
"id": 123,
"fromIndex": 0,
"toIndex": 5
})
```
**Parameters:**
- `id`: Environment variable ID
- `fromIndex`: Current position index
- `toIndex`: Target position index
**Returns:** `EnvResponse`
### Scheduled Tasks (Cron) Management
#### getCronDetail
Get details of a scheduled task.
```python
cron = QLAPI.getCronDetail({"log_path": "/path/to/log"})
```
**Parameters:**
- `log_path`: Path to the task log file
**Returns:** `CronResponse`
#### createCron
Create a new scheduled task.
```python
result = QLAPI.createCron({
"command": "node script.js",
"schedule": "0 0 * * *",
"name": "Daily Task",
"labels": ["tag1", "tag2"],
"sub_id": None,
"extra_schedules": [],
"task_before": "",
"task_after": ""
})
```
**Parameters:**
- `command`: Command to execute
- `schedule`: Cron expression
- `name`: Task name (optional)
- `labels`: List of labels (optional)
- Other optional fields
**Returns:** `CronResponse`
#### updateCron
Update an existing scheduled task.
```python
result = QLAPI.updateCron({
"id": 123,
"command": "node updated_script.js",
"schedule": "0 0 * * *",
"name": "Updated Task",
"labels": [],
"sub_id": None,
"extra_schedules": [],
"task_before": "",
"task_after": ""
})
```
**Returns:** `CronResponse`
#### deleteCrons
Delete scheduled tasks by IDs.
```python
result = QLAPI.deleteCrons({"ids": [123, 456]})
```
**Parameters:**
- `ids`: List of task IDs to delete
**Returns:** `Response`
### Notifications
#### notify
Send a notification using configured notification channels.
```python
result = QLAPI.notify("Notification Title", "Notification Content")
```
**Parameters:**
- First argument: Notification title
- Second argument: Notification content
**Returns:** Notification result
#### systemNotify
Send a system notification with custom parameters.
```python
result = QLAPI.systemNotify({
"title": "System Alert",
"content": "This is a system notification"
})
```
**Parameters:**
- `title`: Notification title
- `content`: Notification content
**Returns:** `Response`
## Complete Example
```python
"""
Example Qinglong Python script demonstrating QLAPI usage
"""
# Get environment variables
print("Fetching environment variables...")
envs = QLAPI.getEnvs({"searchValue": "TOKEN"})
print(f"Found {len(envs.get('data', []))} environment variables")
# Create a new environment variable
print("Creating new environment variable...")
result = QLAPI.createEnv({
"envs": [
{
"name": "MY_TEST_VAR",
"value": "test_value_123",
"remarks": "Created by script"
}
]
})
print(f"Create result: {result}")
# Send notification
print("Sending notification...")
QLAPI.notify("Script Completed", "The script has finished executing successfully")
# Send system notification
QLAPI.systemNotify({
"title": "Task Report",
"content": f"Processed {len(envs.get('data', []))} environment variables"
})
print("Done!")
```
## Error Handling
All QLAPI methods may raise exceptions if there are errors communicating with the backend. It's recommended to use try-except blocks:
```python
try:
envs = QLAPI.getEnvs({"searchValue": "USER"})
print(f"Success: {envs}")
except Exception as e:
print(f"Error: {e}")
QLAPI.notify("Script Error", str(e))
```
## Troubleshooting
### AttributeError: 'BaseApi' object has no attribute 'getEnvs'
This error occurs when using an older version of Qinglong (before v2.8.0). Solutions:
1. **Upgrade Qinglong**: Update to version 2.8.0 or higher
2. **Check Installation**: Ensure `shell/preload/client.py` exists
3. **Verify Environment**: Make sure scripts are running within Qinglong environment
### Module Import Errors
The QLAPI is only available when scripts run within the Qinglong environment. If you're testing locally, you won't have access to QLAPI.
## Technical Details
The Python QLAPI is a wrapper around the Node.js gRPC API client. When you call a method like `QLAPI.getEnvs()`:
1. Python Client creates a temporary Node.js script
2. Executes the corresponding Node.js API method
3. Returns the JSON result back to Python
This architecture ensures consistency between JavaScript and Python APIs.
## See Also
For more information and examples:
- [JavaScript API Client Source](./client.js) - The underlying Node.js gRPC client
- [Python Sample Script](../../sample/ql_sample.py) - Example Python script using QLAPI
- [JavaScript Sample Script](../../sample/ql_sample.js) - Example JavaScript script for comparison
These files are located in the Qinglong repository under `shell/preload/` and `sample/` directories.
+6 -1
View File
@@ -5,7 +5,7 @@ const { join } = require('path');
class GrpcClient { class GrpcClient {
static #config = { static #config = {
protoPath: join(process.env.QL_DIR, 'back/protos/api.proto'), 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: { protoOptions: {
keepCase: true, keepCase: true,
longs: String, longs: String,
@@ -33,6 +33,11 @@ class GrpcClient {
'createCron', 'createCron',
'updateCron', 'updateCron',
'deleteCrons', 'deleteCrons',
'getCrons',
'getCronById',
'enableCrons',
'disableCrons',
'runCrons',
]; ];
#client; #client;
-34
View File
@@ -1,20 +1,3 @@
"""
Qinglong Python API Client
This module provides a Python interface to the Qinglong API through a gRPC-based
Node.js client. It enables Python scripts to interact with Qinglong's environment
variables, scheduled tasks, and notification systems.
The Client class is used as a base for the QLAPI object that is automatically
available in Qinglong Python scripts. Users can call methods like:
QLAPI.getEnvs({"searchValue": "USER"})
QLAPI.createEnv({"envs": [{"name": "VAR", "value": "val"}]})
QLAPI.notify("Title", "Content")
For detailed documentation, see README_PYTHON_API.md
"""
import subprocess import subprocess
import json import json
import tempfile import tempfile
@@ -193,23 +176,6 @@ class CronResponse(TypedDict):
class Client: class Client:
"""
Qinglong API Client for Python.
This class provides methods to interact with Qinglong's API for managing
environment variables, scheduled tasks (crons), and notifications.
The client works by executing Node.js code that calls the actual gRPC API,
then returning the results to Python. This ensures consistency between
JavaScript and Python API interfaces.
Usage:
client = Client()
envs = client.getEnvs({"searchValue": "TOKEN"})
Note: This class is typically used through the QLAPI object which is
automatically available in Qinglong Python scripts.
"""
def __init__(self): def __init__(self):
self.temp_dir = tempfile.mkdtemp(prefix="node_client_") self.temp_dir = tempfile.mkdtemp(prefix="node_client_")
self.temp_script = os.path.join(self.temp_dir, "temp_script.js") self.temp_script = os.path.join(self.temp_dir, "temp_script.js")
-4
View File
@@ -131,14 +131,10 @@ try:
from __ql_notify__ import send from __ql_notify__ import send
# BaseApi inherits all methods from Client (getEnvs, createEnv, etc.)
# and adds the notify method for sending notifications
class BaseApi(Client): class BaseApi(Client):
def notify(self, *args, **kwargs): def notify(self, *args, **kwargs):
return send(*args, **kwargs) return send(*args, **kwargs)
# Create QLAPI instance and make it globally available
# This allows scripts to use: QLAPI.getEnvs(), QLAPI.notify(), etc.
QLAPI = BaseApi() QLAPI = BaseApi()
builtins.QLAPI = QLAPI builtins.QLAPI = QLAPI
except Exception as error: except Exception as error:
-125
View File
@@ -1,125 +0,0 @@
#!/usr/bin/env python3
"""
Validation script for QLAPI Client functionality.
Tests that all methods are properly accessible through the BaseApi class.
"""
import sys
from client import Client
def test_client_has_required_methods():
"""Test that Client class has all required API methods."""
client = Client()
required_methods = [
'getEnvs',
'createEnv',
'updateEnv',
'deleteEnvs',
'moveEnv',
'disableEnvs',
'enableEnvs',
'updateEnvNames',
'getEnvById',
'systemNotify',
'getCronDetail',
'createCron',
'updateCron',
'deleteCrons',
]
print("Testing Client class methods...")
for method in required_methods:
assert hasattr(client, method), f"Client missing method: {method}"
assert callable(getattr(client, method)), f"Client.{method} is not callable"
print(f"{method}")
print(f"\n✓ All {len(required_methods)} methods are present and callable")
return True
def test_baseapi_inheritance():
"""Test that BaseApi properly inherits from Client."""
# Simulate the BaseApi class from sitecustomize.py
class BaseApi(Client):
def notify(self, *args, **kwargs):
return "mock_notify_result"
api = BaseApi()
print("\nTesting BaseApi inheritance...")
# Test that BaseApi has all Client methods
assert hasattr(api, 'getEnvs'), "BaseApi missing getEnvs"
assert callable(api.getEnvs), "BaseApi.getEnvs is not callable"
print(" ✓ getEnvs is accessible")
# Test that BaseApi also has its own method
assert hasattr(api, 'notify'), "BaseApi missing notify"
assert callable(api.notify), "BaseApi.notify is not callable"
print(" ✓ notify is accessible")
# Verify getEnvs has type annotations (either params or return)
annotations = api.getEnvs.__annotations__
assert len(annotations) > 0, "getEnvs should have type annotations"
assert 'return' in annotations, "getEnvs should have return type annotation"
print(" ✓ getEnvs has correct signature with type annotations")
print("\n✓ BaseApi properly inherits from Client and adds notify method")
return True
def test_method_signatures():
"""Test that methods have correct type annotations."""
client = Client()
print("\nTesting method signatures...")
# Test getEnvs signature
getEnvs_annotations = client.getEnvs.__annotations__
# Check that annotations exist - could be 'params', or just 'return'
assert len(getEnvs_annotations) > 0, "getEnvs should have type annotations"
assert 'return' in getEnvs_annotations, "getEnvs should have return type annotation"
print(" ✓ getEnvs has type annotations")
# Test other critical methods
assert hasattr(client, 'createEnv'), "Missing createEnv"
assert hasattr(client, 'updateEnv'), "Missing updateEnv"
print(" ✓ Critical methods present")
print("\n✓ All method signatures are correct")
return True
def main():
"""Run all validation tests."""
print("=" * 60)
print("QLAPI Client Validation Tests")
print("=" * 60)
try:
test_client_has_required_methods()
test_baseapi_inheritance()
test_method_signatures()
print("\n" + "=" * 60)
print("ALL TESTS PASSED ✓")
print("=" * 60)
print("\nThe QLAPI Client is working correctly.")
print("Users can safely use: QLAPI.getEnvs({'searchValue': 'USER'})")
return 0
except AssertionError as e:
print(f"\n❌ TEST FAILED: {e}")
return 1
except Exception as e:
print(f"\n❌ UNEXPECTED ERROR: {e}")
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
sys.exit(main())
+32 -11
View File
@@ -59,15 +59,10 @@ list_own_user=$dir_list_tmp/own_user.list
list_own_add=$dir_list_tmp/own_add.list list_own_add=$dir_list_tmp/own_add.list
list_own_drop=$dir_list_tmp/own_drop.list list_own_drop=$dir_list_tmp/own_drop.list
## 软连接及其原始文件对应关系
link_name=( link_name=(
task task
ql ql
) )
original_name=(
task.sh
update.sh
)
init_env() { init_env() {
local pnpm_global_path=$(pnpm root -g 2>/dev/null) local pnpm_global_path=$(pnpm root -g 2>/dev/null)
@@ -84,15 +79,20 @@ init_env() {
export PYTHONUNBUFFERED=1 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() { import_config() {
[[ -f $file_config_user ]] && . $file_config_user [[ -f $file_config_user ]] && . $file_config_user
ql_base_url=${QlBaseUrl:-"/"} load_ql_envs
ql_port=${QlPort:-"5700"}
command_timeout_time=${CommandTimeoutTime:-""} command_timeout_time=${CommandTimeoutTime:-""}
file_extensions=${RepoFileExtensions:-"js py"} file_extensions=${RepoFileExtensions:-"js py"}
proxy_url=${ProxyUrl:-""} proxy_url=${ProxyUrl:-""}
current_branch=${QL_BRANCH:-""}
if [[ -n "${DefaultCronRule}" ]]; then if [[ -n "${DefaultCronRule}" ]]; then
default_cron="${DefaultCronRule}" default_cron="${DefaultCronRule}"
@@ -272,14 +272,35 @@ random_range() {
delete_pm2() { delete_pm2() {
cd $dir_root 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() { reload_pm2() {
cd $dir_root cd $dir_root
restore_env_vars 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() { diff_time() {
+19 -10
View File
@@ -46,18 +46,22 @@ handle_log_path() {
time=$(date "+$mtime_format") time=$(date "+$mtime_format")
log_time=$(format_log_time "$mtime_format" "$time") log_time=$(format_log_time "$mtime_format" "$time")
log_dir_tmp="${file_param##*/}" if [[ -z $log_name ]]; then
if [[ $file_param =~ "/" ]]; then log_dir_tmp="${file_param##*/}"
if [[ $file_param == /* ]]; then if [[ $file_param =~ "/" ]]; then
log_dir_tmp_path="${file_param:1}" if [[ $file_param == /* ]]; then
else log_dir_tmp_path="${file_param:1}"
log_dir_tmp_path="${file_param}" else
log_dir_tmp_path="${file_param}"
fi
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 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" log_path="$log_dir/$log_time.log"
if [[ ${real_log_path:=} ]]; then if [[ ${real_log_path:=} ]]; then
@@ -73,6 +77,11 @@ handle_log_path() {
if [[ "${real_time:=}" == "true" ]]; then if [[ "${real_time:=}" == "true" ]]; then
cmd="" cmd=""
fi fi
if [[ "${log_dir:=}" == "/dev/null" ]]; then
cmd=">> /dev/null"
log_path="/dev/null"
fi
} }
format_params() { format_params() {
+1
View File
@@ -3,6 +3,7 @@
dir_shell=$QL_DIR/shell dir_shell=$QL_DIR/shell
. $dir_shell/share.sh . $dir_shell/share.sh
. $dir_shell/api.sh . $dir_shell/api.sh
load_ql_envs
. $dir_shell/env.sh . $dir_shell/env.sh
send_mark=$dir_shell/send_mark 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'), 'en': require('./locales/en-US.json'),
'zh': require('./locales/zh-CN.json'), 'zh': require('./locales/zh-CN.json'),
}; };
let currentLocale = intl.determineLocale({ let currentLocale: string;
urlLocaleKey: 'lang', try {
cookieLocaleKey: 'lang', currentLocale = intl.determineLocale({
localStorageLocaleKey: 'lang', urlLocaleKey: 'lang',
}).slice(0, 2); 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)) { if (!currentLocale || !Object.keys(locales).includes(currentLocale)) {
currentLocale = 'zh'; currentLocale = 'zh';
+16 -11
View File
@@ -1,6 +1,6 @@
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import React, { useRef, useState, useEffect } from 'react'; 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 { CopyOutlined, CheckOutlined } from '@ant-design/icons';
import { CopyToClipboard } from 'react-copy-to-clipboard'; import { CopyToClipboard } from 'react-copy-to-clipboard';
@@ -10,16 +10,21 @@ const Copy = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copyIdRef = useRef<number>(); 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?.preventDefault();
e?.stopPropagation(); e?.stopPropagation();
setCopied(true);
cleanCopyId();
copyIdRef.current = window.setTimeout(() => {
setCopied(false);
}, 3000);
}; };
const cleanCopyId = () => { const cleanCopyId = () => {
@@ -27,8 +32,8 @@ const Copy = ({ text }: { text: string }) => {
}; };
return ( return (
<Link onClick={copyText} style={{ marginLeft: 1 }}> <Link onClick={handleClick} style={{ marginLeft: 4 }}>
<CopyToClipboard text={text}> <CopyToClipboard text={text} onCopy={handleCopy}>
<Tooltip <Tooltip
key="copy" key="copy"
title={copied ? intl.get('复制成功') : intl.get('复制')} title={copied ? intl.get('复制成功') : intl.get('复制')}
+35 -3
View File
@@ -104,7 +104,7 @@
"序号": "Number", "序号": "Number",
"备注": "Remarks", "备注": "Remarks",
"更新时间": "Update Time", "更新时间": "Update Time",
"创建时间": "Creation Time", "创建时间": "Created Time",
"确认删除依赖": "Confirm to delete the dependency", "确认删除依赖": "Confirm to delete the dependency",
"确认重新安装": "Confirm to reinstall", "确认重新安装": "Confirm to reinstall",
"确认取消安装": "Confirm to cancel install", "确认取消安装": "Confirm to cancel install",
@@ -252,7 +252,7 @@
"登录日志": "Login Logs", "登录日志": "Login Logs",
"其他设置": "Other Settings", "其他设置": "Other Settings",
"关于": "About", "关于": "About",
"成功": "Success", "成功": "Successfully",
"失败": "Failure", "失败": "Failure",
"登录时间": "Login Time", "登录时间": "Login Time",
"登录地址": "Login Address", "登录地址": "Login Address",
@@ -389,6 +389,7 @@
"消息接收人": "message recipient", "消息接收人": "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.", "调用版本;专业版填写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", "飞书群组机器人: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", "邮箱服务名称,比如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", "邮箱地址": "Email Address",
"SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "The SMTP login password may also be a special passphrase, depending on the specific email service provider's instructions", "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "The SMTP login password may also be a special passphrase, depending on the specific email service provider's instructions",
@@ -521,5 +522,36 @@
"远程仓库缓存": "Remote repository cache", "远程仓库缓存": "Remote repository cache",
"SSH 文件缓存": "SSH file cache", "SSH 文件缓存": "SSH file cache",
"清除依赖缓存": "Clean dependency cache", "清除依赖缓存": "Clean dependency cache",
"清除成功": "Clean successful" "清除成功": "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"
} }
+33 -1
View File
@@ -389,6 +389,7 @@
"消息接收人": "消息接收人", "消息接收人": "消息接收人",
"调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版", "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版",
"飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973", "飞书群组机器人: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", "邮箱服务名称,比如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 登录密码,也可能为特殊口令,视具体邮件服务商说明而定", "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定",
@@ -521,5 +522,36 @@
"远程仓库缓存": "远程仓库缓存", "远程仓库缓存": "远程仓库缓存",
"SSH 文件缓存": "SSH 文件缓存", "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 Crontab = () => {
const { headerStyle, isPhone, theme } = useOutletContext<SharedContext>(); const { headerStyle, isPhone, theme } = useOutletContext<SharedContext>();
const [allSubscriptions, setAllSubscriptions] = useState<any[]>([]);
const columns: ColumnProps<ICrontab>[] = [ const columns: ColumnProps<ICrontab>[] = [
{ {
title: intl.get('名称'), title: intl.get('名称'),
@@ -247,8 +248,8 @@ const Crontab = () => {
> >
{record.last_execution_time {record.last_execution_time
? dayjs(record.last_execution_time * 1000).format( ? dayjs(record.last_execution_time * 1000).format(
'YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm:ss',
) )
: '-'} : '-'}
</span> </span>
); );
@@ -272,6 +273,12 @@ const Crontab = () => {
title: intl.get('关联订阅'), title: intl.get('关联订阅'),
width: 185, width: 185,
render: (text, record: any) => record?.subscription?.name || '-', 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('操作'), title: intl.get('操作'),
@@ -361,11 +368,10 @@ const Crontab = () => {
const getCrons = () => { const getCrons = () => {
setLoading(true); setLoading(true);
const { page, size, sorter, filters } = pageConf; const { page, size, sorter, filters } = pageConf;
let url = `${ let url = `${config.apiPrefix
config.apiPrefix }crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify(
}crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify( filters,
filters, )}`;
)}`;
if (sorter && sorter.column && sorter.order) { if (sorter && sorter.column && sorter.order) {
url += `&sorter=${JSON.stringify({ url += `&sorter=${JSON.stringify({
field: sorter.column.key, field: sorter.column.key,
@@ -523,9 +529,8 @@ const Crontab = () => {
const enabledOrDisabledCron = (record: any, index: number) => { const enabledOrDisabledCron = (record: any, index: number) => {
Modal.confirm({ Modal.confirm({
title: `确认${ title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') }`,
}`,
content: ( content: (
<> <>
{intl.get('确认')} {intl.get('确认')}
@@ -540,8 +545,7 @@ const Crontab = () => {
onOk() { onOk() {
request request
.put( .put(
`${config.apiPrefix}crons/${ `${config.apiPrefix}crons/${record.isDisabled === 1 ? 'enable' : 'disable'
record.isDisabled === 1 ? 'enable' : 'disable'
}`, }`,
[record.id], [record.id],
) )
@@ -565,9 +569,8 @@ const Crontab = () => {
const pinOrUnPinCron = (record: any, index: number) => { const pinOrUnPinCron = (record: any, index: number) => {
Modal.confirm({ Modal.confirm({
title: `确认${ title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') }`,
}`,
content: ( content: (
<> <>
{intl.get('确认')} {intl.get('确认')}
@@ -582,8 +585,7 @@ const Crontab = () => {
onOk() { onOk() {
request request
.put( .put(
`${config.apiPrefix}crons/${ `${config.apiPrefix}crons/${record.isPinned === 1 ? 'unpin' : 'pin'
record.isPinned === 1 ? 'unpin' : 'pin'
}`, }`,
[record.id], [record.id],
) )
@@ -799,8 +801,20 @@ const Crontab = () => {
} }
}, [viewConf, enabledCronViews]); }, [viewConf, enabledCronViews]);
const getAllSubscriptions = () => {
request
.get(`${config.apiPrefix}subscriptions`)
.then(({ code, data }) => {
if (code === 200) {
setAllSubscriptions(data || []);
}
})
.catch(() => {});
};
useEffect(() => { useEffect(() => {
getCronViews(); getCronViews();
getAllSubscriptions();
}, []); }, []);
const viewAction = (key: string) => { const viewAction = (key: string) => {
@@ -1014,6 +1028,7 @@ const Crontab = () => {
)} )}
<Table <Table
columns={columns} columns={columns}
sortDirections={['descend', 'ascend']}
pagination={{ pagination={{
current: pageConf.page, current: pageConf.page,
pageSize: pageConf.size, pageSize: pageConf.size,
+1 -1
View File
@@ -55,7 +55,7 @@ const CronLogModal = ({
const log = data as string; const log = data as string;
setValue(log || intl.get("暂无日志")); setValue(log || intl.get("暂无日志"));
const hasNext = Boolean( 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(() => { setTimeout(() => {
+53 -5
View File
@@ -3,7 +3,7 @@ import config from '@/utils/config';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Form, Input, Modal, Select, Space, message } from 'antd'; 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 { useEffect, useState } from 'react';
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import { getScheduleType, scheduleTypeMap } from './const'; import { getScheduleType, scheduleTypeMap } from './const';
@@ -91,10 +91,14 @@ const CronModal = ({
{ required: true }, { required: true },
{ {
validator: (_, value) => { validator: (_, value) => {
if (!value || cronParse.parseExpression(value).hasNext()) { try {
return Promise.resolve(); if (!value || CronExpressionParser.parse(value).hasNext()) {
return Promise.resolve();
}
return Promise.reject(intl.get('Cron表达式格式有误'));
} catch (e) {
return Promise.reject(intl.get('Cron表达式格式有误'));
} }
return Promise.reject(intl.get('Cron表达式格式有误'));
}, },
}, },
]} ]}
@@ -180,6 +184,51 @@ const CronModal = ({
<Form.Item name="labels" label={intl.get('标签')}> <Form.Item name="labels" label={intl.get('标签')}>
<EditableTagGroup /> <EditableTagGroup />
</Form.Item> </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 <Form.Item
name="task_before" name="task_before"
label={intl.get('执行前')} label={intl.get('执行前')}
@@ -312,4 +361,3 @@ const CronLabelModal = ({
}; };
export { CronLabelModal, CronModal as default }; export { CronLabelModal, CronModal as default };
+1
View File
@@ -37,6 +37,7 @@ export interface ICrontab {
nextRunTime: Date; nextRunTime: Date;
sub_id: number; sub_id: number;
extra_schedules?: Array<{ schedule: string }>; extra_schedules?: Array<{ schedule: string }>;
allow_multiple_instances?: 1 | 0;
} }
export enum ScheduleType { export enum ScheduleType {
+111 -36
View File
@@ -1,47 +1,42 @@
import intl from 'react-intl-universal'; import useTableScrollHeight from '@/hooks/useTableScrollHeight';
import React, { import { SharedContext } from '@/layouts';
useCallback, import config from '@/utils/config';
useRef, import { request } from '@/utils/http';
useState, import { exportJson } from '@/utils/index';
useEffect,
useMemo,
} from 'react';
import { import {
Button,
message,
Modal,
Table,
Tag,
Space,
Typography,
Tooltip,
Input,
UploadProps,
Upload,
} from 'antd';
import {
EditOutlined,
DeleteOutlined,
SyncOutlined,
CheckCircleOutlined, CheckCircleOutlined,
DeleteOutlined,
EditOutlined,
PushpinFilled,
PushpinOutlined,
StopOutlined, StopOutlined,
UploadOutlined, UploadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import config from '@/utils/config';
import { PageContainer } from '@ant-design/pro-layout'; import { PageContainer } from '@ant-design/pro-layout';
import { request } from '@/utils/http'; import { useOutletContext } from '@umijs/max';
import EnvModal from './modal'; import {
import EditNameModal from './editNameModal'; 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 { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import './index.less'; import intl from 'react-intl-universal';
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 { useVT } from 'virtualizedtableforantd4'; 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 { Paragraph } = Typography;
const { Search } = Input; const { Search } = Input;
@@ -59,11 +54,15 @@ enum StatusColor {
enum OperationName { enum OperationName {
'启用', '启用',
'禁用', '禁用',
'置顶',
'取消置顶',
} }
enum OperationPath { enum OperationPath {
'enable', 'enable',
'disable', 'disable',
'pin',
'unpin',
} }
const type = 'DragableBodyRow'; const type = 'DragableBodyRow';
@@ -181,7 +180,7 @@ const Env = () => {
{ {
title: intl.get('操作'), title: intl.get('操作'),
key: 'action', key: 'action',
width: 120, width: 160,
render: (text: string, record: any, index: number) => { render: (text: string, record: any, index: number) => {
const isPc = !isPhone; const isPc = !isPhone;
return ( return (
@@ -208,6 +207,23 @@ const Env = () => {
)} )}
</a> </a>
</Tooltip> </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('删除') : ''}> <Tooltip title={isPc ? intl.get('删除') : ''}>
<a onClick={() => deleteEnv(record, index)}> <a onClick={() => deleteEnv(record, index)}>
<DeleteOutlined /> <DeleteOutlined />
@@ -305,6 +321,51 @@ const Env = () => {
setIsModalVisible(true); 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) => { const deleteEnv = (record: any, index: number) => {
Modal.confirm({ Modal.confirm({
title: intl.get('确认删除'), title: intl.get('确认删除'),
@@ -589,6 +650,20 @@ const Env = () => {
> >
{intl.get('批量禁用')} {intl.get('批量禁用')}
</Button> </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 }}> <span style={{ marginLeft: 8 }}>
{intl.get('已选择')} {intl.get('已选择')}
<a>{selectedRowIds?.length}</a> <a>{selectedRowIds?.length}</a>
+1 -1
View File
@@ -16,7 +16,7 @@ const SaveModal = ({
const handleOk = async (values: any) => { const handleOk = async (values: any) => {
setLoading(true); setLoading(true);
const payload = { ...file, ...values, originFilename: file.title }; const payload = { ...values, originFilename: file.title, content: file.content };
request request
.post(`${config.apiPrefix}scripts`, payload) .post(`${config.apiPrefix}scripts`, payload)
.then(({ code, data }) => { .then(({ code, data }) => {
+1
View File
@@ -49,6 +49,7 @@ const Setting = () => {
reloadTheme, reloadTheme,
systemInfo, systemInfo,
} = useOutletContext<SharedContext>(); } = useOutletContext<SharedContext>();
console.log('user',user)
const columns = [ const columns = [
{ {
title: intl.get('名称'), title: intl.get('名称'),
+31 -1
View File
@@ -30,6 +30,7 @@ const dataMap = {
'log-remove-frequency': 'logRemoveFrequency', 'log-remove-frequency': 'logRemoveFrequency',
'cron-concurrency': 'cronConcurrency', 'cron-concurrency': 'cronConcurrency',
timezone: 'timezone', timezone: 'timezone',
'global-ssh-key': 'globalSshKey',
}; };
const exportModules = [ const exportModules = [
@@ -54,6 +55,7 @@ const Other = ({
logRemoveFrequency?: number | null; logRemoveFrequency?: number | null;
cronConcurrency?: number | null; cronConcurrency?: number | null;
timezone?: string | null; timezone?: string | null;
globalSshKey?: string | null;
}>(); }>();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [exportLoading, setExportLoading] = useState(false); const [exportLoading, setExportLoading] = useState(false);
@@ -240,6 +242,7 @@ const Other = ({
addonBefore={intl.get('每')} addonBefore={intl.get('每')}
addonAfter={intl.get('天')} addonAfter={intl.get('天')}
style={{ width: 180 }} style={{ width: 180 }}
placeholder={intl.get('未启用')}
min={0} min={0}
value={systemConfig?.logRemoveFrequency} value={systemConfig?.logRemoveFrequency}
onChange={(value) => { onChange={(value) => {
@@ -261,8 +264,9 @@ const Other = ({
<Input.Group compact> <Input.Group compact>
<InputNumber <InputNumber
style={{ width: 180 }} style={{ width: 180 }}
min={1} min={4}
value={systemConfig?.cronConcurrency} value={systemConfig?.cronConcurrency}
placeholder={intl.get('默认为 CPU 个数')}
onChange={(value) => { onChange={(value) => {
setSystemConfig({ ...systemConfig, cronConcurrency: value }); setSystemConfig({ ...systemConfig, cronConcurrency: value });
}} }}
@@ -306,6 +310,32 @@ const Other = ({
</Button> </Button>
</Input.Group> </Input.Group>
</Form.Item> </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('global-ssh-key');
}}
style={{ width: 264, marginTop: 8 }}
>
{intl.get('确认')}
</Button>
</Form.Item>
<Form.Item label={intl.get('语言')} name="lang"> <Form.Item label={intl.get('语言')} name="lang">
<Select <Select
defaultValue={localStorage.getItem('lang') || ''} defaultValue={localStorage.getItem('lang') || ''}
+14 -10
View File
@@ -12,7 +12,7 @@ import {
} from 'antd'; } from 'antd';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
import config from '@/utils/config'; import config from '@/utils/config';
import cron_parser from 'cron-parser'; import CronExpressionParser from 'cron-parser';
import isNil from 'lodash/isNil'; import isNil from 'lodash/isNil';
const { Option } = Select; const { Option } = Select;
@@ -224,8 +224,8 @@ const SubscriptionModal = ({
type === 'raw' type === 'raw'
? 'file' ? 'file'
: url.startsWith('http') : url.startsWith('http')
? 'public-repo' ? 'public-repo'
: 'private-repo'; : 'private-repo';
form.setFieldsValue({ form.setFieldsValue({
type: _type, type: _type,
@@ -378,13 +378,17 @@ const SubscriptionModal = ({
{ required: true }, { required: true },
{ {
validator: (rule, value) => { validator: (rule, value) => {
if ( try {
scheduleType === 'interval' || if (
!value || scheduleType === 'interval' ||
cron_parser.parseExpression(value).hasNext() !value ||
) { CronExpressionParser.parse(value).hasNext()
return Promise.resolve(); ) {
} else { return Promise.resolve();
} else {
return Promise.reject(intl.get('Subscription表达式格式有误'));
}
} catch (e) {
return Promise.reject(intl.get('Subscription表达式格式有误')); return Promise.reject(intl.get('Subscription表达式格式有误'));
} }
}, },
+6
View File
@@ -395,6 +395,12 @@ export default {
), ),
required: true, required: true,
}, },
{
label: 'larkSecret',
tip: intl.get(
'飞书群组机器人加签密钥,安全设置中开启签名校验后获得',
),
},
], ],
email: [ email: [
{ {
+6 -6
View File
@@ -84,12 +84,12 @@ let _request = axios.create({
}); });
const apiWhiteList = [ const apiWhiteList = [
'/api/user/login', `${config.baseUrl}api/user/login`,
'/open/auth/token', `${config.baseUrl}open/auth/token`,
'/api/user/two-factor/login', `${config.baseUrl}api/user/two-factor/login`,
'/api/system', `${config.baseUrl}api/system`,
'/api/user/init', `${config.baseUrl}api/user/init`,
'/api/user/notification/init', `${config.baseUrl}api/user/notification/init`,
]; ];
_request.interceptors.request.use((_config) => { _request.interceptors.request.use((_config) => {
+6 -6
View File
@@ -1,6 +1,6 @@
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import { LANG_MAP, LOG_END_SYMBOL } from './const'; 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'; import { ICrontab } from '@/pages/crontab/type';
export default function browserType() { export default function browserType() {
@@ -155,9 +155,9 @@ export default function browserType() {
shell === 'none' shell === 'none'
? {} ? {}
: { : {
shell, // wechat qq uc 360 2345 sougou liebao maxthon shell, // wechat qq uc 360 2345 sougou liebao maxthon
shellVs, shellVs,
}, },
); );
console.log( console.log(
@@ -333,11 +333,11 @@ export function getCommandScript(
export function parseCrontab(schedule: string): Date | null { export function parseCrontab(schedule: string): Date | null {
try { try {
const time = cron_parser.parseExpression(schedule); const time = CronExpressionParser.parse(schedule);
if (time) { if (time) {
return time.next().toDate(); return time.next().toDate();
} }
} catch (error) {} } catch (error) { }
return null; return null;
} }
+46 -9
View File
@@ -1,10 +1,47 @@
version: 2.19.2 version: 2.20.0
changeLogLink: https://t.me/jiao_long/431 changeLogLink: https://t.me/jiao_long/432
publishTime: 2025-06-27 23:59 publishTime: 2025-12-10 01:05
changeLog: | changeLog: |
1. 备份数据支持选择模块,支持清除依赖缓存 1. 定时任务(cron / task)相关的大量修复 & 增强
2. QLAPI 和 openapi 的 systemNotify 支持自定义通知类型和参数
3. ntfy 增加可选的认证与用户动作,感谢 https://github.com/liheji 修复 cron 解析错误(修复 parse cron / 升级 cron-parser
4. 修复取消安装依赖 修复集群模式下定时任务可能不执行(race condition
5. 修复环境变量过大解析报错 定时任务支持订阅筛选
6. 修改服务启动方式 定时任务支持排序调整
定时任务支持自定义日志文件或无日志
修复任务实例默认值
任务支持单实例 / 多实例模式
修复 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. 系统设置
新增多终端/多平台的并发登录会话支持