Compare commits

..

7 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] b33d994316 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>
2025-11-11 16:48:25 +00:00
copilot-swe-agent[bot] 6ede8139ce 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>
2025-11-11 16:33:12 +00:00
copilot-swe-agent[bot] 95939bbea5 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>
2025-11-07 16:34:42 +00:00
copilot-swe-agent[bot] 2baf352350 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>
2025-11-07 16:32:11 +00:00
copilot-swe-agent[bot] 827453986b 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>
2025-11-07 16:29:59 +00:00
copilot-swe-agent[bot] 15f4bcf363 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>
2025-11-07 16:28:33 +00:00
copilot-swe-agent[bot] d8ea840266 Initial plan 2025-11-07 16:14:23 +00:00
56 changed files with 276 additions and 1823 deletions
-2
View File
@@ -41,8 +41,6 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image.
**⚠️ Important**: If you need to run Docker as a **non-root user**, please use the `debian` image. Alpine's `crond` requires root privileges.
```bash
docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian
-2
View File
@@ -43,8 +43,6 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
`latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像
**⚠️ 重要提示**: 如果您需要以**非 root 用户**运行 Docker,请使用 `debian` 镜像。Alpine 的 `crond` 需要 root 权限。
```bash
docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian
+7 -41
View File
@@ -1,12 +1,12 @@
import { Joi, celebrate } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express';
import fs from 'fs';
import multer from 'multer';
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import config from '../config';
import { safeJSONParse } from '../config/util';
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 { safeJSONParse } from '../config/util';
const route = Router();
const storage = multer.diskStorage({
@@ -196,40 +196,6 @@ export default (app: Router) => {
},
);
route.put(
'/pin',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.pin(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/unpin',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.unPin(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.post(
'/upload',
upload.single('env'),
+3 -3
View File
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston';
import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate';
import { CronExpressionParser } from 'cron-parser';
import cron_parser from 'cron-parser';
const route = Router();
export default (app: Router) => {
@@ -60,7 +60,7 @@ export default (app: Router) => {
try {
if (
!req.body.schedule ||
CronExpressionParser.parse(req.body.schedule).hasNext()
cron_parser.parseExpression(req.body.schedule).hasNext()
) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.create(req.body);
@@ -193,7 +193,7 @@ export default (app: Router) => {
if (
!req.body.schedule ||
typeof req.body.schedule === 'object' ||
CronExpressionParser.parse(req.body.schedule).hasNext()
cron_parser.parseExpression(req.body.schedule).hasNext()
) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.update(req.body);
+2 -23
View File
@@ -14,7 +14,6 @@ import {
} from '../config/util';
import dayjs from 'dayjs';
import multer from 'multer';
import { logStreamManager } from '../shared/logStreamManager';
const route = Router();
const storage = multer.diskStorage({
@@ -277,19 +276,17 @@ export default (app: Router) => {
res.setHeader('QL-Task-Log', `${logPath}`);
},
onEnd: async (cp, endTime, diff) => {
// Close the stream after task completion
await logStreamManager.closeStream(await handleLogPath(logPath));
res.end();
},
onError: async (message: string) => {
res.write(message);
const absolutePath = await handleLogPath(logPath);
await logStreamManager.write(absolutePath, message);
await fs.appendFile(absolutePath, message);
},
onLog: async (message: string) => {
res.write(message);
const absolutePath = await handleLogPath(logPath);
await logStreamManager.write(absolutePath, message);
await fs.appendFile(absolutePath, message);
},
},
);
@@ -426,24 +423,6 @@ export default (app: Router) => {
},
);
route.put(
'/config/global-ssh-key',
celebrate({
body: Joi.object({
globalSshKey: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateGlobalSshKey(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/dependence-clean',
celebrate({
+2 -3
View File
@@ -8,7 +8,7 @@ import path from 'path';
import { v4 as uuidV4 } from 'uuid';
import rateLimit from 'express-rate-limit';
import config from '../config';
import { isDemoEnv, getToken } from '../config/util';
import { isDemoEnv } from '../config/util';
const route = Router();
const storage = multer.diskStorage({
@@ -56,8 +56,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const token = getToken(req);
await userService.logout(req.platform, token);
await userService.logout(req.platform);
res.send({ code: 200 });
} catch (e) {
return next(e);
+19 -19
View File
@@ -60,11 +60,11 @@ class Application {
// 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');
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);
Logger.error('Failed to wait for gRPC worker:', error);
process.exit(1);
});
@@ -73,7 +73,7 @@ class Application {
if (metadata) {
if (!this.isShuttingDown) {
Logger.error(
`✌️ ${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
`${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
}). Restarting...`,
);
// If gRPC worker died, restart it and wait for it to be ready
@@ -81,26 +81,26 @@ class Application {
const newGrpcWorker = this.forkWorker('grpc');
this.waitForWorkerReady(newGrpcWorker, 30000)
.then(() => {
Logger.info('✌️ gRPC worker restarted and ready');
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');
Logger.info('Sent reregister-crons message to HTTP worker');
} catch (error) {
Logger.error('✌️ Failed to send reregister-crons message:', error);
Logger.error('Failed to send reregister-crons message:', error);
}
}
})
.catch((error) => {
Logger.error('✌️ Failed to restart gRPC worker:', 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})`);
Logger.info(`Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`);
}
}
@@ -169,7 +169,7 @@ class Application {
if (worker) {
const exitPromise = new Promise<void>((resolve) => {
worker.once('exit', () => {
Logger.info(`✌️ Worker ${worker.process.pid} exited`);
Logger.info(`Worker ${worker.process.pid} exited`);
resolve();
});
@@ -177,7 +177,7 @@ class Application {
worker.send('shutdown');
} catch (error) {
Logger.warn(
`✌️ Failed to send shutdown to worker ${worker.process.pid}:`,
`Failed to send shutdown to worker ${worker.process.pid}:`,
error,
);
}
@@ -192,14 +192,14 @@ class Application {
Promise.all(workerPromises),
new Promise<void>((resolve) => {
setTimeout(() => {
Logger.warn('✌️ Worker shutdown timeout reached');
Logger.warn('Worker shutdown timeout reached');
resolve();
}, 10000);
}),
]);
process.exit(0);
} catch (error) {
Logger.error('✌️ Error during worker shutdown:', error);
Logger.error('Error during worker shutdown:', error);
process.exit(1);
}
};
@@ -211,7 +211,7 @@ class Application {
private async startWorkerProcess() {
const serviceType = process.env.SERVICE_TYPE;
if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
Logger.error('✌️ Invalid SERVICE_TYPE:', serviceType);
Logger.error('Invalid SERVICE_TYPE:', serviceType);
process.exit(1);
}
@@ -226,7 +226,7 @@ class Application {
process.send?.('ready');
} catch (error) {
Logger.error(`✌️ ${serviceType} worker failed:`, error);
Logger.error(`${serviceType} worker failed:`, error);
process.exit(1);
}
}
@@ -265,13 +265,13 @@ class Application {
} 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...');
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');
Logger.info('Cron jobs re-registered successfully');
} catch (error) {
Logger.error('✌️ Failed to re-register cron jobs:', error);
Logger.error('Failed to re-register cron jobs:', error);
}
}
});
@@ -293,7 +293,7 @@ class Application {
}
process.exit(0);
} catch (error) {
Logger.error(`✌️ [${serviceType}] Error during shutdown:`, error);
Logger.error(`[${serviceType}] Error during shutdown:`, error);
process.exit(1);
}
}
@@ -301,6 +301,6 @@ class Application {
const app = new Application();
app.start().catch((error) => {
Logger.error('🙅‍♀️ Application failed to start:', error);
Logger.error('Application failed to start:', error);
process.exit(1);
});
-1
View File
@@ -176,5 +176,4 @@ export default {
sshdPath,
systemLogPath,
dependenceCachePath,
maxTokensPerPlatform: 10, // Maximum number of concurrent sessions per platform
};
-21
View File
@@ -417,27 +417,6 @@ export async function getPid(cmd: string) {
return pid ? Number(pid) : undefined;
}
export async function getAllPids(cmd: string): Promise<number[]> {
const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}'`;
const pidsStr = await promiseExec(taskCommand);
if (!pidsStr) return [];
return pidsStr
.split('\n')
.map((p) => Number(p.trim()))
.filter((p) => !isNaN(p) && p > 0);
}
export async function killAllTasks(cmd: string): Promise<void> {
const pids = await getAllPids(cmd);
for (const pid of pids) {
try {
await killTask(pid);
} catch (error) {
// Ignore errors if process already terminated
}
}
}
interface IVersion {
version: string;
changeLogLink: string;
+1 -7
View File
@@ -21,8 +21,6 @@ export class Crontab {
extra_schedules?: Array<{ schedule: string }>;
task_before?: string;
task_after?: string;
log_name?: string;
allow_multiple_instances?: 1 | 0;
constructor(options: Crontab) {
this.name = options.name;
@@ -47,8 +45,6 @@ export class Crontab {
this.extra_schedules = options.extra_schedules;
this.task_before = options.task_before;
this.task_after = options.task_after;
this.log_name = options.log_name;
this.allow_multiple_instances = options.allow_multiple_instances || 0;
}
}
@@ -59,7 +55,7 @@ export enum CrontabStatus {
'disabled',
}
export interface CronInstance extends Model<Crontab, Crontab>, Crontab {}
export interface CronInstance extends Model<Crontab, Crontab>, Crontab { }
export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
name: {
unique: 'compositeIndex',
@@ -88,6 +84,4 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
extra_schedules: DataTypes.JSON,
task_before: DataTypes.STRING,
task_after: DataTypes.STRING,
log_name: DataTypes.STRING,
allow_multiple_instances: DataTypes.NUMBER,
});
+1 -4
View File
@@ -1,5 +1,5 @@
import { DataTypes, Model } from 'sequelize';
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
export class Env {
value?: string;
@@ -9,7 +9,6 @@ export class Env {
position?: number;
name?: string;
remarks?: string;
isPinned?: 1 | 0;
constructor(options: Env) {
this.value = options.value;
@@ -22,7 +21,6 @@ export class Env {
this.position = options.position;
this.name = options.name;
this.remarks = options.remarks || '';
this.isPinned = options.isPinned || 0;
}
}
@@ -44,5 +42,4 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
position: DataTypes.NUMBER,
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING,
isPinned: DataTypes.NUMBER,
});
+1 -15
View File
@@ -38,7 +38,6 @@ export interface SystemConfigInfo {
pythonMirror?: string;
linuxMirror?: string;
timezone?: string;
globalSshKey?: string;
}
export interface LoginLogInfo {
@@ -49,19 +48,6 @@ export interface LoginLogInfo {
status?: LoginStatus;
}
export interface TokenInfo {
value: string;
timestamp: number;
ip: string;
address: string;
platform: string;
/**
* Token expiration time in seconds since Unix epoch.
* If undefined, the token uses JWT's built-in expiration.
*/
expiration?: number;
}
export interface AuthInfo {
username: string;
password: string;
@@ -72,7 +58,7 @@ export interface AuthInfo {
platform: string;
isTwoFactorChecking: boolean;
token: string;
tokens: Record<string, string | TokenInfo[]>;
tokens: Record<string, string>;
twoFactorActivated: boolean;
twoFactorSecret: string;
avatar: string;
+37 -32
View File
@@ -19,38 +19,43 @@ export default async () => {
await CrontabViewModel.sync();
// 初始化新增字段
const migrations = [
{
table: 'CrontabViews',
column: 'filterRelation',
type: 'VARCHAR(255)',
},
{ table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' },
{ table: 'CrontabViews', column: 'type', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' },
{ table: 'Crontabs', column: 'sub_id', type: 'NUMBER' },
{ table: 'Crontabs', column: 'extra_schedules', type: 'JSON' },
{ table: 'Crontabs', column: 'task_before', type: 'TEXT' },
{ table: 'Crontabs', column: 'task_after', type: 'TEXT' },
{ table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' },
{
table: 'Crontabs',
column: 'allow_multiple_instances',
type: 'NUMBER',
},
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
];
for (const migration of migrations) {
try {
await sequelize.query(
`alter table ${migration.table} add column ${migration.column} ${migration.type}`,
);
} catch (error) {
// Column already exists or other error, continue
}
}
try {
await sequelize.query(
'alter table CrontabViews add column filterRelation VARCHAR(255)',
);
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column proxy VARCHAR(255)',
);
} catch (error) {}
try {
await sequelize.query('alter table CrontabViews add column type NUMBER');
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column autoAddCron NUMBER',
);
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column autoDelCron NUMBER',
);
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column sub_id NUMBER');
} catch (error) {}
try {
await sequelize.query(
'alter table Crontabs add column extra_schedules JSON',
);
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column task_before TEXT');
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column task_after TEXT');
} catch (error) {}
Logger.info('✌️ DB loaded');
} catch (error) {
+5 -17
View File
@@ -1,9 +1,8 @@
import path from 'path';
import fs from 'fs/promises';
import os from 'os';
import chokidar from 'chokidar';
import config from '../config/index';
import Logger from './logger';
import { fileExist, promiseExec, rmPath } from '../config/util';
async function linkToNodeModule(src: string, dst?: string) {
const target = path.join(config.rootPath, 'node_modules', dst || src);
@@ -18,18 +17,8 @@ async function linkToNodeModule(src: string, dst?: string) {
}
async function linkCommand() {
const homeDir = os.homedir();
let userBinDir = path.join(homeDir, 'bin');
try {
await fs.mkdir(userBinDir, { recursive: true });
await linkCommandToDir(userBinDir);
} catch (error) {
Logger.error('Linking command failed:', error);
}
}
async function linkCommandToDir(commandDir: string) {
const commandPath = await promiseExec('which node');
const commandDir = path.dirname(commandPath);
const linkShell = [
{
src: 'update.sh',
@@ -53,7 +42,6 @@ async function linkCommandToDir(commandDir: string) {
await fs.unlink(tmpTarget);
}
} catch (error) { }
await fs.symlink(source, tmpTarget);
await fs.rename(tmpTarget, target);
}
@@ -70,6 +58,6 @@ export default async (src: string = 'deps') => {
});
watcher
.on('add', () => linkToNodeModule(src))
.on('change', () => linkToNodeModule(src));
.on('add', (path) => linkToNodeModule(src))
.on('change', (path) => linkToNodeModule(src));
};
+5 -3
View File
@@ -9,7 +9,6 @@ import rewrite from 'express-urlrewrite';
import { errors } from 'celebrate';
import { serveEnv } from '../config/serverEnv';
import { IKeyvStore, shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
import path from 'path';
export default ({ app }: { app: Application }) => {
@@ -78,8 +77,11 @@ export default ({ app }: { app: Application }) => {
}
const authInfo = await shareStore.getAuthInfo();
if (isValidToken(authInfo, headerToken, req.platform)) {
return next();
if (authInfo && headerToken) {
const { token = '', tokens = {} } = authInfo;
if (headerToken === token || tokens[req.platform] === headerToken) {
return next();
}
}
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
-7
View File
@@ -2,7 +2,6 @@ import { Container } from 'typedi';
import SystemService from '../services/system';
import ScheduleService, { ScheduleTaskType } from '../services/schedule';
import SubscriptionService from '../services/subscription';
import SshKeyService from '../services/sshKey';
import config from '../config';
import { fileExist } from '../config/util';
import { join } from 'path';
@@ -11,7 +10,6 @@ export default async () => {
const systemService = Container.get(SystemService);
const scheduleService = Container.get(ScheduleService);
const subscriptionService = Container.get(SubscriptionService);
const sshKeyService = Container.get(SshKeyService);
// 生成内置token
let tokenCommand = `ts-node-transpile-only ${join(
@@ -59,11 +57,6 @@ export default async () => {
}
systemService.updateTimezone(data.info);
// Apply global SSH key if configured
if (data.info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
}
}
await subscriptionService.setSshConfig();
+12 -11
View File
@@ -4,7 +4,6 @@ import { Container } from 'typedi';
import SockService from '../services/sock';
import { getPlatform } from '../config/util';
import { shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
export default async ({ server }: { server: Server }) => {
const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} });
@@ -18,19 +17,21 @@ export default async ({ server }: { server: Server }) => {
const authInfo = await shareStore.getAuthInfo();
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
if (authInfo) {
const { token = '', tokens = {} } = authInfo;
if (headerToken === token || tokens[platform] === headerToken) {
sockService.addClient(conn);
if (isValidToken(authInfo, headerToken, platform)) {
sockService.addClient(conn);
conn.on('data', (message) => {
conn.write(message);
});
conn.on('data', (message) => {
conn.write(message);
});
conn.on('close', function () {
sockService.removeClient(conn);
});
conn.on('close', function () {
sockService.removeClient(conn);
});
return;
return;
}
}
conn.close('404');
-17
View File
@@ -97,18 +97,6 @@ message UpdateCronRequest {
message DeleteCronsRequest { repeated int32 ids = 1; }
message GetCronsRequest {
optional string searchValue = 1;
}
message GetCronByIdRequest { int32 id = 1; }
message EnableCronsRequest { repeated int32 ids = 1; }
message DisableCronsRequest { repeated int32 ids = 1; }
message RunCronsRequest { repeated int32 ids = 1; }
message CronsResponse {
int32 code = 1;
repeated CronItem data = 2;
@@ -266,9 +254,4 @@ service Api {
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
rpc DeleteCrons(DeleteCronsRequest) returns (Response) {}
rpc GetCrons(GetCronsRequest) returns (CronsResponse) {}
rpc GetCronById(GetCronByIdRequest) returns (CronResponse) {}
rpc EnableCrons(EnableCronsRequest) returns (Response) {}
rpc DisableCrons(DisableCronsRequest) returns (Response) {}
rpc RunCrons(RunCronsRequest) returns (Response) {}
}
-471
View File
@@ -281,26 +281,6 @@ export interface DeleteCronsRequest {
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 {
code: number;
data: CronItem[];
@@ -2227,332 +2207,6 @@ 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 {
return { code: 0, data: [], message: undefined };
}
@@ -4322,51 +3976,6 @@ export const ApiService = {
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
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;
export interface ApiServer extends UntypedServiceImplementation {
@@ -4384,11 +3993,6 @@ export interface ApiServer extends UntypedServiceImplementation {
createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
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 {
@@ -4602,81 +4206,6 @@ export interface ApiClient extends Client {
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): 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 {
-119
View File
@@ -30,12 +30,6 @@ import {
UpdateCronRequest,
DeleteCronsRequest,
CronResponse,
GetCronsRequest,
CronsResponse,
GetCronByIdRequest,
EnableCronsRequest,
DisableCronsRequest,
RunCronsRequest,
} from '../protos/api';
import { NotificationInfo } from '../data/notify';
@@ -329,116 +323,3 @@ export const deleteCrons = async (
callback(e);
}
};
export const getCrons = async (
call: ServerUnaryCall<GetCronsRequest, CronsResponse>,
callback: sendUnaryData<CronsResponse>,
) => {
try {
const cronService = Container.get(CronService);
const result = await cronService.crontabs({
searchValue: call.request.searchValue || '',
page: '0',
size: '0',
sorter: '',
filters: '',
queryString: '',
});
const data = result.data.map((x) => normalizeCronData(x as CronItem));
callback(null, {
code: 200,
data: data.filter((x): x is CronItem => x !== undefined),
});
} catch (e: any) {
callback(null, {
code: 500,
data: [],
message: e.message,
});
}
};
export const getCronById = async (
call: ServerUnaryCall<GetCronByIdRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
if (!call.request.id) {
return callback(null, {
code: 400,
data: undefined,
message: 'id parameter is required',
});
}
const cronService = Container.get(CronService);
const data = (await cronService.getDb({ id: call.request.id })) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(null, {
code: 404,
data: undefined,
message: e.message,
});
}
};
export const enableCrons = async (
call: ServerUnaryCall<EnableCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.enabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const disableCrons = async (
call: ServerUnaryCall<DisableCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.disabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const runCrons = async (
call: ServerUnaryCall<RunCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.run(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
+6 -3
View File
@@ -17,11 +17,14 @@ const check = async (
return callback(null, { status: 1 });
}
const qinglongErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/qinglong-error.log`,
const panelErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/panel-error.log`,
);
const scheduleErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/schedule-error.log`,
);
return callback(
new Error(`${qinglongErrLog || ''}\n${res}`.trim()),
new Error(`${scheduleErrLog || ''}\n${panelErrLog || ''}\n${res}`.trim()),
);
default:
+22 -69
View File
@@ -4,12 +4,11 @@ import config from '../config';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import { exec, execSync } from 'child_process';
import fs from 'fs/promises';
import { CronExpressionParser } from 'cron-parser';
import cron_parser from 'cron-parser';
import {
getFileContentByName,
fileExist,
killTask,
killAllTasks,
getUniqPath,
safeJSONParse,
isDemoEnv,
@@ -25,7 +24,6 @@ import pickBy from 'lodash/pickBy';
import omit from 'lodash/omit';
import { writeFileWithLock } from '../shared/utils';
import { ScheduleType } from '../interface/schedule';
import { logStreamManager } from '../shared/logStreamManager';
@Service()
export default class CronService {
@@ -51,29 +49,9 @@ export default class CronService {
return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule);
}
private async getLogName(cron: Crontab) {
const { log_name, command, id } = cron;
if (log_name === '/dev/null') {
return log_name;
}
let uniqPath = await getUniqPath(command, `${id}`);
if (log_name) {
const normalizedLogName = log_name.startsWith('/')
? log_name
: path.join(config.logPath, log_name);
if (normalizedLogName.startsWith(config.logPath)) {
uniqPath = log_name;
}
}
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
await fs.mkdir(logDirPath, { recursive: true });
return uniqPath;
}
public async create(payload: Crontab): Promise<Crontab> {
const tab = new Crontab(payload);
tab.saved = false;
tab.log_name = await this.getLogName(tab);
const doc = await this.insert(tab);
if (isDemoEnv()) {
@@ -104,7 +82,6 @@ export default class CronService {
const doc = await this.getDb({ id: payload.id });
const tab = new Crontab({ ...doc, ...payload });
tab.saved = false;
tab.log_name = await this.getLogName(tab);
const newDoc = await this.updateDb(tab);
if (doc.isDisabled === 1 || isDemoEnv()) {
@@ -465,17 +442,12 @@ export default class CronService {
public async stop(ids: number[]) {
const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) {
// Kill all running instances of this task
try {
const command = this.makeCommand(doc);
await killAllTasks(command);
this.logger.info(
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
);
} catch (error) {
this.logger.error(
`[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`,
);
if (doc.pid) {
try {
await killTask(doc.pid);
} catch (error) {
this.logger.error(error);
}
}
}
@@ -504,15 +476,13 @@ export default class CronService {
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
);
let { id, command, log_name } = cron;
const uniqPath =
log_name === '/dev/null' || !log_name
? await getUniqPath(command, `${id}`)
: log_name;
let { id, command, log_path } = cron;
const uniqPath = await getUniqPath(command, `${id}`);
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
await fs.mkdir(logDirPath, { recursive: true });
if (log_path?.split('/')?.every((x) => x !== uniqPath)) {
await fs.mkdir(logDirPath, { recursive: true });
}
const logPath = `${uniqPath}/${logTime}.log`;
const absolutePath = path.resolve(config.logPath, `${logPath}`);
const cp = spawn(
@@ -528,7 +498,7 @@ export default class CronService {
{ where: { id } },
);
cp.stdout.on('data', async (data) => {
await logStreamManager.write(absolutePath, data.toString());
await fs.appendFile(absolutePath, data.toString());
});
cp.stderr.on('data', async (data) => {
this.logger.info(
@@ -536,7 +506,7 @@ export default class CronService {
command,
data.toString(),
);
await logStreamManager.write(absolutePath, data.toString());
await fs.appendFile(absolutePath, data.toString());
});
cp.on('error', async (err) => {
this.logger.error(
@@ -544,7 +514,7 @@ export default class CronService {
command,
err,
);
await logStreamManager.write(absolutePath, JSON.stringify(err));
await fs.appendFile(absolutePath, JSON.stringify(err));
});
cp.on('exit', async (code) => {
@@ -553,8 +523,6 @@ export default class CronService {
JSON.stringify(params),
code,
);
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id } },
@@ -596,9 +564,7 @@ export default class CronService {
if (!doc) {
return '';
}
if (doc.log_name === '/dev/null') {
return '日志设置为忽略';
}
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
const logFileExist = doc.log_path && (await fileExist(absolutePath));
if (logFileExist) {
@@ -641,11 +607,9 @@ export default class CronService {
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
command = `${TASK_PREFIX}${tab.command}`;
}
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${tab.id} `;
// 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} `;
}
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${
tab.id
} `;
if (tab.task_before) {
commandVariable += `task_before='${tab.task_before
.replace(/'/g, "'\\''")
@@ -687,23 +651,12 @@ export default class CronService {
await writeFileWithLock(config.crontabFile, crontab_string);
try {
execSync(`crontab ${config.crontabFile}`);
} catch (error: any) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to update system crontab:', errorMsg);
}
execSync(`crontab ${config.crontabFile}`);
await CrontabModel.update({ saved: true }, { where: {} });
}
public importCrontab() {
exec('crontab -l', (error, stdout) => {
if (error) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to read system crontab:', errorMsg);
}
exec('crontab -l', (error, stdout, stderr) => {
const lines = stdout.split('\n');
const namePrefix = new Date().getTime();
@@ -717,7 +670,7 @@ export default class CronService {
if (
command &&
schedule &&
CronExpressionParser.parse(schedule).hasNext()
cron_parser.parseExpression(schedule).hasNext()
) {
const name = namePrefix + '_' + index;
+4 -12
View File
@@ -1,8 +1,7 @@
import groupBy from 'lodash/groupBy';
import { FindOptions, Op } from 'sequelize';
import { Inject, Service } from 'typedi';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import config from '../config';
import * as fs from 'fs/promises';
import {
Env,
EnvModel,
@@ -12,6 +11,8 @@ import {
minPosition,
stepPosition,
} from '../data/env';
import groupBy from 'lodash/groupBy';
import { FindOptions, Op } from 'sequelize';
import { writeFileWithLock } from '../shared/utils';
@Service()
@@ -146,7 +147,6 @@ export default class EnvService {
}
try {
const result = await this.find(condition, [
['isPinned', 'DESC'],
['position', 'DESC'],
['createdAt', 'ASC'],
]);
@@ -190,14 +190,6 @@ export default class EnvService {
await this.set_envs();
}
public async pin(ids: number[]) {
await EnvModel.update({ isPinned: 1 }, { where: { id: ids } });
}
public async unPin(ids: number[]) {
await EnvModel.update({ isPinned: 0 }, { where: { id: ids } });
}
public async set_envs() {
const envs = await this.envs('', {
name: { [Op.not]: null },
-28
View File
@@ -131,32 +131,4 @@ 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',
},
);
}
}
+7 -11
View File
@@ -31,7 +31,6 @@ import { formatCommand, formatUrl } from '../config/subscription';
import { CrontabModel } from '../data/cron';
import CrontabService from './cron';
import taskLimit from '../shared/pLimit';
import { logStreamManager } from '../shared/logStreamManager';
@Service()
export default class SubscriptionService {
@@ -137,7 +136,7 @@ export default class SubscriptionService {
let beforeStr = '';
try {
if (doc.sub_before) {
await logStreamManager.write(absolutePath, `\n## 执行before命令...\n\n`);
await fs.appendFile(absolutePath, `\n## 执行before命令...\n\n`);
beforeStr = await promiseExec(doc.sub_before);
}
} catch (error: any) {
@@ -145,7 +144,7 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
}
if (beforeStr) {
await logStreamManager.write(absolutePath, `${beforeStr}\n`);
await fs.appendFile(absolutePath, `${beforeStr}\n`);
}
},
onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => {
@@ -164,7 +163,7 @@ export default class SubscriptionService {
let afterStr = '';
try {
if (sub.sub_after) {
await logStreamManager.write(absolutePath, `\n\n## 执行after命令...\n\n`);
await fs.appendFile(absolutePath, `\n\n## 执行after命令...\n\n`);
afterStr = await promiseExec(sub.sub_after);
}
} catch (error: any) {
@@ -172,19 +171,16 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
}
if (afterStr) {
await logStreamManager.write(absolutePath, `${afterStr}\n`);
await fs.appendFile(absolutePath, `${afterStr}\n`);
}
await logStreamManager.write(
await fs.appendFile(
absolutePath,
`\n## 执行结束... ${endTime.format(
'YYYY-MM-DD HH:mm:ss',
)} 耗时 ${diff}${LOG_END_SYMBOL}`,
);
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: undefined },
{ where: { id: sub.id } },
@@ -199,12 +195,12 @@ export default class SubscriptionService {
onError: async (message: string) => {
const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string);
await logStreamManager.write(absolutePath, `\n${message}`);
await fs.appendFile(absolutePath, `\n${message}`);
},
onLog: async (message: string) => {
const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string);
await logStreamManager.write(absolutePath, `\n${message}`);
await fs.appendFile(absolutePath, `\n${message}`);
},
};
}
-21
View File
@@ -530,27 +530,6 @@ 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') {
if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: '参数错误' };
+7 -139
View File
@@ -11,7 +11,6 @@ import {
SystemModelInfo,
LoginStatus,
AuthInfo,
TokenInfo,
} from '../data/system';
import { NotificationInfo } from '../data/notify';
import NotificationService from './notify';
@@ -102,23 +101,12 @@ export default class UserService {
algorithm: 'HS384',
});
const tokenInfo: TokenInfo = {
value: token,
timestamp,
ip,
address,
platform: req.platform,
};
const updatedTokens = this.addTokenToList(
tokens,
req.platform,
tokenInfo,
);
await this.updateAuthInfo(content, {
token,
tokens: updatedTokens,
tokens: {
...tokens,
[req.platform]: token,
},
lastlogon: timestamp,
retries: 0,
lastip: ip,
@@ -192,37 +180,11 @@ export default class UserService {
}
}
public async logout(platform: string, tokenValue: string): Promise<any> {
if (!platform || !tokenValue) {
this.logger.warn('Invalid logout parameters - empty platform or token');
return;
}
public async logout(platform: string): Promise<any> {
const authInfo = await this.getAuthInfo();
// Verify the token exists before attempting to remove it
const tokenExists = this.findTokenInList(
authInfo.tokens,
platform,
tokenValue,
);
if (!tokenExists && authInfo.token !== tokenValue) {
// Token not found, but don't throw error - user may have already logged out
this.logger.info(
`Logout attempted for non-existent token on platform: ${platform}`,
);
return;
}
const updatedTokens = this.removeTokenFromList(
authInfo.tokens,
platform,
tokenValue,
);
await this.updateAuthInfo(authInfo, {
token: authInfo.token === tokenValue ? '' : authInfo.token,
tokens: updatedTokens,
token: '',
tokens: { ...authInfo.tokens, [platform]: '' },
});
}
@@ -402,100 +364,6 @@ export default class UserService {
}
}
private normalizeTokens(
tokens: Record<string, string | TokenInfo[]>,
): Record<string, TokenInfo[]> {
const normalized: Record<string, TokenInfo[]> = {};
for (const [platform, value] of Object.entries(tokens)) {
if (typeof value === 'string') {
// Legacy format: convert string token to TokenInfo array
if (value) {
normalized[platform] = [
{
value,
timestamp: Date.now(),
ip: '',
address: '',
platform,
},
];
} else {
normalized[platform] = [];
}
} else {
// Already in new format
normalized[platform] = value || [];
}
}
return normalized;
}
private addTokenToList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenInfo: TokenInfo,
maxTokensPerPlatform: number = config.maxTokensPerPlatform,
): Record<string, TokenInfo[]> {
// Validate maxTokensPerPlatform parameter
if (!Number.isInteger(maxTokensPerPlatform) || maxTokensPerPlatform < 1) {
this.logger.warn(
`Invalid maxTokensPerPlatform value: ${maxTokensPerPlatform}, using default`,
);
maxTokensPerPlatform = config.maxTokensPerPlatform;
}
const normalized = this.normalizeTokens(tokens);
if (!normalized[platform]) {
normalized[platform] = [];
}
// Add new token
normalized[platform].unshift(tokenInfo);
// Limit the number of active tokens per platform
if (normalized[platform].length > maxTokensPerPlatform) {
normalized[platform] = normalized[platform].slice(
0,
maxTokensPerPlatform,
);
}
return normalized;
}
private removeTokenFromList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): Record<string, TokenInfo[]> {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
normalized[platform] = normalized[platform].filter(
(t) => t.value !== tokenValue,
);
}
return normalized;
}
private findTokenInList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): TokenInfo | undefined {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
return normalized[platform].find((t) => t.value === tokenValue);
}
return undefined;
}
public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info;
const authInfo = await this.getAuthInfo();
-46
View File
@@ -1,46 +0,0 @@
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
@@ -1,110 +0,0 @@
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,45 +2,10 @@ import { spawn } from 'cross-spawn';
import taskLimit from './pLimit';
import Logger from '../loaders/logger';
import { ICron } from '../protos/cron';
import { CrontabModel, CrontabStatus } from '../data/cron';
import { killTask } from '../config/util';
export function runCron(cmd: string, cron: ICron): Promise<number | void> {
return taskLimit.runWithCronLimit(cron, () => {
return new Promise(async (resolve: any) => {
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
try {
const existingCron = await CrontabModel.findOne({
where: { id: Number(cron.id) },
});
// Default to single instance mode (0) for backward compatibility
const allowMultipleInstances =
existingCron?.allow_multiple_instances === 1;
if (
!allowMultipleInstances &&
existingCron &&
existingCron.pid &&
(existingCron.status === CrontabStatus.running ||
existingCron.status === CrontabStatus.queued)
) {
Logger.info(
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
);
await killTask(existingCron.pid);
// Update the status to idle after killing
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: Number(cron.id) } },
);
}
} catch (error) {
Logger.error(
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
);
}
Logger.info(
`[schedule][开始执行任务] 参数 ${JSON.stringify({
...cron,
+2 -47
View File
@@ -1,8 +1,6 @@
import { Joi } from 'celebrate';
import { CronExpressionParser } from 'cron-parser';
import cron_parser from 'cron-parser';
import { ScheduleType } from '../interface/schedule';
import path from 'path';
import config from '../config';
const validateSchedule = (value: string, helpers: any) => {
if (
@@ -13,7 +11,7 @@ const validateSchedule = (value: string, helpers: any) => {
}
try {
if (CronExpressionParser.parse(value).hasNext()) {
if (cron_parser.parseExpression(value).hasNext()) {
return value;
}
} catch (e) {
@@ -39,47 +37,4 @@ export const commonCronSchema = {
extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null),
log_name: Joi.string()
.optional()
.allow('')
.allow(null)
.custom((value, helpers) => {
if (!value) return value;
// Check if it's an absolute path
if (value.startsWith('/')) {
// Allow /dev/null as special case
if (value === '/dev/null') {
return value;
}
// For other absolute paths, ensure they are within the safe log directory
const normalizedValue = path.normalize(value);
const normalizedLogPath = path.normalize(config.logPath);
if (!normalizedValue.startsWith(normalizedLogPath)) {
return helpers.error('string.unsafePath');
}
return value;
}
if (
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
value,
)
) {
return helpers.error('string.pattern.base');
}
if (value.length > 100) {
return helpers.error('string.max');
}
return value;
})
.messages({
'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符',
'string.max': '日志名称不能超过100个字符',
'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null',
}),
allow_multiple_instances: Joi.number().optional().valid(0, 1),
};
+7 -26
View File
@@ -1,56 +1,37 @@
#!/bin/bash
export PATH="$HOME/bin:$PATH"
dir_shell=/ql/shell
. $dir_shell/share.sh
export_ql_envs() {
export BACK_PORT="${ql_port}"
export GRPC_PORT="${ql_grpc_port}"
}
. $dir_shell/env.sh
log_with_style() {
local level="$1"
local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
}
# 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
log_with_style "INFO" "🚀 1. 检测配置文件..."
import_config "$@"
fix_config
# Try to initialize PM2, but don't fail if it doesn't work
pm2 l &>/dev/null || log_with_style "WARN" "PM2 初始化可能失败,将在启动时尝试使用备用方案"
pm2 l &>/dev/null
log_with_style "INFO" "⚙️ 2. 启动 pm2 服务..."
reload_pm2
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 &
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 &
fi
log_with_style "SUCCESS" "🎉 容器启动成功!"
log_with_style "SUCCESS" "🎉 容器启动成功!"
crond -f >/dev/null
+1 -2
View File
@@ -1,6 +1,5 @@
{
"private": true,
"packageManager": "pnpm@8.3.1",
"scripts": {
"start": "concurrently -n w: npm:start:*",
"start:back": "nodemon ./back/app.ts",
@@ -62,7 +61,7 @@
"celebrate": "^15.0.3",
"chokidar": "^4.0.1",
"cors": "^2.8.5",
"cron-parser": "^5.4.0",
"cron-parser": "^4.9.0",
"cross-spawn": "^7.0.6",
"dayjs": "^1.11.13",
"dotenv": "^16.4.6",
+2 -14
View File
@@ -39,8 +39,8 @@ dependencies:
specifier: ^2.8.5
version: 2.8.5
cron-parser:
specifier: ^5.4.0
version: 5.4.0
specifier: ^4.9.0
version: 4.9.0
cross-spawn:
specifier: ^7.0.6
version: 7.0.6
@@ -6524,13 +6524,6 @@ packages:
luxon: 3.5.0
dev: false
/cron-parser@5.4.0:
resolution: {integrity: sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA==}
engines: {node: '>=18'}
dependencies:
luxon: 3.7.2
dev: false
/croner@7.0.8:
resolution: {integrity: sha512-4E27J9ZQV9prM9ggU18QGPYPMSblbA9JuGv4Ff3Gk6supX4RszNGQxBgiFBL6wb/L9HuSMpFbQpduMiDRo+z5Q==}
engines: {node: '>=6.0'}
@@ -9799,11 +9792,6 @@ packages:
engines: {node: '>=12'}
dev: false
/luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
dev: false
/make-dir@2.1.0:
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
engines: {node: '>=6'}
-28
View File
@@ -12,32 +12,4 @@ QLAPI.getEnvs({ searchValue: 'dddd' }).then((x) => {
QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => {
console.log('systemNotify', x);
});
// 查询定时任务 (Query cron tasks)
QLAPI.getCrons({ searchValue: 'test' }).then((x) => {
console.log('getCrons', x);
});
// 通过ID查询定时任务 (Get cron by ID)
QLAPI.getCronById({ id: 1 }).then((x) => {
console.log('getCronById', x);
}).catch((err) => {
console.log('getCronById error', err);
});
// 启用定时任务 (Enable cron tasks)
QLAPI.enableCrons({ ids: [1, 2] }).then((x) => {
console.log('enableCrons', x);
});
// 禁用定时任务 (Disable cron tasks)
QLAPI.disableCrons({ ids: [1, 2] }).then((x) => {
console.log('disableCrons', x);
});
// 手动执行定时任务 (Run cron tasks manually)
QLAPI.runCrons({ ids: [1] }).then((x) => {
console.log('runCrons', x);
});
console.log('test desc');
+8 -8
View File
@@ -41,7 +41,7 @@ add_cron_api() {
fi
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \
@@ -71,7 +71,7 @@ update_cron_api() {
fi
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \
-X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
@@ -98,7 +98,7 @@ update_cron_command_api() {
fi
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \
-X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
@@ -118,7 +118,7 @@ del_cron_api() {
local ids="$1"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \
-X 'DELETE' \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
@@ -143,7 +143,7 @@ update_cron() {
local runningTime="${6:-0}"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons/status?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons/status?t=$currentTimeStamp" \
-X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
@@ -165,7 +165,7 @@ notify_api() {
local content="$2"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/system/notify?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/system/notify?t=$currentTimeStamp" \
-X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
@@ -185,7 +185,7 @@ find_cron_api() {
local params="$1"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons/detail?$params&t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons/detail?$params&t=$currentTimeStamp" \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
--compressed
@@ -204,7 +204,7 @@ update_auth_config() {
local tip="$2"
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/open/system/auth/reset?t=$currentTimeStamp" \
-X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
+5 -5
View File
@@ -24,14 +24,14 @@ copy_dep() {
pm2_log() {
echo -e "---> pm2日志"
local panelOut="/root/.pm2/logs/qinglong-out.log"
local panelError="/root/.pm2/logs/qinglong-error.log"
local panelOut="/root/.pm2/logs/panel-out.log"
local panelError="/root/.pm2/logs/panel-error.log"
tail -n 300 "$panelOut"
tail -n 300 "$panelError"
}
check_ql() {
local api=$(curl -s --noproxy "*" "http://0.0.0.0:${ql_port}")
local api=$(curl -s --noproxy "*" "http://0.0.0.0:5700")
echo -e "\n=====> 检测面板\n\n$api\n"
if [[ $api =~ "<div id=\"root\"></div>" ]]; then
echo -e "=====> 面板服务启动正常\n"
@@ -42,10 +42,10 @@ check_pm2() {
pm2_log
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/api/system?t=$currentTimeStamp" \
curl -s --noproxy "*" "http://0.0.0.0:5700/api/system?t=$currentTimeStamp" \
-H 'Accept: */*' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \
-H "Referer: http://0.0.0.0:${ql_port}/crontab" \
-H 'Referer: http://0.0.0.0:5700/crontab' \
-H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \
--compressed
)
+1 -6
View File
@@ -5,7 +5,7 @@ const { join } = require('path');
class GrpcClient {
static #config = {
protoPath: join(process.env.QL_DIR, 'back/protos/api.proto'),
serverAddress: `0.0.0.0:${process.env.GRPC_PORT || '5500'}`,
serverAddress: '0.0.0.0:5500',
protoOptions: {
keepCase: true,
longs: String,
@@ -33,11 +33,6 @@ class GrpcClient {
'createCron',
'updateCron',
'deleteCrons',
'getCrons',
'getCronById',
'enableCrons',
'disableCrons',
'runCrons',
];
#client;
-7
View File
@@ -28,7 +28,6 @@ function run() {
numParam,
file_task_before,
file_task_before_js,
file_preload_js,
dir_scripts,
task_before,
PREV_NODE_OPTIONS,
@@ -41,13 +40,7 @@ function run() {
const fileName = process.argv[1].replace(`${dir_scripts}/`, '');
const tempFile = `/tmp/env_${process.pid}.json`;
// Export NODE_OPTIONS so task_before can use it for any node commands
const nodeOptionsForBash = file_preload_js
? ['-r', file_preload_js, PREV_NODE_OPTIONS].filter(Boolean).join(' ')
: PREV_NODE_OPTIONS || '';
const commands = [
`export NODE_OPTIONS="${nodeOptionsForBash}"`,
`source ${file_task_before} ${fileName}`,
task_before ? `eval '${task_before.replace(/'/g, "'\\''")}'` : null,
`echo -e '${splitStr}'`,
-8
View File
@@ -44,19 +44,11 @@ def run():
split_str = "__sitecustomize__"
file_name = sys.argv[0].replace(f"{os.getenv('dir_scripts')}/", "")
# Get environment variables needed for PYTHONPATH
dir_preload = os.getenv("dir_preload", "")
dir_config = os.getenv("dir_config", "")
# 创建临时文件路径
temp_file = f"/tmp/env_{os.getpid()}.json"
# Export PYTHONPATH so task_before can use it for any python commands
pythonpath_for_bash = ':'.join(filter(None, [dir_preload, dir_config, prev_pythonpath]))
# 构建命令数组
commands = [
f'export PYTHONPATH="{pythonpath_for_bash}"',
f'source {os.getenv("file_task_before")} {file_name}'
]
+11 -32
View File
@@ -59,10 +59,15 @@ list_own_user=$dir_list_tmp/own_user.list
list_own_add=$dir_list_tmp/own_add.list
list_own_drop=$dir_list_tmp/own_drop.list
## 软连接及其原始文件对应关系
link_name=(
task
ql
)
original_name=(
task.sh
update.sh
)
init_env() {
local pnpm_global_path=$(pnpm root -g 2>/dev/null)
@@ -79,20 +84,15 @@ init_env() {
export PYTHONUNBUFFERED=1
}
load_ql_envs() {
ql_base_url=${QlBaseUrl:-"/"}
ql_port=${QlPort:-"5700"}
ql_grpc_port=${QlGrpcPort:-"5500"}
current_branch=${QL_BRANCH:-""}
}
import_config() {
[[ -f $file_config_user ]] && . $file_config_user
load_ql_envs
ql_base_url=${QlBaseUrl:-"/"}
ql_port=${QlPort:-"5700"}
command_timeout_time=${CommandTimeoutTime:-""}
file_extensions=${RepoFileExtensions:-"js py"}
proxy_url=${ProxyUrl:-""}
current_branch=${QL_BRANCH:-""}
if [[ -n "${DefaultCronRule}" ]]; then
default_cron="${DefaultCronRule}"
@@ -272,35 +272,14 @@ random_range() {
delete_pm2() {
cd $dir_root
# 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
pm2 delete ecosystem.config.js
}
reload_pm2() {
cd $dir_root
restore_env_vars
# 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
pm2 flush &>/dev/null
pm2 startOrGracefulReload ecosystem.config.js --update-env
}
diff_time() {
+10 -19
View File
@@ -46,22 +46,18 @@ handle_log_path() {
time=$(date "+$mtime_format")
log_time=$(format_log_time "$mtime_format" "$time")
if [[ -z $log_name ]]; then
log_dir_tmp="${file_param##*/}"
if [[ $file_param =~ "/" ]]; then
if [[ $file_param == /* ]]; then
log_dir_tmp_path="${file_param:1}"
else
log_dir_tmp_path="${file_param}"
fi
log_dir_tmp="${file_param##*/}"
if [[ $file_param =~ "/" ]]; then
if [[ $file_param == /* ]]; then
log_dir_tmp_path="${file_param:1}"
else
log_dir_tmp_path="${file_param}"
fi
log_dir_tmp_path="${log_dir_tmp_path%/*}"
log_dir_tmp_path="${log_dir_tmp_path##*/}"
[[ $log_dir_tmp_path ]] && log_dir_tmp="${log_dir_tmp_path}_${log_dir_tmp}"
log_dir="${log_dir_tmp%.*}${suffix}"
else
log_dir="$log_name"
fi
log_dir_tmp_path="${log_dir_tmp_path%/*}"
log_dir_tmp_path="${log_dir_tmp_path##*/}"
[[ $log_dir_tmp_path ]] && log_dir_tmp="${log_dir_tmp_path}_${log_dir_tmp}"
log_dir="${log_dir_tmp%.*}${suffix}"
log_path="$log_dir/$log_time.log"
if [[ ${real_log_path:=} ]]; then
@@ -77,11 +73,6 @@ handle_log_path() {
if [[ "${real_time:=}" == "true" ]]; then
cmd=""
fi
if [[ "${log_dir:=}" == "/dev/null" ]]; then
cmd=">> /dev/null"
log_path="/dev/null"
fi
}
format_params() {
-1
View File
@@ -3,7 +3,6 @@
dir_shell=$QL_DIR/shell
. $dir_shell/share.sh
. $dir_shell/api.sh
load_ql_envs
. $dir_shell/env.sh
send_mark=$dir_shell/send_mark
+5 -12
View File
@@ -7,18 +7,11 @@ export function rootContainer(container: any) {
'en': require('./locales/en-US.json'),
'zh': require('./locales/zh-CN.json'),
};
let currentLocale: string;
try {
currentLocale = intl.determineLocale({
urlLocaleKey: 'lang',
cookieLocaleKey: 'lang',
localStorageLocaleKey: 'lang',
}).slice(0, 2);
} catch (e: unknown) {
// Handle decodeURIComponent errors from malformed cookies
console.warn('Failed to determine locale from cookies:', e);
currentLocale = '';
}
let currentLocale = intl.determineLocale({
urlLocaleKey: 'lang',
cookieLocaleKey: 'lang',
localStorageLocaleKey: 'lang',
}).slice(0, 2);
if (!currentLocale || !Object.keys(locales).includes(currentLocale)) {
currentLocale = 'zh';
+11 -16
View File
@@ -1,6 +1,6 @@
import intl from 'react-intl-universal';
import React, { useRef, useState, useEffect } from 'react';
import { Tooltip, Typography, message } from 'antd';
import { Tooltip, Typography } from 'antd';
import { CopyOutlined, CheckOutlined } from '@ant-design/icons';
import { CopyToClipboard } from 'react-copy-to-clipboard';
@@ -10,21 +10,16 @@ const Copy = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false);
const copyIdRef = useRef<number>();
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) => {
const copyText = (e?: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
setCopied(true);
cleanCopyId();
copyIdRef.current = window.setTimeout(() => {
setCopied(false);
}, 3000);
};
const cleanCopyId = () => {
@@ -32,8 +27,8 @@ const Copy = ({ text }: { text: string }) => {
};
return (
<Link onClick={handleClick} style={{ marginLeft: 4 }}>
<CopyToClipboard text={text} onCopy={handleCopy}>
<Link onClick={copyText} style={{ marginLeft: 1 }}>
<CopyToClipboard text={text}>
<Tooltip
key="copy"
title={copied ? intl.get('复制成功') : intl.get('复制')}
+3 -34
View File
@@ -104,7 +104,7 @@
"序号": "Number",
"备注": "Remarks",
"更新时间": "Update Time",
"创建时间": "Created Time",
"创建时间": "Creation Time",
"确认删除依赖": "Confirm to delete the dependency",
"确认重新安装": "Confirm to reinstall",
"确认取消安装": "Confirm to cancel install",
@@ -252,7 +252,7 @@
"登录日志": "Login Logs",
"其他设置": "Other Settings",
"关于": "About",
"成功": "Successfully",
"成功": "Success",
"失败": "Failure",
"登录时间": "Login Time",
"登录地址": "Login Address",
@@ -521,36 +521,5 @@
"远程仓库缓存": "Remote repository cache",
"SSH 文件缓存": "SSH file cache",
"清除依赖缓存": "Clean dependency cache",
"清除成功": "Clean successful",
"日志名称": "Log Name",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports absolute paths like /dev/null",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports /dev/null to discard logs, other absolute paths must be within log directory",
"请输入自定义日志文件夹名称": "Please enter a custom log folder name",
"请输入自定义日志文件夹名称或绝对路径": "Please enter a custom log folder name or absolute path",
"请输入自定义日志文件夹名称或 /dev/null": "Please enter a custom log folder name or /dev/null",
"日志名称只能包含字母、数字、下划线和连字符": "Log name can only contain letters, numbers, underscores and hyphens",
"日志名称不能超过100个字符": "Log name cannot exceed 100 characters",
"未启用": "Not enabled",
"默认为 CPU 个数": "Default is the number of CPUs",
"Minimum is 4": "Minimum is 4",
"实例模式": "Instance Mode",
"单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "Single instance mode: automatically stop old task before starting new scheduled task; Multi-instance mode: allow multiple task instances to run simultaneously",
"请选择实例模式": "Please select instance mode",
"单实例": "Single Instance",
"多实例": "Multi-Instance",
"SSH密钥": "SSH Keys",
"别名": "Alias",
"编辑SSH密钥": "Edit SSH Key",
"创建SSH密钥": "Create SSH Key",
"更新SSH密钥成功": "SSH key updated successfully",
"创建SSH密钥成功": "SSH key created successfully",
"请输入SSH密钥别名": "Please enter SSH key alias",
"请输入SSH私钥": "Please enter SSH private key",
"请输入SSH私钥内容(以 -----BEGIN 开头)": "Please enter SSH private key content (starts with -----BEGIN)",
"确认删除SSH密钥": "Confirm to delete SSH key",
"批量": "Batch",
"全局SSH私钥": "Global SSH Private Key",
"用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories",
"请输入完整的SSH私钥内容": "Please enter the complete SSH private key content"
"清除成功": "Clean successful"
}
+1 -32
View File
@@ -521,36 +521,5 @@
"远程仓库缓存": "远程仓库缓存",
"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私钥内容"
"清除成功": "清除成功"
}
+17 -32
View File
@@ -66,7 +66,6 @@ const SHOW_TAB_COUNT = 10;
const Crontab = () => {
const { headerStyle, isPhone, theme } = useOutletContext<SharedContext>();
const [allSubscriptions, setAllSubscriptions] = useState<any[]>([]);
const columns: ColumnProps<ICrontab>[] = [
{
title: intl.get('名称'),
@@ -248,8 +247,8 @@ const Crontab = () => {
>
{record.last_execution_time
? dayjs(record.last_execution_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)
'YYYY-MM-DD HH:mm:ss',
)
: '-'}
</span>
);
@@ -273,12 +272,6 @@ const Crontab = () => {
title: intl.get('关联订阅'),
width: 185,
render: (text, record: any) => record?.subscription?.name || '-',
key: 'sub_id',
dataIndex: 'sub_id',
filters: allSubscriptions.map((sub) => ({
text: sub.name || sub.alias,
value: sub.id,
})),
},
{
title: intl.get('操作'),
@@ -368,10 +361,11 @@ const Crontab = () => {
const getCrons = () => {
setLoading(true);
const { page, size, sorter, filters } = pageConf;
let url = `${config.apiPrefix
}crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify(
filters,
)}`;
let url = `${
config.apiPrefix
}crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify(
filters,
)}`;
if (sorter && sorter.column && sorter.order) {
url += `&sorter=${JSON.stringify({
field: sorter.column.key,
@@ -529,8 +523,9 @@ const Crontab = () => {
const enabledOrDisabledCron = (record: any, index: number) => {
Modal.confirm({
title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
title: `确认${
record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
content: (
<>
{intl.get('确认')}
@@ -545,7 +540,8 @@ const Crontab = () => {
onOk() {
request
.put(
`${config.apiPrefix}crons/${record.isDisabled === 1 ? 'enable' : 'disable'
`${config.apiPrefix}crons/${
record.isDisabled === 1 ? 'enable' : 'disable'
}`,
[record.id],
)
@@ -569,8 +565,9 @@ const Crontab = () => {
const pinOrUnPinCron = (record: any, index: number) => {
Modal.confirm({
title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
title: `确认${
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
content: (
<>
{intl.get('确认')}
@@ -585,7 +582,8 @@ const Crontab = () => {
onOk() {
request
.put(
`${config.apiPrefix}crons/${record.isPinned === 1 ? 'unpin' : 'pin'
`${config.apiPrefix}crons/${
record.isPinned === 1 ? 'unpin' : 'pin'
}`,
[record.id],
)
@@ -801,20 +799,8 @@ const Crontab = () => {
}
}, [viewConf, enabledCronViews]);
const getAllSubscriptions = () => {
request
.get(`${config.apiPrefix}subscriptions`)
.then(({ code, data }) => {
if (code === 200) {
setAllSubscriptions(data || []);
}
})
.catch(() => {});
};
useEffect(() => {
getCronViews();
getAllSubscriptions();
}, []);
const viewAction = (key: string) => {
@@ -1028,7 +1014,6 @@ const Crontab = () => {
)}
<Table
columns={columns}
sortDirections={['descend', 'ascend']}
pagination={{
current: pageConf.page,
pageSize: pageConf.size,
+1 -1
View File
@@ -55,7 +55,7 @@ const CronLogModal = ({
const log = data as string;
setValue(log || intl.get("暂无日志"));
const hasNext = Boolean(
log && !logEnded(log) && !log.includes("日志不存在") && !log.includes("日志设置为忽略"),
log && !logEnded(log) && !log.includes("日志不存在"),
);
if (!hasNext && !logEnded(value) && value !== intl.get("启动中...")) {
setTimeout(() => {
+3 -47
View File
@@ -3,7 +3,7 @@ import config from '@/utils/config';
import { request } from '@/utils/http';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Form, Input, Modal, Select, Space, message } from 'antd';
import { CronExpressionParser } from 'cron-parser';
import cronParse from 'cron-parser';
import { useEffect, useState } from 'react';
import intl from 'react-intl-universal';
import { getScheduleType, scheduleTypeMap } from './const';
@@ -91,7 +91,7 @@ const CronModal = ({
{ required: true },
{
validator: (_, value) => {
if (!value || CronExpressionParser.parse(value).hasNext()) {
if (!value || cronParse.parseExpression(value).hasNext()) {
return Promise.resolve();
}
return Promise.reject(intl.get('Cron表达式格式有误'));
@@ -180,51 +180,6 @@ const CronModal = ({
<Form.Item name="labels" label={intl.get('标签')}>
<EditableTagGroup />
</Form.Item>
<Form.Item
name="allow_multiple_instances"
label={intl.get('实例模式')}
tooltip={intl.get(
'单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例',
)}
>
<Select placeholder={intl.get('请选择实例模式')}>
<Select.Option value={0}>{intl.get('单实例')}</Select.Option>
<Select.Option value={1}>{intl.get('多实例')}</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="log_name"
label={intl.get('日志名称')}
tooltip={intl.get(
'自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内',
)}
rules={[
{
validator: (_, value) => {
if (!value) return Promise.resolve();
if (value === '/dev/null') return Promise.resolve();
if (value.length > 100) {
return Promise.reject(intl.get('日志名称不能超过100个字符'));
}
if (
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
value,
)
) {
return Promise.reject(
intl.get('日志名称只能包含字母、数字、下划线和连字符'),
);
}
return Promise.resolve();
},
},
]}
>
<Input
placeholder={intl.get('请输入自定义日志文件夹名称或 /dev/null')}
maxLength={200}
/>
</Form.Item>
<Form.Item
name="task_before"
label={intl.get('执行前')}
@@ -357,3 +312,4 @@ const CronLabelModal = ({
};
export { CronLabelModal, CronModal as default };
-1
View File
@@ -37,7 +37,6 @@ export interface ICrontab {
nextRunTime: Date;
sub_id: number;
extra_schedules?: Array<{ schedule: string }>;
allow_multiple_instances?: 1 | 0;
}
export enum ScheduleType {
+36 -111
View File
@@ -1,42 +1,47 @@
import useTableScrollHeight from '@/hooks/useTableScrollHeight';
import { SharedContext } from '@/layouts';
import config from '@/utils/config';
import { request } from '@/utils/http';
import { exportJson } from '@/utils/index';
import intl from 'react-intl-universal';
import React, {
useCallback,
useRef,
useState,
useEffect,
useMemo,
} from 'react';
import {
Button,
message,
Modal,
Table,
Tag,
Space,
Typography,
Tooltip,
Input,
UploadProps,
Upload,
} from 'antd';
import {
CheckCircleOutlined,
DeleteOutlined,
EditOutlined,
PushpinFilled,
PushpinOutlined,
DeleteOutlined,
SyncOutlined,
CheckCircleOutlined,
StopOutlined,
UploadOutlined,
} from '@ant-design/icons';
import config from '@/utils/config';
import { PageContainer } from '@ant-design/pro-layout';
import { useOutletContext } from '@umijs/max';
import {
Button,
Input,
Modal,
Space,
Table,
Tag,
Tooltip,
Typography,
Upload,
UploadProps,
message,
} from 'antd';
import dayjs from 'dayjs';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { request } from '@/utils/http';
import EnvModal from './modal';
import EditNameModal from './editNameModal';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import intl from 'react-intl-universal';
import { useVT } from 'virtualizedtableforantd4';
import Copy from '../../components/copy';
import EditNameModal from './editNameModal';
import './index.less';
import EnvModal from './modal';
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 dayjs from 'dayjs';
const { Paragraph } = Typography;
const { Search } = Input;
@@ -54,15 +59,11 @@ enum StatusColor {
enum OperationName {
'启用',
'禁用',
'置顶',
'取消置顶',
}
enum OperationPath {
'enable',
'disable',
'pin',
'unpin',
}
const type = 'DragableBodyRow';
@@ -180,7 +181,7 @@ const Env = () => {
{
title: intl.get('操作'),
key: 'action',
width: 160,
width: 120,
render: (text: string, record: any, index: number) => {
const isPc = !isPhone;
return (
@@ -207,23 +208,6 @@ const Env = () => {
)}
</a>
</Tooltip>
<Tooltip
title={
isPc
? record.isPinned === 1
? intl.get('取消置顶')
: intl.get('置顶')
: ''
}
>
<a onClick={() => pinOrUnpinEnv(record, index)}>
{record.isPinned === 1 ? (
<PushpinFilled />
) : (
<PushpinOutlined />
)}
</a>
</Tooltip>
<Tooltip title={isPc ? intl.get('删除') : ''}>
<a onClick={() => deleteEnv(record, index)}>
<DeleteOutlined />
@@ -321,51 +305,6 @@ const Env = () => {
setIsModalVisible(true);
};
const pinOrUnpinEnv = (record: any, index: number) => {
Modal.confirm({
title: `确认${
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
content: (
<>
{intl.get('确认')}
{record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')}
Env{' '}
<Paragraph
style={{ wordBreak: 'break-all', display: 'inline' }}
ellipsis={{ rows: 6, expandable: true }}
type="warning"
copyable
>
{record.name}: {record.value}
</Paragraph>{' '}
{intl.get('吗')}
</>
),
onOk() {
request
.put(
`${config.apiPrefix}envs/${
record.isPinned === 1 ? 'unpin' : 'pin'
}`,
[record.id],
)
.then(({ code, data }) => {
if (code === 200) {
message.success(
`${
record.isPinned === 1
? intl.get('取消置顶')
: intl.get('置顶')
}${intl.get('成功')}`,
);
getEnvs();
}
});
},
});
};
const deleteEnv = (record: any, index: number) => {
Modal.confirm({
title: intl.get('确认删除'),
@@ -650,20 +589,6 @@ const Env = () => {
>
{intl.get('批量禁用')}
</Button>
<Button
type="primary"
onClick={() => operateEnvs(2)}
style={{ marginLeft: 8, marginBottom: 5 }}
>
{intl.get('批量置顶')}
</Button>
<Button
type="primary"
onClick={() => operateEnvs(3)}
style={{ marginLeft: 8, marginRight: 8 }}
>
{intl.get('批量取消置顶')}
</Button>
<span style={{ marginLeft: 8 }}>
{intl.get('已选择')}
<a>{selectedRowIds?.length}</a>
-1
View File
@@ -49,7 +49,6 @@ const Setting = () => {
reloadTheme,
systemInfo,
} = useOutletContext<SharedContext>();
console.log('user',user)
const columns = [
{
title: intl.get('名称'),
+1 -31
View File
@@ -30,7 +30,6 @@ const dataMap = {
'log-remove-frequency': 'logRemoveFrequency',
'cron-concurrency': 'cronConcurrency',
timezone: 'timezone',
'global-ssh-key': 'globalSshKey',
};
const exportModules = [
@@ -55,7 +54,6 @@ const Other = ({
logRemoveFrequency?: number | null;
cronConcurrency?: number | null;
timezone?: string | null;
globalSshKey?: string | null;
}>();
const [form] = Form.useForm();
const [exportLoading, setExportLoading] = useState(false);
@@ -242,7 +240,6 @@ const Other = ({
addonBefore={intl.get('每')}
addonAfter={intl.get('天')}
style={{ width: 180 }}
placeholder={intl.get('未启用')}
min={0}
value={systemConfig?.logRemoveFrequency}
onChange={(value) => {
@@ -264,9 +261,8 @@ const Other = ({
<Input.Group compact>
<InputNumber
style={{ width: 180 }}
min={4}
min={1}
value={systemConfig?.cronConcurrency}
placeholder={intl.get('默认为 CPU 个数')}
onChange={(value) => {
setSystemConfig({ ...systemConfig, cronConcurrency: value });
}}
@@ -310,32 +306,6 @@ const Other = ({
</Button>
</Input.Group>
</Form.Item>
<Form.Item
label={intl.get('全局SSH私钥')}
name="globalSshKey"
tooltip={intl.get('用于访问所有私有仓库的全局SSH私钥')}
>
<Input.Group compact>
<Input.TextArea
value={systemConfig?.globalSshKey || ''}
style={{ width: 264 }}
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder={intl.get('请输入完整的SSH私钥内容')}
onChange={(e) => {
setSystemConfig({ ...systemConfig, globalSshKey: e.target.value });
}}
/>
</Input.Group>
<Button
type="primary"
onClick={() => {
updateSystemConfig('global-ssh-key');
}}
style={{ width: 264, marginTop: 8 }}
>
{intl.get('确认')}
</Button>
</Form.Item>
<Form.Item label={intl.get('语言')} name="lang">
<Select
defaultValue={localStorage.getItem('lang') || ''}
+4 -4
View File
@@ -12,7 +12,7 @@ import {
} from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import { CronExpressionParser } from 'cron-parser';
import cron_parser from 'cron-parser';
import isNil from 'lodash/isNil';
const { Option } = Select;
@@ -224,8 +224,8 @@ const SubscriptionModal = ({
type === 'raw'
? 'file'
: url.startsWith('http')
? 'public-repo'
: 'private-repo';
? 'public-repo'
: 'private-repo';
form.setFieldsValue({
type: _type,
@@ -381,7 +381,7 @@ const SubscriptionModal = ({
if (
scheduleType === 'interval' ||
!value ||
CronExpressionParser.parse(value).hasNext()
cron_parser.parseExpression(value).hasNext()
) {
return Promise.resolve();
} else {
+6 -6
View File
@@ -1,6 +1,6 @@
import intl from 'react-intl-universal';
import { LANG_MAP, LOG_END_SYMBOL } from './const';
import { CronExpressionParser } from 'cron-parser';
import cron_parser from 'cron-parser';
import { ICrontab } from '@/pages/crontab/type';
export default function browserType() {
@@ -155,9 +155,9 @@ export default function browserType() {
shell === 'none'
? {}
: {
shell, // wechat qq uc 360 2345 sougou liebao maxthon
shellVs,
},
shell, // wechat qq uc 360 2345 sougou liebao maxthon
shellVs,
},
);
console.log(
@@ -333,11 +333,11 @@ export function getCommandScript(
export function parseCrontab(schedule: string): Date | null {
try {
const time = CronExpressionParser.parse(schedule);
const time = cron_parser.parseExpression(schedule);
if (time) {
return time.next().toDate();
}
} catch (error) { }
} catch (error) {}
return null;
}