Compare commits

..

23 Commits

Author SHA1 Message Date
whyour 01b404765e 更新版本 v2.15.13 2023-05-04 17:13:18 +08:00
whyour c231a871d7 修复 check 异常 2023-05-04 17:13:04 +08:00
whyour 598c6b4e57 修复 task 打印日志逻辑 2023-05-04 15:17:38 +08:00
whyour 2295fabcc5 修复 task 命令日志目录判断 2023-05-04 13:11:18 +08:00
whyour be94d796ac 增加 procps 依赖 2023-05-04 13:05:48 +08:00
whyour c84908d7fa 修复运行命令api任务日志路径 2023-05-04 10:25:04 +08:00
whyour d8ae039b92 修复内容区头部样式 2023-05-03 21:23:13 +08:00
whyour bce1431d03 修复错误界面重试次数判断 2023-05-03 19:59:06 +08:00
whyour 9db0095e29 修复系统通知错误提示,gotifyPriority 配置参数 2023-05-03 18:18:28 +08:00
whyour 77dc7817fb 修复 pnpm 8.4.0 重新安装依赖会默认进行询问 2023-05-03 01:58:41 +08:00
whyour d0dd97631e 修复 task 命令任务 ID 查询参数 2023-05-03 01:27:14 +08:00
whyour d11d6d0c18 增加容器健康检查 2023-05-02 22:11:50 +08:00
whyour 0af687f781 增加停止指定命令接口 2023-05-01 22:56:47 +08:00
whyour 8db997abe8 修改 check 逻辑 2023-05-01 16:16:47 +08:00
whyour aab6bbeb15 增加运行指定命令接口 2023-05-01 14:28:58 +08:00
whyour df1addc1ff 修改 task 命令超时时间判断 2023-05-01 11:05:36 +08:00
whyour 1f8f35476a 移除任务默认超时时间 2023-04-30 20:02:14 +08:00
whyour 5354fc76db 修复 task 命令任务ID查询,系统依赖安装逻辑 2023-04-30 19:35:14 +08:00
whyour b29a9e012c 修复停止任务无效 2023-04-29 22:49:50 +08:00
whyour 6e5d89c197 修改 task 命令生成日志逻辑和关联任务查询 2023-04-28 23:15:07 +08:00
whyour d6cfb18f06 修改仓库拉取状态判断逻辑 2023-04-24 15:37:58 +08:00
whyour 9a3e38051d 克隆仓库增加 depth 2023-04-24 14:47:17 +08:00
whyour b705ad6ee8 修复更新任务、环境变量、依赖、订阅状态丢失 2023-04-21 21:15:28 +08:00
37 changed files with 1084 additions and 439 deletions
+1
View File
@@ -15,6 +15,7 @@ export default defineConfig({
'/api/public': {
target: 'http://127.0.0.1:5400/',
changeOrigin: true,
pathRewrite: { '^/api/public': '/api/' },
},
'/api': {
target: 'http://127.0.0.1:5600/',
+62 -1
View File
@@ -7,7 +7,12 @@ import SystemService from '../services/system';
import { celebrate, Joi } from 'celebrate';
import UserService from '../services/user';
import { EnvModel } from '../data/env';
import { parseVersion, promiseExec } from '../config/util';
import {
getUniqPath,
handleLogPath,
parseVersion,
promiseExec,
} from '../config/util';
import dayjs from 'dayjs';
const route = Router();
@@ -147,4 +152,60 @@ export default (app: Router) => {
}
},
);
route.put(
'/command-run',
celebrate({
body: Joi.object({
command: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const uniqPath = await getUniqPath(req.body.command);
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logPath = `${uniqPath}/${logTime}.log`;
res.setHeader('Content-type', 'application/octet-stream');
await systemService.run(
{ ...req.body, logPath },
{
onEnd: async (cp, endTime, diff) => {
res.end();
},
onError: async (message: string) => {
res.write(`\n${message}`);
const absolutePath = await handleLogPath(logPath);
fs.appendFileSync(absolutePath, `\n${message}`);
},
onLog: async (message: string) => {
res.write(`\n${message}`);
const absolutePath = await handleLogPath(logPath);
fs.appendFileSync(absolutePath, `\n${message}`);
},
},
);
} catch (e) {
return next(e);
}
},
);
route.put(
'/command-stop',
celebrate({
body: Joi.object({
command: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.stop(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
};
+61 -7
View File
@@ -7,6 +7,8 @@ import FormData from 'form-data';
import psTreeFun from 'pstree.remy';
import { promisify } from 'util';
import { load } from 'js-yaml';
import config from './index';
import { TASK_COMMAND } from './const';
export function getFileContentByName(fileName: string) {
if (fs.existsSync(fileName)) {
@@ -245,6 +247,18 @@ export async function createFile(file: string, data: string = '') {
});
}
export async function handleLogPath(
logPath: string,
data: string = '',
): Promise<string> {
const absolutePath = path.resolve(config.logPath, logPath);
const logFileExist = await fileExist(absolutePath);
if (!logFileExist) {
await createFile(absolutePath, data);
}
return absolutePath;
}
export async function concurrentRun(
fnList: Array<() => Promise<any>> = [],
max = 5,
@@ -469,19 +483,22 @@ export function psTree(pid: number): Promise<number[]> {
export async function killTask(pid: number) {
const pids = await psTree(pid);
// SIGALRM 14 时钟信号
// SIGINT 2 程序终止(interrupt)信号,不会打印额外信息
if (pids.length) {
process.kill(pids[0], 14);
try {
[pid, ...pids].forEach((x) => {
process.kill(x, 2);
});
} catch (error) {}
} else {
process.kill(pid, 14);
process.kill(pid, 2);
}
}
export async function getPid(name: string) {
let taskCommand = `ps -ef | grep "${name}" | grep -v grep | awk '{print $1}'`;
const execAsync = promisify(exec);
let pid = (await execAsync(taskCommand)).stdout;
return Number(pid);
const taskCommand = `ps -eo pid,command | grep "${name}" | grep -v grep | awk '{print $1}' | head -1 | xargs echo -n`;
const pid = await promiseExec(taskCommand);
return pid ? Number(pid) : undefined;
}
interface IVersion {
@@ -497,3 +514,40 @@ export async function parseVersion(path: string): Promise<IVersion> {
export async function parseContentVersion(content: string): Promise<IVersion> {
return load(content) as IVersion;
}
export async function getUniqPath(command: string): Promise<string> {
const idStr = `cat ${config.crontabFile} | grep -E "${command}" | perl -pe "s|.*ID=(.*) ${command}.*|\\1|" | head -1 | awk -F " " '{print $1}' | xargs echo -n`;
let id = await promiseExec(idStr);
if (/^\d\d*\d$/.test(id)) {
id = `_${id}`;
} else {
id = '';
}
const items = command.split(/ +/);
let str = items[0];
if (items[0] === TASK_COMMAND) {
str = items[1];
}
const dotIndex = str.lastIndexOf('.');
if (dotIndex !== -1) {
str = str.slice(0, dotIndex);
}
const slashIndex = str.lastIndexOf('/');
let tempStr = '';
if (slashIndex !== -1) {
tempStr = str.slice(0, slashIndex);
const _slashIndex = tempStr.lastIndexOf('/');
if (_slashIndex !== -1) {
tempStr = tempStr.slice(_slashIndex + 1);
}
str = `${tempStr}_${str.slice(slashIndex + 1)}`;
}
return `${str}${id}`;
}
+1 -1
View File
@@ -26,7 +26,7 @@ export class Crontab {
this.saved = options.saved;
this.id = options.id;
this.status =
options.status && CrontabStatus[options.status]
typeof options.status === 'number' && CrontabStatus[options.status]
? options.status
: CrontabStatus.idle;
this.timestamp = new Date().toString();
+4 -1
View File
@@ -12,7 +12,10 @@ export class Dependence {
constructor(options: Dependence) {
this.id = options.id;
this.status = options.status || DependenceStatus.installing;
this.status =
typeof options.status === 'number' && DependenceStatus[options.status]
? options.status
: DependenceStatus.installing;
this.type = options.type || DependenceTypes.nodejs;
this.timestamp = new Date().toString();
this.name = options.name;
+4 -1
View File
@@ -13,7 +13,10 @@ export class Env {
constructor(options: Env) {
this.value = options.value;
this.id = options.id;
this.status = options.status || EnvStatus.normal;
this.status =
typeof options.status === 'number' && EnvStatus[options.status]
? options.status
: EnvStatus.normal;
this.timestamp = new Date().toString();
this.position = options.position;
this.name = options.name;
+2 -2
View File
@@ -37,8 +37,8 @@ export class Subscription {
this.name = options.name || options.alias;
this.type = options.type;
this.schedule = options.schedule;
this.status =
options.status && SubscriptionStatus[options.status]
this.status = this.status =
typeof options.status === 'number' && SubscriptionStatus[options.status]
? options.status
: SubscriptionStatus.idle;
this.url = options.url;
+4 -4
View File
@@ -2,21 +2,21 @@ syntax = "proto3";
package com.ql.cron;
service CronService {
service Cron {
rpc addCron(AddCronRequest) returns (AddCronResponse);
rpc delCron(DeleteCronRequest) returns (DeleteCronResponse);
}
message Cron {
message ICron {
string id = 1;
string schedule = 2;
string command = 3;
}
message AddCronRequest { repeated Cron crons = 1; }
message AddCronRequest { repeated ICron crons = 1; }
message AddCronResponse {}
message DeleteCronRequest { repeated string ids = 1; }
message DeleteCronResponse {}
message DeleteCronResponse {}
+29 -29
View File
@@ -15,14 +15,14 @@ import _m0 from 'protobufjs/minimal';
export const protobufPackage = 'com.ql.cron';
export interface Cron {
export interface ICron {
id: string;
schedule: string;
command: string;
}
export interface AddCronRequest {
crons: Cron[];
crons: ICron[];
}
export interface AddCronResponse {}
@@ -33,12 +33,12 @@ export interface DeleteCronRequest {
export interface DeleteCronResponse {}
function createBaseCron(): Cron {
function createBaseICron(): ICron {
return { id: '', schedule: '', command: '' };
}
export const Cron = {
encode(message: Cron, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
export const ICron = {
encode(message: ICron, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
if (message.id !== '') {
writer.uint32(10).string(message.id);
}
@@ -51,11 +51,11 @@ export const Cron = {
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): Cron {
decode(input: _m0.Reader | Uint8Array, length?: number): ICron {
const reader =
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseCron();
const message = createBaseICron();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
@@ -89,7 +89,7 @@ export const Cron = {
return message;
},
fromJSON(object: any): Cron {
fromJSON(object: any): ICron {
return {
id: isSet(object.id) ? String(object.id) : '',
schedule: isSet(object.schedule) ? String(object.schedule) : '',
@@ -97,7 +97,7 @@ export const Cron = {
};
},
toJSON(message: Cron): unknown {
toJSON(message: ICron): unknown {
const obj: any = {};
message.id !== undefined && (obj.id = message.id);
message.schedule !== undefined && (obj.schedule = message.schedule);
@@ -105,12 +105,12 @@ export const Cron = {
return obj;
},
create<I extends Exact<DeepPartial<Cron>, I>>(base?: I): Cron {
return Cron.fromPartial(base ?? {});
create<I extends Exact<DeepPartial<ICron>, I>>(base?: I): ICron {
return ICron.fromPartial(base ?? {});
},
fromPartial<I extends Exact<DeepPartial<Cron>, I>>(object: I): Cron {
const message = createBaseCron();
fromPartial<I extends Exact<DeepPartial<ICron>, I>>(object: I): ICron {
const message = createBaseICron();
message.id = object.id ?? '';
message.schedule = object.schedule ?? '';
message.command = object.command ?? '';
@@ -128,7 +128,7 @@ export const AddCronRequest = {
writer: _m0.Writer = _m0.Writer.create(),
): _m0.Writer {
for (const v of message.crons) {
Cron.encode(v!, writer.uint32(10).fork()).ldelim();
ICron.encode(v!, writer.uint32(10).fork()).ldelim();
}
return writer;
},
@@ -146,7 +146,7 @@ export const AddCronRequest = {
break;
}
message.crons.push(Cron.decode(reader, reader.uint32()));
message.crons.push(ICron.decode(reader, reader.uint32()));
continue;
}
if ((tag & 7) == 4 || tag == 0) {
@@ -160,7 +160,7 @@ export const AddCronRequest = {
fromJSON(object: any): AddCronRequest {
return {
crons: Array.isArray(object?.crons)
? object.crons.map((e: any) => Cron.fromJSON(e))
? object.crons.map((e: any) => ICron.fromJSON(e))
: [],
};
},
@@ -168,7 +168,7 @@ export const AddCronRequest = {
toJSON(message: AddCronRequest): unknown {
const obj: any = {};
if (message.crons) {
obj.crons = message.crons.map((e) => (e ? Cron.toJSON(e) : undefined));
obj.crons = message.crons.map((e) => (e ? ICron.toJSON(e) : undefined));
} else {
obj.crons = [];
}
@@ -185,7 +185,7 @@ export const AddCronRequest = {
object: I,
): AddCronRequest {
const message = createBaseAddCronRequest();
message.crons = object.crons?.map((e) => Cron.fromPartial(e)) || [];
message.crons = object.crons?.map((e) => ICron.fromPartial(e)) || [];
return message;
},
};
@@ -366,10 +366,10 @@ export const DeleteCronResponse = {
},
};
export type CronServiceService = typeof CronServiceService;
export const CronServiceService = {
export type CronService = typeof CronService;
export const CronService = {
addCron: {
path: '/com.ql.cron.CronService/addCron',
path: '/com.ql.cron.Cron/addCron',
requestStream: false,
responseStream: false,
requestSerialize: (value: AddCronRequest) =>
@@ -380,7 +380,7 @@ export const CronServiceService = {
responseDeserialize: (value: Buffer) => AddCronResponse.decode(value),
},
delCron: {
path: '/com.ql.cron.CronService/delCron',
path: '/com.ql.cron.Cron/delCron',
requestStream: false,
responseStream: false,
requestSerialize: (value: DeleteCronRequest) =>
@@ -392,12 +392,12 @@ export const CronServiceService = {
},
} as const;
export interface CronServiceServer extends UntypedServiceImplementation {
export interface CronServer extends UntypedServiceImplementation {
addCron: handleUnaryCall<AddCronRequest, AddCronResponse>;
delCron: handleUnaryCall<DeleteCronRequest, DeleteCronResponse>;
}
export interface CronServiceClient extends Client {
export interface CronClient extends Client {
addCron(
request: AddCronRequest,
callback: (error: ServiceError | null, response: AddCronResponse) => void,
@@ -439,16 +439,16 @@ export interface CronServiceClient extends Client {
): ClientUnaryCall;
}
export const CronServiceClient = makeGenericClientConstructor(
CronServiceService,
'com.ql.cron.CronService',
export const CronClient = makeGenericClientConstructor(
CronService,
'com.ql.cron.Cron',
) as unknown as {
new (
address: string,
credentials: ChannelCredentials,
options?: Partial<ClientOptions>,
): CronServiceClient;
service: typeof CronServiceService;
): CronClient;
service: typeof CronService;
};
type Builtin =
+22
View File
@@ -0,0 +1,22 @@
syntax = "proto3";
package com.ql.health;
message HealthCheckRequest {
string service = 1;
}
message HealthCheckResponse {
enum ServingStatus {
UNKNOWN = 0;
SERVING = 1;
NOT_SERVING = 2;
SERVICE_UNKNOWN = 3;
}
ServingStatus status = 1;
}
service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}
+317
View File
@@ -0,0 +1,317 @@
/* eslint-disable */
import {
CallOptions,
ChannelCredentials,
Client,
ClientOptions,
ClientReadableStream,
ClientUnaryCall,
handleServerStreamingCall,
handleUnaryCall,
makeGenericClientConstructor,
Metadata,
ServiceError,
UntypedServiceImplementation,
} from '@grpc/grpc-js';
import _m0 from 'protobufjs/minimal';
export const protobufPackage = 'com.ql.health';
export interface HealthCheckRequest {
service: string;
}
export interface HealthCheckResponse {
status: HealthCheckResponse_ServingStatus;
}
export enum HealthCheckResponse_ServingStatus {
UNKNOWN = 0,
SERVING = 1,
NOT_SERVING = 2,
SERVICE_UNKNOWN = 3,
UNRECOGNIZED = -1,
}
export function healthCheckResponse_ServingStatusFromJSON(
object: any,
): HealthCheckResponse_ServingStatus {
switch (object) {
case 0:
case 'UNKNOWN':
return HealthCheckResponse_ServingStatus.UNKNOWN;
case 1:
case 'SERVING':
return HealthCheckResponse_ServingStatus.SERVING;
case 2:
case 'NOT_SERVING':
return HealthCheckResponse_ServingStatus.NOT_SERVING;
case 3:
case 'SERVICE_UNKNOWN':
return HealthCheckResponse_ServingStatus.SERVICE_UNKNOWN;
case -1:
case 'UNRECOGNIZED':
default:
return HealthCheckResponse_ServingStatus.UNRECOGNIZED;
}
}
export function healthCheckResponse_ServingStatusToJSON(
object: HealthCheckResponse_ServingStatus,
): string {
switch (object) {
case HealthCheckResponse_ServingStatus.UNKNOWN:
return 'UNKNOWN';
case HealthCheckResponse_ServingStatus.SERVING:
return 'SERVING';
case HealthCheckResponse_ServingStatus.NOT_SERVING:
return 'NOT_SERVING';
case HealthCheckResponse_ServingStatus.SERVICE_UNKNOWN:
return 'SERVICE_UNKNOWN';
case HealthCheckResponse_ServingStatus.UNRECOGNIZED:
default:
return 'UNRECOGNIZED';
}
}
function createBaseHealthCheckRequest(): HealthCheckRequest {
return { service: '' };
}
export const HealthCheckRequest = {
encode(
message: HealthCheckRequest,
writer: _m0.Writer = _m0.Writer.create(),
): _m0.Writer {
if (message.service !== '') {
writer.uint32(10).string(message.service);
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): HealthCheckRequest {
const reader =
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseHealthCheckRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
if (tag != 10) {
break;
}
message.service = reader.string();
continue;
}
if ((tag & 7) == 4 || tag == 0) {
break;
}
reader.skipType(tag & 7);
}
return message;
},
fromJSON(object: any): HealthCheckRequest {
return { service: isSet(object.service) ? String(object.service) : '' };
},
toJSON(message: HealthCheckRequest): unknown {
const obj: any = {};
message.service !== undefined && (obj.service = message.service);
return obj;
},
create<I extends Exact<DeepPartial<HealthCheckRequest>, I>>(
base?: I,
): HealthCheckRequest {
return HealthCheckRequest.fromPartial(base ?? {});
},
fromPartial<I extends Exact<DeepPartial<HealthCheckRequest>, I>>(
object: I,
): HealthCheckRequest {
const message = createBaseHealthCheckRequest();
message.service = object.service ?? '';
return message;
},
};
function createBaseHealthCheckResponse(): HealthCheckResponse {
return { status: 0 };
}
export const HealthCheckResponse = {
encode(
message: HealthCheckResponse,
writer: _m0.Writer = _m0.Writer.create(),
): _m0.Writer {
if (message.status !== 0) {
writer.uint32(8).int32(message.status);
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): HealthCheckResponse {
const reader =
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseHealthCheckResponse();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
if (tag != 8) {
break;
}
message.status = reader.int32() as any;
continue;
}
if ((tag & 7) == 4 || tag == 0) {
break;
}
reader.skipType(tag & 7);
}
return message;
},
fromJSON(object: any): HealthCheckResponse {
return {
status: isSet(object.status)
? healthCheckResponse_ServingStatusFromJSON(object.status)
: 0,
};
},
toJSON(message: HealthCheckResponse): unknown {
const obj: any = {};
message.status !== undefined &&
(obj.status = healthCheckResponse_ServingStatusToJSON(message.status));
return obj;
},
create<I extends Exact<DeepPartial<HealthCheckResponse>, I>>(
base?: I,
): HealthCheckResponse {
return HealthCheckResponse.fromPartial(base ?? {});
},
fromPartial<I extends Exact<DeepPartial<HealthCheckResponse>, I>>(
object: I,
): HealthCheckResponse {
const message = createBaseHealthCheckResponse();
message.status = object.status ?? 0;
return message;
},
};
export type HealthService = typeof HealthService;
export const HealthService = {
check: {
path: '/com.ql.health.Health/Check',
requestStream: false,
responseStream: false,
requestSerialize: (value: HealthCheckRequest) =>
Buffer.from(HealthCheckRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => HealthCheckRequest.decode(value),
responseSerialize: (value: HealthCheckResponse) =>
Buffer.from(HealthCheckResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => HealthCheckResponse.decode(value),
},
watch: {
path: '/com.ql.health.Health/Watch',
requestStream: false,
responseStream: true,
requestSerialize: (value: HealthCheckRequest) =>
Buffer.from(HealthCheckRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => HealthCheckRequest.decode(value),
responseSerialize: (value: HealthCheckResponse) =>
Buffer.from(HealthCheckResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => HealthCheckResponse.decode(value),
},
} as const;
export interface HealthServer extends UntypedServiceImplementation {
check: handleUnaryCall<HealthCheckRequest, HealthCheckResponse>;
watch: handleServerStreamingCall<HealthCheckRequest, HealthCheckResponse>;
}
export interface HealthClient extends Client {
check(
request: HealthCheckRequest,
callback: (
error: ServiceError | null,
response: HealthCheckResponse,
) => void,
): ClientUnaryCall;
check(
request: HealthCheckRequest,
metadata: Metadata,
callback: (
error: ServiceError | null,
response: HealthCheckResponse,
) => void,
): ClientUnaryCall;
check(
request: HealthCheckRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (
error: ServiceError | null,
response: HealthCheckResponse,
) => void,
): ClientUnaryCall;
watch(
request: HealthCheckRequest,
options?: Partial<CallOptions>,
): ClientReadableStream<HealthCheckResponse>;
watch(
request: HealthCheckRequest,
metadata?: Metadata,
options?: Partial<CallOptions>,
): ClientReadableStream<HealthCheckResponse>;
}
export const HealthClient = makeGenericClientConstructor(
HealthService,
'com.ql.health.Health',
) as unknown as {
new (
address: string,
credentials: ChannelCredentials,
options?: Partial<ClientOptions>,
): HealthClient;
service: typeof HealthService;
};
type Builtin =
| Date
| Function
| Uint8Array
| string
| number
| boolean
| undefined;
export type DeepPartial<T> = T extends Builtin
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends {}
? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
export type Exact<P, I extends P> = P extends Builtin
? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & {
[K in Exclude<keyof I, KeysOfUnion<P>>]: never;
};
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
+11 -5
View File
@@ -2,15 +2,21 @@ import express from 'express';
import { exec } from 'child_process';
import Logger from './loaders/logger';
import config from './config';
import { HealthClient } from './protos/health';
import { credentials } from '@grpc/grpc-js';
const app = express();
const client = new HealthClient(
`localhost:${config.cronPort}`,
credentials.createInsecure(),
);
app.get('/api/public/panel/log', (req, res) => {
exec('tail -n 300 ~/.pm2/logs/panel-error.log', (err, stdout, stderr) => {
if (err || stderr) {
return res.send({ code: 400, message: (err && err.message) || stderr });
app.get('/api/health', (req, res) => {
client.check({ service: 'cron' }, (err, response) => {
if (err) {
return res.status(500).send({ error: err });
}
return res.send({ code: 200, data: stdout });
return res.status(200).send({ data: response });
});
});
+2 -2
View File
@@ -2,14 +2,14 @@ import { credentials } from '@grpc/grpc-js';
import {
AddCronRequest,
AddCronResponse,
CronServiceClient,
CronClient,
DeleteCronRequest,
DeleteCronResponse,
} from '../protos/cron';
import config from '../config';
class Client {
private client = new CronServiceClient(
private client = new CronClient(
`localhost:${config.cronPort}`,
credentials.createInsecure(),
);
+30
View File
@@ -0,0 +1,30 @@
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
import { HealthCheckRequest, HealthCheckResponse } from '../protos/health';
import { exec } from 'child_process';
import config from '../config';
import { promiseExec } from '../config/util';
const check = async (
call: ServerUnaryCall<HealthCheckRequest, HealthCheckResponse>,
callback: sendUnaryData<HealthCheckResponse>,
) => {
switch (call.request.service) {
case 'cron':
const res = await promiseExec(
`curl -sf http://localhost:${config.port}/api/system`,
);
if (res.includes('200')) {
return callback(null, { status: 1 });
}
const errLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/panel-error.log`,
);
return callback(new Error(errLog));
default:
return callback(null, { status: 1 });
}
};
export { check };
+5 -2
View File
@@ -1,12 +1,15 @@
import { Server, ServerCredentials } from '@grpc/grpc-js';
import { CronServiceService } from '../protos/cron';
import { CronService } from '../protos/cron';
import { addCron } from './addCron';
import { delCron } from './delCron';
import { HealthService } from '../protos/health';
import { check } from './health';
import config from '../config';
import Logger from '../loaders/logger';
const server = new Server();
server.addService(CronServiceService, { addCron, delCron });
server.addService(HealthService, { check });
server.addService(CronService, { addCron, delCron });
server.bindAsync(
`localhost:${config.cronPort}`,
ServerCredentials.createInsecure(),
+1 -2
View File
@@ -49,7 +49,7 @@ export default class EnvService {
}
public async update(payload: Env): Promise<Env> {
const doc = await this.getDb({ id: payload.id })
const doc = await this.getDb({ id: payload.id });
const tab = new Env({ ...doc, ...payload });
const newDoc = await this.updateDb(tab);
await this.set_envs();
@@ -146,7 +146,6 @@ export default class EnvService {
}
try {
const result = await this.find(condition, [
['status', 'ASC'],
['position', 'DESC'],
['createdAt', 'ASC'],
]);
+234 -163
View File
@@ -36,7 +36,7 @@ export default class NotificationService {
private content = '';
private params!: Omit<NotificationInfo, 'type'>;
private gotOption = {
timeout: 30000,
timeout: 10000,
retry: 1,
};
@@ -78,33 +78,41 @@ export default class NotificationService {
}
private async gotify() {
const { gotifyUrl, gotifyToken, gotifyPriority } = this.params;
const res: any = await got
.post(`${gotifyUrl}/message?token=${gotifyToken}`, {
...this.gotOption,
body: `title=${encodeURIComponent(
this.title,
)}&message=${encodeURIComponent(
this.content,
)}&priority=${gotifyPriority}`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
.json();
return typeof res.id === 'number';
const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params;
try {
const res: any = await got
.post(`${gotifyUrl}/message?token=${gotifyToken}`, {
...this.gotOption,
body: `title=${encodeURIComponent(
this.title,
)}&message=${encodeURIComponent(
this.content,
)}&priority=${gotifyPriority}`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
.json();
return typeof res.id === 'number';
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async goCqHttpBot() {
const { goCqHttpBotQq, goCqHttpBotToken, goCqHttpBotUrl } = this.params;
const res: any = await got
.post(`${goCqHttpBotUrl}?${goCqHttpBotQq}`, {
...this.gotOption,
json: { message: `${this.title}\n${this.content}` },
headers: { Authorization: 'Bearer ' + goCqHttpBotToken },
})
.json();
return res.retcode === 0;
try {
const res: any = await got
.post(`${goCqHttpBotUrl}?${goCqHttpBotQq}`, {
...this.gotOption,
json: { message: `${this.title}\n${this.content}` },
headers: { Authorization: 'Bearer ' + goCqHttpBotToken },
})
.json();
return res.retcode === 0;
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async serverChan() {
@@ -112,49 +120,61 @@ export default class NotificationService {
const url = serverChanKey.startsWith('SCT')
? `https://sctapi.ftqq.com/${serverChanKey}.send`
: `https://sc.ftqq.com/${serverChanKey}.send`;
const res: any = await got
.post(url, {
...this.gotOption,
body: `title=${this.title}&desp=${this.content}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.json();
return res.errno === 0 || res.data.errno === 0;
try {
const res: any = await got
.post(url, {
...this.gotOption,
body: `title=${this.title}&desp=${this.content}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.json();
return res.errno === 0 || res.data.errno === 0;
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async pushDeer() {
const { pushDeerKey, pushDeerUrl } = this.params;
const url = pushDeerUrl || `https://api2.pushdeer.com/message/push`;
const res: any = await got
.post(url, {
...this.gotOption,
body: `pushkey=${pushDeerKey}&text=${encodeURIComponent(
this.title,
)}&desp=${encodeURIComponent(this.content)}&type=markdown`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.json();
return (
res.content.result.length !== undefined && res.content.result.length > 0
);
try {
const res: any = await got
.post(url, {
...this.gotOption,
body: `pushkey=${pushDeerKey}&text=${encodeURIComponent(
this.title,
)}&desp=${encodeURIComponent(this.content)}&type=markdown`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.json();
return (
res.content.result.length !== undefined && res.content.result.length > 0
);
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async chat() {
const { chatUrl, chatToken } = this.params;
const url = `${chatUrl}${chatToken}`;
const res: any = await got
.post(url, {
...this.gotOption,
body: `payload={"text":"${this.title}\n${this.content}"}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.json();
return res.success;
try {
const res: any = await got
.post(url, {
...this.gotOption,
body: `payload={"text":"${this.title}\n${this.content}"}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.json();
return res.success;
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async bark() {
let { barkPush, barkIcon, barkSound, barkGroup } = this.params;
if (!barkPush.startsWith('http') && !barkPush.startsWith('https')) {
if (!barkPush.startsWith('http')) {
barkPush = `https://api.day.app/${barkPush}`;
}
const url = `${barkPush}/${encodeURIComponent(
@@ -162,13 +182,18 @@ export default class NotificationService {
)}/${encodeURIComponent(
this.content,
)}?icon=${barkIcon}&sound=${barkSound}&group=${barkGroup}`;
const res: any = await got
.get(url, {
...this.gotOption,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.json();
return res.code === 200;
try {
const res: any = await got
.get(url, {
...this.gotOption,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.json();
return res.code === 200;
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async telegramBot() {
@@ -200,15 +225,19 @@ export default class NotificationService {
https: httpsAgent,
};
}
const res: any = await got
.post(url, {
...this.gotOption,
body: `chat_id=${telegramBotUserId}&text=${this.title}\n\n${this.content}&disable_web_page_preview=true`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
agent,
})
.json();
return !!res.ok;
try {
const res: any = await got
.post(url, {
...this.gotOption,
body: `chat_id=${telegramBotUserId}&text=${this.title}\n\n${this.content}&disable_web_page_preview=true`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
agent,
})
.json();
return !!res.ok;
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async dingtalkBot() {
@@ -222,35 +251,43 @@ export default class NotificationService {
secretParam = `&timestamp=${dateNow}&sign=${result}`;
}
const url = `https://oapi.dingtalk.com/robot/send?access_token=${dingtalkBotToken}${secretParam}`;
const res: any = await got
.post(url, {
...this.gotOption,
json: {
msgtype: 'text',
text: {
content: ` ${this.title}\n\n${this.content}`,
try {
const res: any = await got
.post(url, {
...this.gotOption,
json: {
msgtype: 'text',
text: {
content: ` ${this.title}\n\n${this.content}`,
},
},
},
})
.json();
return res.errcode === 0;
})
.json();
return res.errcode === 0;
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async weWorkBot() {
const { weWorkBotKey } = this.params;
const url = `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${weWorkBotKey}`;
const res: any = await got
.post(url, {
...this.gotOption,
json: {
msgtype: 'text',
text: {
content: ` ${this.title}\n\n${this.content}`,
try {
const res: any = await got
.post(url, {
...this.gotOption,
json: {
msgtype: 'text',
text: {
content: ` ${this.title}\n\n${this.content}`,
},
},
},
})
.json();
return res.errcode === 0;
})
.json();
return res.errcode === 0;
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async weWorkApp() {
@@ -306,22 +343,26 @@ export default class NotificationService {
break;
}
const res: any = await got
.post(
`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${tokenRes.access_token}`,
{
...this.gotOption,
json: {
touser,
agentid,
safe: '0',
...options,
try {
const res: any = await got
.post(
`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${tokenRes.access_token}`,
{
...this.gotOption,
json: {
touser,
agentid,
safe: '0',
...options,
},
},
},
)
.json();
)
.json();
return res.errcode === 0;
return res.errcode === 0;
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async aibotk() {
@@ -353,85 +394,111 @@ export default class NotificationService {
break;
}
const res: any = await got
.post(url, {
...this.gotOption,
json: {
...json,
},
})
.json();
try {
const res: any = await got
.post(url, {
...this.gotOption,
json: {
...json,
},
})
.json();
return res.code === 0;
return res.code === 0;
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async iGot() {
const { iGotPushKey } = this.params;
const url = `https://push.hellyw.com/${iGotPushKey.toLowerCase()}`;
const res: any = await got
.post(url, {
...this.gotOption,
body: `title=${this.title}&content=${this.content}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.json();
try {
const res: any = await got
.post(url, {
...this.gotOption,
body: `title=${this.title}&content=${this.content}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.json();
return res.ret === 0;
return res.ret === 0;
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async pushPlus() {
const { pushPlusToken, pushPlusUser } = this.params;
const url = `https://www.pushplus.plus/send`;
const res: any = await got
.post(url, {
...this.gotOption,
json: {
token: `${pushPlusToken}`,
title: `${this.title}`,
content: `${this.content.replace(/[\n\r]/g, '<br>')}`,
topic: `${pushPlusUser || ''}`,
},
})
.json();
try {
const res: any = await got
.post(url, {
...this.gotOption,
json: {
token: `${pushPlusToken}`,
title: `${this.title}`,
content: `${this.content.replace(/[\n\r]/g, '<br>')}`,
topic: `${pushPlusUser || ''}`,
},
})
.json();
return res.code === 200;
return res.code === 200;
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async lark() {
const { larkKey } = this.params;
const res: any = await got
.post(`https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`, {
...this.gotOption,
json: {
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
},
headers: { 'Content-Type': 'application/json' },
})
.json();
return res.StatusCode === 0;
let { larkKey } = this.params;
if (!larkKey.startsWith('http')) {
larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`;
}
try {
const res: any = await got
.post(larkKey, {
...this.gotOption,
json: {
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
},
headers: { 'Content-Type': 'application/json' },
})
.json();
return res.StatusCode === 0;
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async email() {
const { emailPass, emailService, emailUser } = this.params;
const transporter = nodemailer.createTransport({
service: emailService,
auth: {
user: emailUser,
pass: emailPass,
},
});
const info = await transporter.sendMail({
from: `"青龙快讯" <${emailUser}>`,
to: `${emailUser}`,
subject: `${this.title}`,
html: `${this.content.replace(/\n/g, '<br/>')}`,
});
try {
const transporter = nodemailer.createTransport({
service: emailService,
auth: {
user: emailUser,
pass: emailPass,
},
});
transporter.close();
const info = await transporter.sendMail({
from: `"青龙快讯" <${emailUser}>`,
to: `${emailUser}`,
subject: `${this.title}`,
html: `${this.content.replace(/\n/g, '<br/>')}`,
});
return !!info.messageId;
transporter.close();
return !!info.messageId;
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async webhook() {
@@ -460,8 +527,12 @@ export default class NotificationService {
allowGetBody: true,
...bodyParam,
};
const res = await got(formatUrl, options);
return String(res.statusCode).startsWith('20');
try {
const res = await got(formatUrl, options);
return String(res.statusCode).startsWith('20');
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private formatBody(contentType: string, body: any): object {
+11 -20
View File
@@ -19,6 +19,7 @@ import {
fileExist,
createFile,
killTask,
handleLogPath,
} from '../config/util';
import { promises, existsSync } from 'fs';
import { FindOptions, Op } from 'sequelize';
@@ -121,18 +122,6 @@ export default class SubscriptionService {
});
}
private async handleLogPath(
logPath: string,
data: string = '',
): Promise<string> {
const absolutePath = path.resolve(config.logPath, logPath);
const logFileExist = await fileExist(absolutePath);
if (!logFileExist) {
await createFile(absolutePath, data);
}
return absolutePath;
}
private taskCallbacks(doc: Subscription): TaskCallbacks {
return {
onBefore: async (startTime) => {
@@ -145,7 +134,7 @@ export default class SubscriptionService {
},
{ where: { id: doc.id } },
);
const absolutePath = await this.handleLogPath(
const absolutePath = await handleLogPath(
logPath as string,
`## 开始执行... ${startTime.format('YYYY-MM-DD HH:mm:ss')}\n`,
);
@@ -175,7 +164,7 @@ export default class SubscriptionService {
},
onEnd: async (cp, endTime, diff) => {
const sub = await this.getDb({ id: doc.id });
const absolutePath = await this.handleLogPath(sub.log_path as string);
const absolutePath = await handleLogPath(sub.log_path as string);
// 执行 sub_after
let afterStr = '';
@@ -212,12 +201,12 @@ export default class SubscriptionService {
},
onError: async (message: string) => {
const sub = await this.getDb({ id: doc.id });
const absolutePath = await this.handleLogPath(sub.log_path as string);
const absolutePath = await handleLogPath(sub.log_path as string);
fs.appendFileSync(absolutePath, `\n${message}`);
},
onLog: async (message: string) => {
const sub = await this.getDb({ id: doc.id });
const absolutePath = await this.handleLogPath(sub.log_path as string);
const absolutePath = await handleLogPath(sub.log_path as string);
fs.appendFileSync(absolutePath, `\n${message}`);
},
};
@@ -236,7 +225,7 @@ export default class SubscriptionService {
}
public async update(payload: Subscription): Promise<Subscription> {
const doc = await this.getDb({ id: payload.id })
const doc = await this.getDb({ id: payload.id });
const tab = new Subscription({ ...doc, ...payload });
const newDoc = await this.updateDb(tab);
await this.handleTask(newDoc, !newDoc.is_disabled);
@@ -289,7 +278,9 @@ export default class SubscriptionService {
await this.setSshConfig();
}
public async getDb(query: FindOptions<Subscription>['where']): Promise<Subscription> {
public async getDb(
query: FindOptions<Subscription>['where'],
): Promise<Subscription> {
const doc: any = await SubscriptionModel.findOne({ where: { ...query } });
return doc && (doc.get({ plain: true }) as Subscription);
}
@@ -315,7 +306,7 @@ export default class SubscriptionService {
this.logger.silly(error);
}
}
const absolutePath = await this.handleLogPath(doc.log_path as string);
const absolutePath = await handleLogPath(doc.log_path as string);
fs.appendFileSync(
`${absolutePath}`,
@@ -369,7 +360,7 @@ export default class SubscriptionService {
return '';
}
const absolutePath = await this.handleLogPath(doc.log_path as string);
const absolutePath = await handleLogPath(doc.log_path as string);
return getFileContentByName(absolutePath);
}
+34 -2
View File
@@ -5,11 +5,17 @@ import * as fs from 'fs';
import { AuthDataType, AuthInfo, AuthModel, LoginStatus } from '../data/auth';
import { NotificationInfo } from '../data/notify';
import NotificationService from './notify';
import ScheduleService from './schedule';
import ScheduleService, { TaskCallbacks } from './schedule';
import { spawn } from 'child_process';
import SockService from './sock';
import got from 'got';
import { parseContentVersion, parseVersion } from '../config/util';
import {
getPid,
killTask,
parseContentVersion,
parseVersion,
} from '../config/util';
import { TASK_COMMAND } from '../config/const';
@Service()
export default class SystemService {
@@ -170,4 +176,30 @@ export default class SystemService {
return { code: 400, message: '通知发送失败,请检查系统设置/通知配置' };
}
}
public async run(
{ command, logPath }: { command: string; logPath: string },
callback: TaskCallbacks,
) {
if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`;
}
this.scheduleService.runTask(
`real_log_path=${logPath} real_time=true ${command}`,
callback,
);
}
public async stop({ command }: { command: string }) {
if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`;
}
const pid = await getPid(command);
if (pid) {
await killTask(pid);
return { code: 200 };
} else {
return { code: 400, message: '任务未找到' };
}
}
}
+56 -52
View File
@@ -1,11 +1,11 @@
FROM python:3.10-alpine as builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
RUN set -x \
&& apk update \
&& apk add nodejs npm git \
&& npm i -g pnpm \
&& cd /tmp/build \
&& pnpm install --prod
&& apk update \
&& apk add nodejs npm git \
&& npm i -g pnpm \
&& cd /tmp/build \
&& pnpm install --prod
FROM python:3.10-alpine
@@ -15,59 +15,63 @@ ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
ARG QL_BRANCH=develop
ENV PNPM_HOME=/root/.local/share/pnpm \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/share/pnpm:/root/.local/share/pnpm/global/5/node_modules:$PNPM_HOME \
NODE_PATH=/usr/local/bin:/usr/local/pnpm-global/5/node_modules:/usr/local/lib/node_modules:/root/.local/share/pnpm/global/5/node_modules \
LANG=C.UTF-8 \
SHELL=/bin/bash \
PS1="\u@\h:\w \$ " \
QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH}
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/share/pnpm:/root/.local/share/pnpm/global/5/node_modules:$PNPM_HOME \
NODE_PATH=/usr/local/bin:/usr/local/pnpm-global/5/node_modules:/usr/local/lib/node_modules:/root/.local/share/pnpm/global/5/node_modules \
LANG=C.UTF-8 \
SHELL=/bin/bash \
PS1="\u@\h:\w \$ " \
QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH}
RUN set -x \
&& sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk update -f \
&& apk upgrade \
&& apk --no-cache add -f bash \
coreutils \
moreutils \
git \
curl \
wget \
tzdata \
perl \
openssl \
nginx \
nodejs \
jq \
openssh \
npm \
&& rm -rf /var/cache/apk/* \
&& apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& git config --global user.email "qinglong@@users.noreply.github.com" \
&& git config --global user.name "qinglong" \
&& git config --global http.postBuffer 524288000 \
&& npm install -g pnpm \
&& pnpm add -g pm2 tsx \
&& rm -rf /root/.pnpm-store \
&& rm -rf /root/.local/share/pnpm/store \
&& rm -rf /root/.cache \
&& rm -rf /root/.npm
&& sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk update -f \
&& apk upgrade \
&& apk --no-cache add -f bash \
coreutils \
moreutils \
git \
curl \
wget \
tzdata \
perl \
openssl \
nginx \
nodejs \
jq \
openssh \
procps \
npm \
&& rm -rf /var/cache/apk/* \
&& apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& git config --global user.email "qinglong@@users.noreply.github.com" \
&& git config --global user.name "qinglong" \
&& git config --global http.postBuffer 524288000 \
&& npm install -g pnpm \
&& pnpm add -g pm2 tsx \
&& rm -rf /root/.pnpm-store \
&& rm -rf /root/.local/share/pnpm/store \
&& rm -rf /root/.cache \
&& rm -rf /root/.npm
ARG SOURCE_COMMIT
RUN git clone -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
&& cd ${QL_DIR} \
&& cp -f .env.example .env \
&& chmod 777 ${QL_DIR}/shell/*.sh \
&& chmod 777 ${QL_DIR}/docker/*.sh \
&& git clone -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \
&& mkdir -p ${QL_DIR}/static \
&& cp -rf /static/* ${QL_DIR}/static \
&& rm -rf /static
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
&& cd ${QL_DIR} \
&& cp -f .env.example .env \
&& chmod 777 ${QL_DIR}/shell/*.sh \
&& chmod 777 ${QL_DIR}/docker/*.sh \
&& git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \
&& mkdir -p ${QL_DIR}/static \
&& cp -rf /static/* ${QL_DIR}/static \
&& rm -rf /static
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=10 \
CMD curl -sf http://127.0.0.1:5400/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+5
View File
@@ -7,3 +7,8 @@ services:
ports:
- "0.0.0.0:5700:5700"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-sf", "http://127.0.0.1:5400/api/health", "||", "exit", "1"]
interval: 2m
timeout: 10s
retries: 3
+1
View File
@@ -12,6 +12,7 @@ make_dir /run/nginx
init_nginx
pm2 l &>/dev/null
pm2 flush &>/dev/null
echo -e "======================2. 安装依赖========================\n"
patch_version
+1 -1
View File
@@ -21,7 +21,7 @@ server {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://publicApi/api/public/;
proxy_pass http://publicApi/api/;
}
location QL_BASE_URL/api/ {
+1 -1
View File
@@ -11,7 +11,7 @@
"panel": "npm run build:back && node static/build/app.js",
"schedule": "npm run build:back && node static/build/schedule/index.js",
"public": "npm run build:back && node static/build/public.js",
"gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/cron.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true",
"gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true",
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
"postinstall": "max setup 2>/dev/null || true",
"test": "umi-test",
+2 -11
View File
@@ -1,9 +1,3 @@
## Version: v2.8.0
## Date: 2021-06-20
## Update Content: 可持续发展纲要\n1. session管理破坏性修改\n2. 配置管理可编辑config下文件\n3. 自定义脚本改为查看脚本\n4. 移除互助相关
## 上面版本号中,如果第2位数字有变化,那么代表增加了新的参数,如果只有第3位数字有变化,仅代表更新了注释,没有增加新的参数,可更新可不更新
## 在运行 ql repo 命令时,是否自动删除失效的脚本与定时任务
AutoDelCron="true"
@@ -24,11 +18,8 @@ CpuWarn=80
MemoryWarn=80
DiskWarn=90
## 设置定时任务执行的超时时间,默认1h,后缀"s"代表秒(默认值), "m"代表分, "h"代表小时, "d"代表天
CommandTimeoutTime="1h"
## 设置批量执行任务时的并发数,默认同时执行5个任务
MaxConcurrentNum="5"
## 设置定时任务执行的超时时间,例如1h,后缀"s"代表秒(默认值), "m"代表分, "h"代表小时, "d"代表天
CommandTimeoutTime=""
## 在运行 task 命令时,随机延迟启动任务的最大延迟时间
## 默认给javascript任务加随机延迟,如 RandomDelay="300" ,表示任务将在 1-300 秒内随机延迟一个秒数,然后再运行,取消延迟赋值为空
+1 -1
View File
@@ -178,7 +178,7 @@ update_cron() {
code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message)
if [[ $code != 200 ]]; then
echo -e "\n## 更新任务状态失败(${message})\n" >>$dir_log/$log_path
echo -e "\n## 更新任务状态失败(${message})\n"
fi
}
+1 -1
View File
@@ -42,6 +42,6 @@ echo -e "\npython3依赖安装成功...\n"
echo -e "4、启动bot程序...\n"
make_dir $dir_log/bot
cd $dir_data
ps -ef | grep "python3 -m jbot" | grep -v grep | awk '{print $1}' | xargs kill -9 2>/dev/null
ps -eo pid,command | grep "python3 -m jbot" | grep -v grep | awk '{print $1}' | xargs kill -9 2>/dev/null
nohup python3 -m jbot >$dir_log/bot/nohup.log 2>&1 &
echo -e "bot启动成功...\n"
+4 -10
View File
@@ -33,12 +33,12 @@ pm2_log() {
echo -e "---> pm2日志"
local panelOut="/root/.pm2/logs/panel-out.log"
local panelError="/root/.pm2/logs/panel-error.log"
tail -n 100 "$panelOut"
tail -n 100 "$panelError"
tail -n 300 "$panelOut"
tail -n 300 "$panelError"
}
check_nginx() {
local nginxPid=$(ps -ef | grep nginx | grep -v grep)
local nginxPid=$(ps -eo pid,command | grep nginx | grep -v grep)
echo -e "=====> 检测nginx服务\n$nginxPid"
if [[ $nginxPid ]]; then
echo -e "\n=====> nginx服务正常\n"
@@ -54,9 +54,6 @@ check_ql() {
echo -e "\n=====> 检测面板\n\n$api\n"
if [[ $api =~ "<div id=\"root\"></div>" ]]; then
echo -e "=====> 面板服务启动正常\n"
else
echo -e "=====> 面板服务异常,重置基础环境\n"
reset_env
fi
}
@@ -74,9 +71,6 @@ check_pm2() {
echo -e "\n=====> 检测后台\n\n$api\n"
if [[ $api =~ "{\"code\"" ]]; then
echo -e "=====> 后台服务启动正常\n"
else
echo -e "=====> 后台服务异常,重置基础环境并重启后台\n"
reset_env
fi
}
@@ -91,7 +85,7 @@ main() {
npm i -g pnpm
patch_version
pnpm add -g pm2 tsx
update_depend
reset_env
start_public
copy_dep
check_ql
+1 -41
View File
@@ -1,11 +1,5 @@
#!/usr/bin/env bash
trap "single_hanle" 2 20 15 14
single_hanle() {
handle_task_after "$@"
exit 1
}
random_delay() {
local random_delay_max=$RandomDelay
if [[ $random_delay_max ]] && [[ $random_delay_max -gt 0 ]]; then
@@ -92,44 +86,10 @@ check_server() {
fi
}
handle_task_before() {
begin_time=$(format_time "$time_format" "$time")
begin_timestamp=$(format_timestamp "$time_format" "$time")
[[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp"
echo -e "## 开始执行... $begin_time\n"
[[ $is_macos -eq 0 ]] && check_server
if [[ -s $task_error_log_path ]]; then
eval cat $task_error_log_path $cmd
eval echo -e "加载 config.sh 出错,请手动检查" $cmd
eval echo $cmd
fi
. $file_task_before "$@"
}
handle_task_after() {
. $file_task_after "$@"
local etime=$(date "+$time_format")
local end_time=$(format_time "$time_format" "$etime")
local end_timestamp=$(format_timestamp "$time_format" "$etime")
local diff_time=$(($end_timestamp - $begin_timestamp))
[[ "$diff_time" == 0 ]] && diff_time=1
echo -e "\n\n## 执行结束... $end_time 耗时 $diff_time 秒     "
[[ $ID ]] && update_cron "\"$ID\"" "1" "" "$log_path" "$begin_timestamp" "$diff_time"
}
## 正常运行单个脚本,$1:传入参数
run_normal() {
local file_param=$1
if [[ $# -eq 1 ]]; then
if [[ $# -eq 1 ]] && [[ "$real_time" != "true" ]]; then
random_delay "$file_param"
fi
+49 -9
View File
@@ -68,7 +68,7 @@ import_config() {
[[ -f $file_env ]] && . $file_env
ql_base_url=${QlBaseUrl:-""}
command_timeout_time=${CommandTimeoutTime:-"1h"}
command_timeout_time=${CommandTimeoutTime:-""}
proxy_url=${ProxyUrl:-""}
file_extensions=${RepoFileExtensions:-"js py"}
current_branch=${QL_BRANCH}
@@ -267,9 +267,9 @@ npm_install_1() {
local dir_work=$1
cd $dir_work
echo -e "运行 npm install...\n"
echo -e "运行 pnpm install...\n"
npm_install_sub
[[ $? -ne 0 ]] && echo -e "\nnpm install 运行不成功,请进入 $dir_work 目录后手动运行 npm install...\n"
[[ $? -ne 0 ]] && echo -e "\nnpm install 运行不成功,请进入 $dir_work 目录后手动运行 pnpm install...\n"
cd $dir_current
}
@@ -278,7 +278,7 @@ npm_install_2() {
local dir_work=$1
cd $dir_work
echo -e "检测到 $dir_work 依赖包有变化,运行 npm install...\n"
echo -e "安装 $dir_work 依赖包...\n"
npm_install_sub
if [[ $? -ne 0 ]]; then
echo -e "\n安装 $dir_work 的依赖包运行不成功,再次尝试一遍...\n"
@@ -315,9 +315,11 @@ git_clone_scripts() {
echo -e "开始克隆仓库 $url$dir\n"
set_proxy "$proxy"
git clone $part_cmd $url $dir
git clone --depth=1 $part_cmd $url $dir
exit_status=$?
unset_proxy
reset_branch "$branch"
}
git_pull_scripts() {
@@ -328,12 +330,18 @@ git_pull_scripts() {
cd $dir_work
echo -e "开始更新仓库:$dir_work"
local pre_commit_id=$(git rev-parse --short HEAD)
set_proxy "$proxy"
git fetch --all
git pull 1>/dev/null
git fetch --depth=1 --all
git pull --depth=1 &>/dev/null
exit_status=$?
unset_proxy
reset_branch "$branch"
local cur_commit_id=$(git rev-parse --short HEAD)
if [[ $cur_commit_id != $pre_commit_id ]]; then
exit_status=0
fi
cd $dir_current
}
@@ -351,7 +359,7 @@ reset_romote_url() {
git init
git remote add origin $url &>/dev/null
fi
reset_branch "$branch"
cd $dir_current
}
@@ -374,6 +382,7 @@ random_range() {
reload_pm2() {
pm2 l &>/dev/null
pm2 flush &>/dev/null
echo -e "启动面板服务\n"
pm2 delete panel --source-map-support --time &>/dev/null
@@ -434,13 +443,14 @@ patch_version() {
# 兼容pnpm@7
pnpm setup &>/dev/null
source ~/.bashrc
apk add procps
if [[ $PipMirror ]]; then
pip3 config set global.index-url $PipMirror
fi
if [[ $NpmMirror ]]; then
cd && pnpm config set registry $NpmMirror
pnpm install -g
pnpm install -g --force
fi
git config --global pull.rebase false
@@ -492,6 +502,36 @@ init_nginx() {
sed -i "s,IPV6_CONFIG,${ipv6Str},g" /etc/nginx/conf.d/front.conf
}
handle_task_before() {
[[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp"
echo -e "## 开始执行... $begin_time\n"
[[ $is_macos -eq 0 ]] && check_server
if [[ -s $task_error_log_path ]]; then
cat $task_error_log_path
echo -e "加载 config.sh 出错,请手动检查"
fi
. $file_task_before "$@"
}
handle_task_after() {
. $file_task_after "$@"
local etime=$(date "+$time_format")
local end_time=$(format_time "$time_format" "$etime")
local end_timestamp=$(format_timestamp "$time_format" "$etime")
local diff_time=$(($end_timestamp - $begin_timestamp))
[[ "$diff_time" == 0 ]] && diff_time=1
echo -e "\n\n## 执行结束... $end_time 耗时 $diff_time 秒     "
[[ $ID ]] && update_cron "\"$ID\"" "1" "" "$log_path" "$begin_timestamp" "$diff_time"
}
init_env
detect_termux
detect_macos
+37 -7
View File
@@ -5,6 +5,12 @@ dir_shell=$QL_DIR/shell
. $dir_shell/share.sh
. $dir_shell/api.sh
trap "single_hanle" 2 3 20 15 14
single_hanle() {
eval handle_task_after "$@" "$cmd"
exit 1
}
## 选择python3还是node
define_program() {
local file_param=$1
@@ -28,14 +34,22 @@ define_program() {
handle_log_path() {
local file_param=$1
if [[ -z $file_param ]];then
if [[ -z $file_param ]]; then
file_param="task"
fi
if [[ -z $ID ]]; then
ID=$(cat $list_crontab_user | grep -E "$cmd_task.* $file_param" | perl -pe "s|.*ID=(.*) $cmd_task.* $file_param\.*|\1|" | head -1 | awk -F " " '{print $1}')
fi
local suffix=""
if [[ ! -z $ID ]]; then
suffix="_${ID}"
if [[ "$ID" -gt 0 ]] 2>/dev/null; then
suffix="_${ID}"
else
ID=""
fi
fi
time=$(date "+$mtime_format")
log_time=$(format_log_time "$mtime_format" "$time")
log_dir_tmp="${file_param##*/}"
@@ -51,11 +65,19 @@ handle_log_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
log_path="$real_log_path"
fi
cmd=">> $dir_log/$log_path 2>&1"
make_dir "$dir_log/$log_dir"
if [[ "$show_log" == "true" ]]; then
cmd="2>&1 | tee -a $dir_log/$log_path"
fi
if [[ "$real_time" == "true" ]]; then
cmd=""
else
make_dir "$dir_log/$log_dir"
fi
}
@@ -67,12 +89,19 @@ format_params() {
mtime_format="%Y-%m-%d %H:%M:%S.%3N"
fi
timeoutCmd=""
if type timeout &>/dev/null; then
timeoutCmd="timeout --foreground -s 14 -k 10s $command_timeout_time "
if [[ $command_timeout_time ]]; then
if type timeout &>/dev/null; then
timeoutCmd="timeout --foreground -s 2 -k 10s $command_timeout_time "
fi
fi
# params=$(echo "$@" | sed -E 's/([^ ])&([^ ])/\1\\\&\2/g')
}
init_begin_time() {
begin_time=$(format_time "$time_format" "$time")
begin_timestamp=$(format_timestamp "$time_format" "$time")
}
while getopts ":lm:" opt; do
case $opt in
l)
@@ -92,8 +121,9 @@ fi
format_params "$@"
define_program "$@"
handle_log_path "$@"
init_begin_time
eval . $dir_shell/otask.sh "$cmd"
[[ -f "$dir_log/$log_path" ]] && cat "$dir_log/$log_path"
[[ -f "$dir_log/$log_path" ]] && [[ ! $show_log ]] && [[ "$real_time" != "true" ]] && cat "$dir_log/$log_path"
exit 0
+1 -6
View File
@@ -46,7 +46,7 @@ del_cron() {
local ids=""
echo -e "开始尝试自动删除失效的定时任务...\n"
for cron in $(cat $list_drop); do
local id=$(cat $list_crontab_user | grep -E "$cmd_task $cron" | perl -pe "s|.*ID=(.*) $cmd_task $cron\.*|\1|" | head -1 | head -1 | awk -F " " '{print $1}')
local id=$(cat $list_crontab_user | grep -E "$cmd_task.* $cron" | perl -pe "s|.*ID=(.*) $cmd_task.* $cron\.*|\1|" | head -1 | awk -F " " '{print $1}')
if [[ $ids ]]; then
ids="$ids,\"$id\""
else
@@ -254,13 +254,9 @@ update_qinglong() {
if [[ $exit_status -eq 0 ]]; then
echo -e "\n更新青龙源文件成功...\n"
reset_romote_url ${dir_root} "https://${mirror}.com/whyour/qinglong.git" ${primary_branch}
cp -f $file_config_sample $dir_config/config.sample.sh
update_depend
[[ -f $dir_root/package.json ]] && ql_depend_new=$(cat $dir_root/package.json)
[[ "$ql_depend_old" != "$ql_depend_new" ]] && npm_install_2 $dir_root
update_qinglong_static "$1" "$primary_branch"
else
echo -e "\n更新青龙源文件失败,请检查网络...\n"
@@ -280,7 +276,6 @@ update_qinglong_static() {
fi
if [[ $exit_status -eq 0 ]]; then
echo -e "\n更新青龙静态资源成功...\n"
reset_romote_url ${ql_static_repo} ${url} ${primary_branch}
rm -rf $dir_static/*
cp -rf $ql_static_repo/* $dir_static
+40
View File
@@ -9,4 +9,44 @@
height: calc(100vh - 80px);
overflow-y: auto;
}
.code-box {
position: relative;
display: inline-block;
width: 80%;
margin: 16px;
background-color: #ffffff;
border: 1px solid rgba(5, 5, 5, 0.06);
border-radius: 6px;
-webkit-transition: all 0.2s;
transition: all 0.2s;
border-radius: 6px 6px 0 0;
color: rgba(0, 0, 0, 0.88);
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
.browser-markup {
position: relative;
border-top: 2em solid rgba(230, 230, 230, 0.7);
border-radius: 3px 3px 0 0;
&::before {
position: absolute;
top: -1.25em;
left: 1em;
display: block;
width: 0.5em;
height: 0.5em;
background-color: #f44;
border-radius: 50%;
box-shadow: 0 0 0 2px #f44, 1.5em 0 0 2px #9b3, 3em 0 0 2px #fb5;
content: '';
}
}
.log {
height: calc(100vh - 150px);
overflow-y: auto;
padding: 12px;
}
}
}
+26 -42
View File
@@ -1,43 +1,35 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import config from '@/utils/config';
import { request } from '@/utils/http';
import Terminal, { ColorMode, LineType } from '../../components/terminal';
import { PageLoading } from '@ant-design/pro-layout';
import { history, useOutletContext } from '@umijs/max';
import Ansi from 'ansi-to-react';
import './index.less';
import { SharedContext } from '@/layouts';
import { Alert, Typography } from 'antd';
const Error = () => {
const { user, theme, reloadUser } = useOutletContext<SharedContext>();
const [loading, setLoading] = useState(false);
const [data, setData] = useState('暂无日志');
const getTimes = () => {
return parseInt(localStorage.getItem('error_retry_times') || '0', 10);
};
let times = getTimes();
const retryTimes = useRef(1);
const getLog = (needLoading: boolean = true) => {
needLoading && setLoading(true);
request
.get(`${config.apiPrefix}public/panel/log`)
.then(({ code, data }) => {
if (code === 200) {
setData(data);
if (!data) {
times = getTimes();
if (times > 5) {
return;
}
localStorage.setItem('error_retry_times', `${times + 1}`);
setTimeout(() => {
reloadUser();
getLog(false);
}, 3000);
}
.get(`${config.apiPrefix}public/health`)
.then(({ status, error }) => {
if (status === 1) {
return reloadUser();
}
if (retryTimes.current > 3) {
setData(error?.details);
return;
}
retryTimes.current += 1;
setTimeout(() => {
reloadUser();
getLog(false);
}, 3000);
})
.finally(() => needLoading && setLoading(false));
};
@@ -56,24 +48,16 @@ const Error = () => {
<div className="error-wrapper">
{loading ? (
<PageLoading />
) : data ? (
<Terminal
name="服务错误"
colorMode={theme === 'vs-dark' ? ColorMode.Dark : ColorMode.Light}
lineData={[
{ type: LineType.Input, value: 'pm2 logs panel' },
{
type: LineType.Output,
value: (
<pre>
<Ansi>{data}</Ansi>
</pre>
),
},
]}
/>
) : times > 5 ? (
<> ql -l check </>
) : retryTimes.current > 3 ? (
<div className="code-box">
<div className="browser-markup"></div>
<Alert
type="error"
message="服务启动超时,请检查如下日志或者进入容器执行 ql -l check 后刷新再试"
banner
/>
<Typography.Paragraph className="log">{data}</Typography.Paragraph>
</div>
) : (
<PageLoading tip="启动中,请稍后..." />
)}
+11 -5
View File
@@ -12,6 +12,7 @@ const NotificationSetting = ({ data }: any) => {
const [form] = Form.useForm();
const handleOk = (values: any) => {
setLoading(true);
const { type } = values;
if (type == 'closed') {
values.type = '';
@@ -30,7 +31,8 @@ const NotificationSetting = ({ data }: any) => {
})
.catch((error: any) => {
console.log(error);
});
})
.finally(() => setLoading(false));
};
const notificationModeChange = (value: string) => {
@@ -56,7 +58,7 @@ const NotificationSetting = ({ data }: any) => {
style={{ maxWidth: 400 }}
initialValue={notificationMode}
>
<Select onChange={notificationModeChange}>
<Select onChange={notificationModeChange} disabled={loading}>
{config.notificationModes.map((x) => (
<Option key={x.value} value={x.value}>
{x.label}
@@ -74,7 +76,10 @@ const NotificationSetting = ({ data }: any) => {
style={{ maxWidth: 400 }}
>
{x.items ? (
<Select placeholder={x.placeholder || `请选择${x.label}`}>
<Select
placeholder={x.placeholder || `请选择${x.label}`}
disabled={loading}
>
{x.items.map((y) => (
<Option key={y.value} value={y.value}>
{y.label || y.value}
@@ -83,14 +88,15 @@ const NotificationSetting = ({ data }: any) => {
</Select>
) : (
<Input.TextArea
disabled={loading}
autoSize={true}
placeholder={x.placeholder || `请输入${x.label}`}
/>
)}
</Form.Item>
))}
<Button type="primary" htmlType="submit">
<Button type="primary" htmlType="submit" disabled={loading}>
{loading ? '测试中...' : '保存'}
</Button>
</Form>
</div>
+2 -2
View File
@@ -4,7 +4,7 @@ import browserType from './index';
export const useCtx = () => {
const [width, setWidth] = useState('100%');
const [marginLeft, setMarginLeft] = useState(0);
const [marginTop, setMarginTop] = useState(-72);
const [marginTop, setMarginTop] = useState(-48);
const [isPhone, setIsPhone] = useState(false);
const { platform } = useMemo(() => browserType(), []);
@@ -18,7 +18,7 @@ export const useCtx = () => {
} else {
setWidth('100%');
setMarginLeft(0);
setMarginTop(-72);
setMarginTop(-48);
setIsPhone(false);
document.body.setAttribute('data-mode', 'desktop');
}
+10 -8
View File
@@ -1,9 +1,11 @@
version: 2.15.12
changeLogLink: https://t.me/jiao_long/368
version: 2.15.13
changeLogLink: https://t.me/jiao_long/373
changeLog: |
1. 修复定时任务筛选
2. 修复更新环境变量、定时任务、订阅,状态被重置
3. 重构六位定时服务
4. 修改手机端页面样式
5. 修改依赖安装流程
6. 其他bug修复
1. 增加运行、停止指定命令接口 system/command-run、system/command-stop
2. 增加容器健康检查
3. 移除执行任务默认超时时间
4. 修改 task 命令生成日志逻辑和关联任务查询
5. 修复更新任务、环境变量、依赖、订阅状态丢失
6. 修改系统通知错误提示
7. 修复系统通知 gotify 配置
8. 其他 bug 修复