Compare commits

..

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] d1360342eb Fix: Prevent Python3 dependencies from appearing in NodeJs tab
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-15 10:11:12 +00:00
copilot-swe-agent[bot] 39ef449c10 Initial plan 2025-12-15 10:07:55 +00:00
36 changed files with 379 additions and 1444 deletions
+16 -15
View File
@@ -9,13 +9,15 @@ on:
- "develop"
tags:
- "v*"
schedule:
- cron: "00 20 * * *"
workflow_dispatch:
jobs:
code_gitlab:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: Yikun/hub-mirror-action@master
@@ -30,7 +32,7 @@ jobs:
code_gitee:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: Yikun/hub-mirror-action@master
@@ -45,12 +47,12 @@ jobs:
build-static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
cache: "pnpm"
@@ -76,12 +78,12 @@ jobs:
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
static_gitlab:
needs: build-static
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: Yikun/hub-mirror-action@master
@@ -97,7 +99,7 @@ jobs:
needs: build-static
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: Yikun/hub-mirror-action@master
@@ -110,7 +112,6 @@ jobs:
force_update: true
build:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static
runs-on: ubuntu-22.04
@@ -120,11 +121,11 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
cache: "pnpm"
@@ -208,11 +209,11 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
cache: "pnpm"
-53
View File
@@ -3,7 +3,6 @@ import { Container } from 'typedi';
import { Logger } from 'winston';
import CronService from '../services/cron';
import CronViewService from '../services/cronView';
import CronStatsService from '../services/cronStats';
import { celebrate, Joi } from 'celebrate';
import { commonCronSchema } from '../validation/schedule';
@@ -142,58 +141,6 @@ export default (app: Router) => {
},
);
route.get(
'/stats',
async (req: Request, res: Response, next: NextFunction) => {
try {
const cronStatsService = Container.get(CronStatsService);
const data = await cronStatsService.stats();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/stats/trend',
async (req: Request, res: Response, next: NextFunction) => {
try {
const cronStatsService = Container.get(CronStatsService);
const data = await cronStatsService.trend();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/stats/top-duration',
async (req: Request, res: Response, next: NextFunction) => {
try {
const cronStatsService = Container.get(CronStatsService);
const data = await cronStatsService.topDuration();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get(
'/stats/top-count',
async (req: Request, res: Response, next: NextFunction) => {
try {
const cronStatsService = Container.get(CronStatsService);
const data = await cronStatsService.topCount();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
+1 -1
View File
@@ -16,7 +16,7 @@ export default (app: Router) => {
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}).unknown(true),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
+4 -4
View File
@@ -29,7 +29,7 @@ export default (app: Router) => {
celebrate({
query: Joi.object({
path: Joi.string().optional().allow(''),
}).unknown(true),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
@@ -79,7 +79,7 @@ export default (app: Router) => {
query: Joi.object({
path: Joi.string().optional().allow(''),
file: Joi.string().required(),
}).unknown(true),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
@@ -103,7 +103,7 @@ export default (app: Router) => {
}),
query: Joi.object({
path: Joi.string().optional().allow(''),
}).unknown(true),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
@@ -130,7 +130,7 @@ export default (app: Router) => {
originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''),
}).unknown(true),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
+1 -1
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 { CronExpressionParser } from 'cron-parser';
const route = Router();
export default (app: Router) => {
-14
View File
@@ -64,19 +64,6 @@ if (!process.env.QL_DIR) {
const lastVersionFile = `https://qn.whyour.cn/version.yaml`;
// Get and normalize QlBaseUrl
let baseUrl = process.env.QlBaseUrl || '';
if (baseUrl) {
// Ensure it starts with /
if (!baseUrl.startsWith('/')) {
baseUrl = `/${baseUrl}`;
}
// Remove trailing slash for consistency in route definitions
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
}
const rootPath = process.env.QL_DIR as string;
const envFound = dotenv.config({ path: path.join(rootPath, '.env') });
@@ -129,7 +116,6 @@ if (envFound.error) {
export default {
...config,
jwt: config.jwt,
baseUrl,
rootPath,
tmpPath,
dataPath,
-31
View File
@@ -1,31 +0,0 @@
import { sequelize } from '.';
import { DataTypes, Model } from 'sequelize';
export class CronLog {
id?: number;
cron_id: number;
cron_name: string;
start_time: number;
duration: number;
constructor(options: CronLog) {
this.cron_id = options.cron_id;
this.cron_name = options.cron_name;
this.start_time = options.start_time;
this.duration = options.duration;
}
}
export interface CronLogInstance extends Model<CronLog, CronLog>, CronLog {}
export const CronLogModel = sequelize.define<CronLogInstance>(
'CronLog',
{
cron_id: DataTypes.NUMBER,
cron_name: DataTypes.STRING,
start_time: DataTypes.NUMBER,
duration: DataTypes.NUMBER,
},
{
indexes: [{ fields: ['cron_id'] }, { fields: ['start_time'] }],
},
);
-2
View File
@@ -6,7 +6,6 @@ import { AppModel } from '../data/open';
import { SystemModel } from '../data/system';
import { SubscriptionModel } from '../data/subscription';
import { CrontabViewModel } from '../data/cronView';
import { CronLogModel } from '../data/cronLog';
import { sequelize } from '../data';
export default async () => {
@@ -18,7 +17,6 @@ export default async () => {
await EnvModel.sync();
await SubscriptionModel.sync();
await CrontabViewModel.sync();
await CronLogModel.sync();
// 初始化新增字段
const migrations = [
+5 -41
View File
@@ -13,35 +13,8 @@ import { isValidToken } from '../shared/auth';
import path from 'path';
export default ({ app }: { app: Application }) => {
// Security: Enable strict routing to prevent case-insensitive path bypass
app.set('case sensitive routing', true);
app.set('strict routing', true);
app.set('trust proxy', 'loopback');
app.use(cors());
// Security: Path normalization middleware to prevent case variation attacks
app.use((req, res, next) => {
const originalPath = req.path;
const normalizedPath = originalPath.toLowerCase();
// Block requests with case variations on protected paths
if (originalPath !== normalizedPath &&
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
return res.status(400).json({
code: 400,
message: 'Invalid path format'
});
}
next();
});
// Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness
if (config.baseUrl) {
app.use(rewrite(`${config.baseUrl}/*`, '/$1'));
}
app.get(`${config.api.prefix}/env.js`, serveEnv);
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
@@ -56,7 +29,7 @@ export default ({ app }: { app: Application }) => {
secret: config.jwt.secret,
algorithms: ['HS384'],
}).unless({
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
path: [...config.apiWhiteList, /^\/(?!api\/).*/],
}),
);
@@ -71,20 +44,19 @@ export default ({ app }: { app: Application }) => {
});
app.use(async (req: Request, res, next) => {
const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) {
return next();
}
const headerToken = getToken(req);
if (pathLower.startsWith('/open/')) {
if (req.path.startsWith('/open/')) {
const apps = await shareStore.getApps();
const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken),
)?.[0];
if (doc && doc.tokens && doc.tokens.length > 0) {
const currentToken = doc.tokens.find((x) => x.value === headerToken);
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
const keyMatch = req.path.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1];
if (
doc.scopes.includes(key as any) &&
@@ -119,15 +91,7 @@ export default ({ app }: { app: Application }) => {
});
app.use(async (req, res, next) => {
const pathLower = req.path.toLowerCase();
if (
![
'/api/user/init',
'/api/user/notification/init',
'/open/user/init',
'/open/user/notification/init',
].includes(req.path)
) {
if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) {
return next();
}
const authInfo =
+2 -2
View File
@@ -13,7 +13,7 @@ import { AuthDataType, SystemModel } from '../data/system';
import SystemService from '../services/system';
import UserService from '../services/user';
import { writeFile, readFile } from 'fs/promises';
import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../config/util';
import { createRandomString, fileExist, safeJSONParse } from '../config/util';
import OpenService from '../services/open';
import { shareStore } from '../shared/store';
import Logger from './logger';
@@ -50,7 +50,7 @@ export default async () => {
const [authConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.authConfig },
});
if (!authConfig?.info || isDemoEnv()) {
if (!authConfig?.info) {
let authInfo = {
username: 'admin',
password: 'admin',
+1 -2
View File
@@ -5,10 +5,9 @@ import SockService from '../services/sock';
import { getPlatform } from '../config/util';
import { shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
import config from '../config';
export default async ({ server }: { server: Server }) => {
const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } });
const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} });
const sockService = Container.get(SockService);
echo.on('connection', async (conn) => {
-1
View File
@@ -231,7 +231,6 @@ message NotificationInfo {
optional string webhookContentType = 57;
optional string larkKey = 58;
optional string larkSecret = 69;
optional string ntfyUrl = 59;
optional string ntfyTopic = 60;
+1 -19
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.21.12
// protoc v3.17.3
// source: back/protos/api.proto
/* eslint-disable */
@@ -382,7 +382,6 @@ export interface NotificationInfo {
webhookMethod?: string | undefined;
webhookContentType?: string | undefined;
larkKey?: string | undefined;
larkSecret?: string | undefined;
ntfyUrl?: string | undefined;
ntfyTopic?: string | undefined;
ntfyPriority?: string | undefined;
@@ -2948,7 +2947,6 @@ function createBaseNotificationInfo(): NotificationInfo {
webhookMethod: undefined,
webhookContentType: undefined,
larkKey: undefined,
larkSecret: undefined,
ntfyUrl: undefined,
ntfyTopic: undefined,
ntfyPriority: undefined,
@@ -3138,9 +3136,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.larkKey !== undefined) {
writer.uint32(466).string(message.larkKey);
}
if (message.larkSecret !== undefined) {
writer.uint32(554).string(message.larkSecret);
}
if (message.ntfyUrl !== undefined) {
writer.uint32(474).string(message.ntfyUrl);
}
@@ -3645,14 +3640,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.larkKey = reader.string();
continue;
}
case 69: {
if (tag !== 554) {
break;
}
message.larkSecret = reader.string();
continue;
}
case 59: {
if (tag !== 474) {
break;
@@ -3810,7 +3797,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
webhookMethod: isSet(object.webhookMethod) ? globalThis.String(object.webhookMethod) : undefined,
webhookContentType: isSet(object.webhookContentType) ? globalThis.String(object.webhookContentType) : undefined,
larkKey: isSet(object.larkKey) ? globalThis.String(object.larkKey) : undefined,
larkSecret: isSet(object.larkSecret) ? globalThis.String(object.larkSecret) : undefined,
ntfyUrl: isSet(object.ntfyUrl) ? globalThis.String(object.ntfyUrl) : undefined,
ntfyTopic: isSet(object.ntfyTopic) ? globalThis.String(object.ntfyTopic) : undefined,
ntfyPriority: isSet(object.ntfyPriority) ? globalThis.String(object.ntfyPriority) : undefined,
@@ -4004,9 +3990,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.larkKey !== undefined) {
obj.larkKey = message.larkKey;
}
if (message.larkSecret !== undefined) {
obj.larkSecret = message.larkSecret;
}
if (message.ntfyUrl !== undefined) {
obj.ntfyUrl = message.ntfyUrl;
}
@@ -4103,7 +4086,6 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.webhookMethod = object.webhookMethod ?? undefined;
message.webhookContentType = object.webhookContentType ?? undefined;
message.larkKey = object.larkKey ?? undefined;
message.larkSecret = object.larkSecret ?? undefined;
message.ntfyUrl = object.ntfyUrl ?? undefined;
message.ntfyTopic = object.ntfyTopic ?? undefined;
message.ntfyPriority = object.ntfyPriority ?? undefined;
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.21.12
// protoc v3.17.3
// source: back/protos/cron.proto
/* eslint-disable */
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.21.12
// protoc v3.17.3
// source: back/protos/health.proto
/* eslint-disable */
+4 -20
View File
@@ -2,10 +2,9 @@ import { Service, Inject } from 'typedi';
import winston from 'winston';
import config from '../config';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import { CronLog, CronLogModel } from '../data/cronLog';
import { exec, execSync } from 'child_process';
import fs from 'fs/promises';
import CronExpressionParser from 'cron-parser';
import { CronExpressionParser } from 'cron-parser';
import {
getFileContentByName,
fileExist,
@@ -30,7 +29,7 @@ import { logStreamManager } from '../shared/logStreamManager';
@Service()
export default class CronService {
constructor(@Inject('logger') private logger: winston.Logger) { }
constructor(@Inject('logger') private logger: winston.Logger) {}
private isNodeCron(cron: Crontab) {
const { schedule, extra_schedules } = cron;
@@ -166,7 +165,7 @@ export default class CronService {
let cron;
try {
cron = await this.getDb({ id });
} catch (err) { }
} catch (err) {}
if (!cron) {
continue;
}
@@ -177,18 +176,6 @@ export default class CronService {
{ ...pickBy(options, (v) => v === 0 || !!v) },
{ where: { id } },
);
if (status === CrontabStatus.idle && last_running_time > 0) {
const cronName = (cron.name || cron.command || '').substring(0, 255);
await CronLogModel.create(
new CronLog({
cron_id: id,
cron_name: cronName,
start_time: last_execution_time,
duration: last_running_time,
}),
);
}
}
}
@@ -480,10 +467,7 @@ export default class CronService {
for (const doc of docs) {
// Kill all running instances of this task
try {
if (doc.pid) {
await killTask(doc.pid);
}
const command = doc.command.replace(/\s+/g, ' ').trim();
const command = this.makeCommand(doc);
await killAllTasks(command);
this.logger.info(
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
-137
View File
@@ -1,137 +0,0 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import { CrontabModel } from '../data/cron';
import { CronLog, CronLogModel } from '../data/cronLog';
import { Op } from 'sequelize';
import dayjs from 'dayjs';
type GroupedLog = {
cron_id: number;
cron_name: string;
durations: number[];
};
@Service()
export default class CronStatsService {
constructor(@Inject('logger') private logger: winston.Logger) {}
private groupLogsByCronId(logs: CronLog[]): Record<number, GroupedLog> {
const grouped: Record<number, GroupedLog> = {};
for (const log of logs) {
if (!grouped[log.cron_id]) {
grouped[log.cron_id] = {
cron_id: log.cron_id,
cron_name: log.cron_name,
durations: [],
};
}
grouped[log.cron_id].durations.push(log.duration);
}
return grouped;
}
private avgOf(nums: number[]): number {
if (nums.length === 0) return 0;
return Math.round(nums.reduce((a, b) => a + b, 0) / nums.length);
}
private getTodayRange() {
return {
start: dayjs().startOf('day').unix(),
end: dayjs().endOf('day').unix(),
};
}
public async stats() {
const { start, end } = this.getTodayRange();
const [allCrons, todayLogs] = await Promise.all([
CrontabModel.findAll({ where: {} }),
CronLogModel.findAll({
where: { start_time: { [Op.between]: [start, end] } },
}),
]);
const total = allCrons.length;
const enabled = allCrons.filter((c: any) => c.isDisabled !== 1).length;
const disabled = allCrons.filter((c: any) => c.isDisabled === 1).length;
const todayCount = todayLogs.length;
const todayTotalDuration = todayLogs.reduce(
(sum: number, l: any) => sum + (l.duration || 0),
0,
);
const todayAvgDuration =
todayCount > 0 ? Math.round(todayTotalDuration / todayCount) : 0;
return {
total,
enabled,
disabled,
today: {
count: todayCount,
avgDuration: todayAvgDuration,
},
};
}
public async trend() {
const days = 7;
const result: Array<{ date: string; count: number }> = [];
for (let i = days - 1; i >= 0; i--) {
const dayStart = dayjs().subtract(i, 'day').startOf('day').unix();
const dayEnd = dayjs().subtract(i, 'day').endOf('day').unix();
const date = dayjs().subtract(i, 'day').format('MM-DD');
const logs = await CronLogModel.findAll({
where: { start_time: { [Op.between]: [dayStart, dayEnd] } },
});
result.push({ date, count: logs.length });
}
return result;
}
public async topDuration(limit = 5) {
const { start, end } = this.getTodayRange();
const logs = await CronLogModel.findAll({
where: { start_time: { [Op.between]: [start, end] } },
});
const grouped = this.groupLogsByCronId(logs as any);
return Object.values(grouped)
.map((g) => ({
cron_id: g.cron_id,
cron_name: g.cron_name,
count: g.durations.length,
avgDuration: this.avgOf(g.durations),
maxDuration: Math.max(...g.durations),
}))
.sort((a, b) => b.avgDuration - a.avgDuration)
.slice(0, limit);
}
public async topCount(limit = 5) {
const { start, end } = this.getTodayRange();
const logs = await CronLogModel.findAll({
where: { start_time: { [Op.between]: [start, end] } },
});
const grouped = this.groupLogsByCronId(logs as any);
return Object.values(grouped)
.map((g) => ({
cron_id: g.cron_id,
cron_name: g.cron_name,
count: g.durations.length,
avgDuration: this.avgOf(g.durations),
}))
.sort((a, b) => b.count - a.count)
.slice(0, limit);
}
}
+2 -3
View File
@@ -13,11 +13,10 @@ import {
stepPosition,
} from '../data/env';
import { writeFileWithLock } from '../shared/utils';
import { sequelize } from '../data';
@Service()
export default class EnvService {
constructor(@Inject('logger') private logger: winston.Logger) { }
constructor(@Inject('logger') private logger: winston.Logger) {}
public async create(payloads: Env[]): Promise<Env[]> {
const envs = await this.envs();
@@ -147,7 +146,7 @@ export default class EnvService {
}
try {
const result = await this.find(condition, [
[sequelize.literal('COALESCE(`isPinned`, 0)'), 'DESC'],
['isPinned', 'DESC'],
['position', 'DESC'],
['createdAt', 'ASC'],
]);
+1 -1
View File
@@ -1,5 +1,5 @@
import { Joi } from 'celebrate';
import CronExpressionParser from 'cron-parser';
import { CronExpressionParser } from 'cron-parser';
import { ScheduleType } from '../interface/schedule';
import path from 'path';
import config from '../config';
+3 -4
View File
@@ -69,10 +69,9 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \
HOME=/root
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
@@ -84,6 +83,6 @@ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:${QlPort:-5700}/api/health || exit 1
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+3 -4
View File
@@ -69,10 +69,9 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \
HOME=/root
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
@@ -84,6 +83,6 @@ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:${QlPort:-5700}/api/health || exit 1
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+2
View File
@@ -1,5 +1,7 @@
#!/bin/bash
export PATH="$HOME/bin:$PATH"
dir_shell=/ql/shell
. $dir_shell/share.sh
+2 -2
View File
@@ -77,9 +77,9 @@
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"multer": "2.1.1",
"multer": "1.4.5-lts.1",
"node-schedule": "^2.1.0",
"nodemailer": "^8.0.1",
"nodemailer": "^6.9.16",
"p-queue-cjs": "7.3.4",
"@bufbuild/protobuf": "^2.10.0",
"ps-tree": "^1.2.0",
+259 -568
View File
File diff suppressed because it is too large Load Diff
+4 -20
View File
@@ -482,13 +482,9 @@ function tgBotNotify(text, desp) {
timeout,
};
if (TG_PROXY_HOST && TG_PROXY_PORT) {
let proxyHost = TG_PROXY_HOST;
if (TG_PROXY_AUTH && !TG_PROXY_HOST.includes('@')) {
proxyHost = `${TG_PROXY_AUTH}@${TG_PROXY_HOST}`;
}
let agent;
agent = new ProxyAgent({
uri: `http://${proxyHost}:${TG_PROXY_PORT}`,
uri: `http://${TG_PROXY_AUTH}${TG_PROXY_HOST}:${TG_PROXY_PORT}`,
});
options.dispatcher = agent;
}
@@ -996,10 +992,7 @@ function fsBotNotify(text, desp) {
return new Promise((resolve) => {
const { FSKEY, FSSECRET } = push_config;
if (FSKEY) {
const body = {
msg_type: 'text',
content: { text: `${text}\n\n${desp}` },
};
const body = { msg_type: 'text', content: { text: `${text}\n\n${desp}` } };
// Add signature if secret is provided
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
@@ -1285,15 +1278,7 @@ function ntfyNotify(text, desp) {
}
return new Promise((resolve) => {
const {
NTFY_URL,
NTFY_TOPIC,
NTFY_PRIORITY,
NTFY_TOKEN,
NTFY_USERNAME,
NTFY_PASSWORD,
NTFY_ACTIONS,
} = push_config;
const { NTFY_URL, NTFY_TOPIC, NTFY_PRIORITY, NTFY_TOKEN, NTFY_USERNAME, NTFY_PASSWORD, NTFY_ACTIONS } = push_config;
if (NTFY_TOPIC) {
const options = {
url: `${NTFY_URL || 'https://ntfy.sh'}/${NTFY_TOPIC}`,
@@ -1308,8 +1293,7 @@ function ntfyNotify(text, desp) {
if (NTFY_TOKEN) {
options.headers['Authorization'] = `Bearer ${NTFY_TOKEN}`;
} else if (NTFY_USERNAME && NTFY_PASSWORD) {
options.headers['Authorization'] =
`Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`;
options.headers['Authorization'] = `Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`;
}
if (NTFY_ACTIONS) {
options.headers['Actions'] = encodeRFC2047(NTFY_ACTIONS);
+1 -7
View File
@@ -1,5 +1,5 @@
import intl from 'react-intl-universal';
import { BarChartOutlined, SettingOutlined } from '@ant-design/icons';
import { SettingOutlined } from '@ant-design/icons';
import IconFont from '@/components/iconfont';
import { BasicLayoutProps } from '@ant-design/pro-layout';
@@ -30,12 +30,6 @@ export default {
icon: <IconFont type="ql-icon-crontab" />,
component: '@/pages/crontab/index',
},
{
path: '/statistics',
name: intl.get('统计面板'),
icon: <BarChartOutlined />,
component: '@/pages/statistics/index',
},
{
path: '/subscription',
name: intl.get('订阅管理'),
-19
View File
@@ -18,25 +18,6 @@
"青龙": "Qinglong",
"返回首页": "Return to Home",
"保存": "Save",
"统计面板": "Statistics",
"总体概览": "Overview",
"总任务数量": "Total Tasks",
"启用任务数": "Enabled Tasks",
"禁用任务数": "Disabled Tasks",
"今日总执行次数": "Today's Executions",
"今日平均耗时(秒)": "Today's Avg Duration (s)",
"近7日执行趋势": "7-Day Execution Trend",
"今日平均耗时 Top 5": "Top 5 Slowest Today",
"今日执行次数 Top 5": "Top 5 Most Frequent Today",
"排名": "Rank",
"任务名称": "Task Name",
"平均耗时(秒)": "Avg Duration (s)",
"最长单次(秒)": "Max Duration (s)",
"今日执行次数": "Today's Count",
"今日暂无执行记录": "No execution records today",
"暂无数据": "No data",
"次": "times",
"刷新": "Refresh",
"日志": "Log",
"脚本": "Script",
"确认保存文件": "Confirm to Save File",
-19
View File
@@ -18,25 +18,6 @@
"青龙": "青龙",
"返回首页": "返回首页",
"保存": "保存",
"统计面板": "统计面板",
"总体概览": "总体概览",
"总任务数量": "总任务数量",
"启用任务数": "启用任务数",
"禁用任务数": "禁用任务数",
"今日总执行次数": "今日总执行次数",
"今日平均耗时(秒)": "今日平均耗时(秒)",
"近7日执行趋势": "近7日执行趋势",
"今日平均耗时 Top 5": "今日平均耗时 Top 5",
"今日执行次数 Top 5": "今日执行次数 Top 5",
"排名": "排名",
"任务名称": "任务名称",
"平均耗时(秒)": "平均耗时(秒)",
"最长单次(秒)": "最长单次(秒)",
"今日执行次数": "今日执行次数",
"今日暂无执行记录": "今日暂无执行记录",
"暂无数据": "暂无数据",
"次": "次",
"刷新": "刷新",
"日志": "日志",
"脚本": "脚本",
"确认保存文件": "确认保存文件",
+4 -8
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 { CronExpressionParser } from 'cron-parser';
import { useEffect, useState } from 'react';
import intl from 'react-intl-universal';
import { getScheduleType, scheduleTypeMap } from './const';
@@ -91,14 +91,10 @@ const CronModal = ({
{ required: true },
{
validator: (_, value) => {
try {
if (!value || CronExpressionParser.parse(value).hasNext()) {
return Promise.resolve();
}
return Promise.reject(intl.get('Cron表达式格式有误'));
} catch (e) {
return Promise.reject(intl.get('Cron表达式格式有误'));
if (!value || CronExpressionParser.parse(value).hasNext()) {
return Promise.resolve();
}
return Promise.reject(intl.get('Cron表达式格式有误'));
},
},
]}
-17
View File
@@ -1,17 +0,0 @@
.stats-section {
margin-bottom: 16px;
}
.trend-chart-wrapper {
width: 100%;
overflow: hidden;
}
.trend-chart-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: #999;
font-size: 14px;
}
-402
View File
@@ -1,402 +0,0 @@
import { SharedContext } from '@/layouts';
import config from '@/utils/config';
import { request } from '@/utils/http';
import { BarChartOutlined, ReloadOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout';
import { useOutletContext } from '@umijs/max';
import {
Button,
Card,
Col,
Row,
Statistic,
Table,
Tooltip,
Typography,
} from 'antd';
import { ColumnProps } from 'antd/lib/table';
import React, { useEffect, useState } from 'react';
import intl from 'react-intl-universal';
import './index.less';
const { Title } = Typography;
interface StatsData {
total: number;
enabled: number;
disabled: number;
today: {
count: number;
avgDuration: number;
};
}
interface TrendItem {
date: string;
count: number;
}
interface TopDurationItem {
cron_id: number;
cron_name: string;
count: number;
avgDuration: number;
maxDuration: number;
}
interface TopCountItem {
cron_id: number;
cron_name: string;
count: number;
avgDuration: number;
}
const TrendChart = ({ data }: { data: TrendItem[] }) => {
if (!data || data.length === 0) {
return (
<div className="trend-chart-empty">
{intl.get('暂无数据')}
</div>
);
}
const width = 600;
const height = 200;
const paddingLeft = 40;
const paddingRight = 20;
const paddingTop = 20;
const paddingBottom = 40;
const chartWidth = width - paddingLeft - paddingRight;
const chartHeight = height - paddingTop - paddingBottom;
const maxCount = Math.max(...data.map((d) => d.count), 1);
const points = data.map((d, i) => ({
x: paddingLeft + (i / Math.max(data.length - 1, 1)) * chartWidth,
y: paddingTop + chartHeight - (d.count / maxCount) * chartHeight,
...d,
}));
const pathD = points
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
.join(' ');
const areaD =
pathD +
` L ${points[points.length - 1].x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)}` +
` L ${points[0].x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)} Z`;
const yTicks = [0, Math.ceil(maxCount / 2), maxCount];
return (
<div className="trend-chart-wrapper">
<svg
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="xMidYMid meet"
style={{ width: '100%', height: 200 }}
>
{/* Grid lines */}
{yTicks.map((tick) => {
const y =
paddingTop + chartHeight - (tick / maxCount) * chartHeight;
return (
<g key={tick}>
<line
x1={paddingLeft}
y1={y}
x2={paddingLeft + chartWidth}
y2={y}
stroke="#f0f0f0"
strokeWidth={1}
/>
<text
x={paddingLeft - 6}
y={y + 4}
textAnchor="end"
fontSize={10}
fill="#999"
>
{tick}
</text>
</g>
);
})}
{/* Area fill */}
<path d={areaD} fill="rgba(24, 144, 255, 0.1)" />
{/* Line */}
<path
d={pathD}
fill="none"
stroke="#1890ff"
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* Points */}
{points.map((p, i) => (
<Tooltip
key={i}
title={`${p.date}: ${p.count} ${intl.get('次')}`}
>
<circle
cx={p.x}
cy={p.y}
r={4}
fill="#1890ff"
stroke="#fff"
strokeWidth={2}
style={{ cursor: 'pointer' }}
/>
</Tooltip>
))}
{/* X axis labels */}
{points.map((p, i) => (
<text
key={i}
x={p.x}
y={height - 8}
textAnchor="middle"
fontSize={10}
fill="#999"
>
{p.date}
</text>
))}
{/* Axes */}
<line
x1={paddingLeft}
y1={paddingTop}
x2={paddingLeft}
y2={paddingTop + chartHeight}
stroke="#e8e8e8"
strokeWidth={1}
/>
<line
x1={paddingLeft}
y1={paddingTop + chartHeight}
x2={paddingLeft + chartWidth}
y2={paddingTop + chartHeight}
stroke="#e8e8e8"
strokeWidth={1}
/>
</svg>
</div>
);
};
const Statistics = () => {
const { headerStyle, isPhone } = useOutletContext<SharedContext>();
const [stats, setStats] = useState<StatsData | null>(null);
const [trend, setTrend] = useState<TrendItem[]>([]);
const [topDuration, setTopDuration] = useState<TopDurationItem[]>([]);
const [topCount, setTopCount] = useState<TopCountItem[]>([]);
const [loading, setLoading] = useState(true);
const loadAll = async () => {
setLoading(true);
try {
const [
statsRes,
trendRes,
topDurationRes,
topCountRes,
] = await Promise.all([
request.get(`${config.apiPrefix}crons/stats`),
request.get(`${config.apiPrefix}crons/stats/trend`),
request.get(`${config.apiPrefix}crons/stats/top-duration`),
request.get(`${config.apiPrefix}crons/stats/top-count`),
]);
if (statsRes.code === 200) setStats(statsRes.data);
if (trendRes.code === 200) setTrend(trendRes.data);
if (topDurationRes.code === 200) setTopDuration(topDurationRes.data);
if (topCountRes.code === 200) setTopCount(topCountRes.data);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadAll();
}, []);
const topDurationColumns: ColumnProps<TopDurationItem>[] = [
{
title: intl.get('排名'),
key: 'rank',
width: 60,
render: (_: any, __: any, index: number) => index + 1,
},
{
title: intl.get('任务名称'),
dataIndex: 'cron_name',
key: 'cron_name',
ellipsis: true,
},
{
title: intl.get('平均耗时(秒)'),
dataIndex: 'avgDuration',
key: 'avgDuration',
width: 120,
render: (v: number) => `${v}s`,
},
{
title: intl.get('最长单次(秒)'),
dataIndex: 'maxDuration',
key: 'maxDuration',
width: 120,
render: (v: number) => `${v}s`,
},
];
const topCountColumns: ColumnProps<TopCountItem>[] = [
{
title: intl.get('排名'),
key: 'rank',
width: 60,
render: (_: any, __: any, index: number) => index + 1,
},
{
title: intl.get('任务名称'),
dataIndex: 'cron_name',
key: 'cron_name',
ellipsis: true,
},
{
title: intl.get('今日执行次数'),
dataIndex: 'count',
key: 'count',
width: 120,
},
{
title: intl.get('平均耗时(秒)'),
dataIndex: 'avgDuration',
key: 'avgDuration',
width: 120,
render: (v: number) => `${v}s`,
},
];
return (
<PageContainer
header={{
style: headerStyle,
}}
title={
<span>
<BarChartOutlined style={{ marginRight: 8 }} />
{intl.get('统计面板')}
</span>
}
extra={[
<Button
key="refresh"
icon={<ReloadOutlined />}
loading={loading}
onClick={loadAll}
>
{intl.get('刷新')}
</Button>,
]}
>
{/* Section 1: Overview Cards */}
<Card
className="stats-section"
title={intl.get('总体概览')}
loading={loading}
>
<Row gutter={[16, 16]}>
<Col xs={12} sm={8} md={6} lg={4}>
<Statistic
title={intl.get('总任务数量')}
value={stats?.total ?? '-'}
/>
</Col>
<Col xs={12} sm={8} md={6} lg={4}>
<Statistic
title={intl.get('启用任务数')}
value={stats?.enabled ?? '-'}
valueStyle={{ color: '#52c41a' }}
/>
</Col>
<Col xs={12} sm={8} md={6} lg={4}>
<Statistic
title={intl.get('禁用任务数')}
value={stats?.disabled ?? '-'}
valueStyle={{ color: '#d9d9d9' }}
/>
</Col>
<Col xs={12} sm={8} md={6} lg={4}>
<Statistic
title={intl.get('今日总执行次数')}
value={stats?.today?.count ?? '-'}
valueStyle={{ color: '#1890ff' }}
/>
</Col>
<Col xs={12} sm={8} md={6} lg={4}>
<Statistic
title={intl.get('今日平均耗时(秒)')}
value={stats?.today?.avgDuration ?? '-'}
suffix="s"
valueStyle={{ color: '#faad14' }}
/>
</Col>
</Row>
</Card>
{/* Section 2: 7-day Trend */}
<Card
className="stats-section"
title={intl.get('近7日执行趋势')}
loading={loading}
>
<TrendChart data={trend} />
</Card>
{/* Section 3 & 4: Top Tables */}
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card
className="stats-section"
title={intl.get('今日平均耗时 Top 5')}
loading={loading}
>
<Table
dataSource={topDuration}
columns={topDurationColumns}
rowKey="cron_id"
pagination={false}
size="small"
locale={{ emptyText: intl.get('今日暂无执行记录') }}
/>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
className="stats-section"
title={intl.get('今日执行次数 Top 5')}
loading={loading}
>
<Table
dataSource={topCount}
columns={topCountColumns}
rowKey="cron_id"
pagination={false}
size="small"
locale={{ emptyText: intl.get('今日暂无执行记录') }}
/>
</Card>
</Col>
</Row>
</PageContainer>
);
};
export default Statistics;
+8 -12
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 { CronExpressionParser } from 'cron-parser';
import isNil from 'lodash/isNil';
const { Option } = Select;
@@ -378,17 +378,13 @@ const SubscriptionModal = ({
{ required: true },
{
validator: (rule, value) => {
try {
if (
scheduleType === 'interval' ||
!value ||
CronExpressionParser.parse(value).hasNext()
) {
return Promise.resolve();
} else {
return Promise.reject(intl.get('Subscription表达式格式有误'));
}
} catch (e) {
if (
scheduleType === 'interval' ||
!value ||
CronExpressionParser.parse(value).hasNext()
) {
return Promise.resolve();
} else {
return Promise.reject(intl.get('Subscription表达式格式有误'));
}
},
-1
View File
@@ -504,7 +504,6 @@ export default {
'/login': intl.get('登录'),
'/initialization': intl.get('初始化'),
'/crontab': intl.get('定时任务'),
'/statistics': intl.get('统计面板'),
'/env': intl.get('环境变量'),
'/subscription': intl.get('订阅管理'),
'/config': intl.get('配置文件'),
+6 -6
View File
@@ -84,12 +84,12 @@ let _request = axios.create({
});
const apiWhiteList = [
`${config.baseUrl}api/user/login`,
`${config.baseUrl}open/auth/token`,
`${config.baseUrl}api/user/two-factor/login`,
`${config.baseUrl}api/system`,
`${config.baseUrl}api/user/init`,
`${config.baseUrl}api/user/notification/init`,
'/api/user/login',
'/open/auth/token',
'/api/user/two-factor/login',
'/api/system',
'/api/user/init',
'/api/user/notification/init',
];
_request.interceptors.request.use((_config) => {
+1 -1
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 { CronExpressionParser } from 'cron-parser';
import { ICrontab } from '@/pages/crontab/type';
export default function browserType() {
+46 -5
View File
@@ -1,6 +1,47 @@
version: 2.20.2
changeLogLink: https://t.me/jiao_long/434
publishTime: 2026-03-01 1800
version: 2.20.0
changeLogLink: https://t.me/jiao_long/432
publishTime: 2025-12-10 01:05
changeLog: |
1. 修复 path 安全漏洞(重要)
1. 定时任务(cron / task)相关的大量修复 & 增强
修复 cron 解析错误(修复 parse cron / 升级 cron-parser
修复集群模式下定时任务可能不执行(race condition
定时任务支持订阅筛选
定时任务支持排序调整
定时任务支持自定义日志文件或无日志
修复任务实例默认值
任务支持单实例 / 多实例模式
修复 task 命令软链可能失败问题
2. 日志系统相关的大更新
修复日志目录逻辑
修复 pm2 日志目录
优化日志写入(stream pooling
3. 环境变量(env)系统的改进与修复
修复环境变量复制到剪贴板时可能失败
添加环境变量“置顶”功能
修复 QlPort 与 QlGrpcPort 环境变量在 host network 模式下被忽略
增加全局 SSH 私钥配置
4. Docker / 非 root 用户 / Alpine 兼容性增强
新增非 root Docker 用户支持,自动初始化命令
修复 Alpine 容器 DNS 解析失败(设置 ndots:0
修复 PM2 在 ARM 路由器(Node.js 不兼容)上的启动失败
移除 nginx(可能是考虑更轻量的镜像运行)
5. API 安全与校验增强
Dependencies GET endpoint 增加校验
Script API routes 增加输入校验
修复 JWT 认证问题
Feishu 机器人通知增加签名校验
QLAPI 增加 cron task 管理功能
修复 URIError(错误 cookie 导致白屏)
6. 系统设置
新增多终端/多平台的并发登录会话支持