mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-14 06:32:02 +08:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e555fcbb0b | ||
|
|
83e8f6bccd | ||
|
|
5801f735bc | ||
|
|
53e1129a12 | ||
|
|
13c0a4d21b | ||
|
|
126fe8eb67 | ||
|
|
73c41eed22 | ||
|
|
40149b6f17 | ||
|
|
6532775a25 | ||
|
|
6469792546 | ||
|
|
e435f6e886 | ||
|
|
a0c09046c6 | ||
|
|
5a9e53c771 | ||
|
|
7334566d3c | ||
|
|
1503f07c75 | ||
|
|
0d33d2321a | ||
|
|
79964f149c | ||
|
|
bcb2471768 |
@@ -383,6 +383,19 @@ export default (app: Router) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
route.get(
|
||||||
|
'/notify-log',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const systemService = Container.get(SystemService);
|
||||||
|
const data = await systemService.getNotifyLog();
|
||||||
|
res.send({ code: 200, data });
|
||||||
|
} catch (e) {
|
||||||
|
return next(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
route.delete(
|
route.delete(
|
||||||
'/log',
|
'/log',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
|||||||
+38
-11
@@ -716,17 +716,20 @@ function isPyprojectToml(name: string): boolean {
|
|||||||
return name.endsWith('pyproject.toml');
|
return name.endsWith('pyproject.toml');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPythonSourceDependency(name: string): boolean {
|
||||||
|
const source = name.trim();
|
||||||
|
return (
|
||||||
|
isGitHubUrl(source) || isRequirementsFile(source) || isPyprojectToml(source)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getGetCommand(type: DependenceTypes, name: string): string {
|
export function getGetCommand(type: DependenceTypes, name: string): string {
|
||||||
const trimmedName = name.trim();
|
const trimmedName = name.trim();
|
||||||
|
|
||||||
// For Python dependencies installed from GitHub or requirements files,
|
// For Python dependencies installed from GitHub or requirements files,
|
||||||
// we can't reliably check if they're installed, so skip the check
|
// we can't reliably check if they're installed, so skip the check
|
||||||
if (type === DependenceTypes.python3) {
|
if (type === DependenceTypes.python3) {
|
||||||
if (
|
if (isPythonSourceDependency(trimmedName)) {
|
||||||
isGitHubUrl(trimmedName) ||
|
|
||||||
isRequirementsFile(trimmedName) ||
|
|
||||||
isPyprojectToml(trimmedName)
|
|
||||||
) {
|
|
||||||
// Return a command that will always indicate not installed
|
// Return a command that will always indicate not installed
|
||||||
// This ensures GitHub URLs and requirements files are always installed
|
// This ensures GitHub URLs and requirements files are always installed
|
||||||
return 'echo ""';
|
return 'echo ""';
|
||||||
@@ -736,10 +739,30 @@ export function getGetCommand(type: DependenceTypes, name: string): string {
|
|||||||
const baseCommands = {
|
const baseCommands = {
|
||||||
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${trimmedName}" | head -1`,
|
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${trimmedName}" | head -1`,
|
||||||
[DependenceTypes.python3]: `${getPythonExecutable()} -c ${shellQuote(
|
[DependenceTypes.python3]: `${getPythonExecutable()} -c ${shellQuote(
|
||||||
`from importlib.metadata import version\nimport importlib.util as u\nname=${JSON.stringify(
|
`from importlib.metadata import version, PackageNotFoundError
|
||||||
trimmedName,
|
import importlib.util
|
||||||
)}\ntry:\n print(version(name))\nexcept Exception:\n print(name if u.find_spec(name) else '')`,
|
import sys
|
||||||
)}`,
|
try:
|
||||||
|
try:
|
||||||
|
from packaging.requirements import Requirement
|
||||||
|
except ImportError:
|
||||||
|
from pip._vendor.packaging.requirements import Requirement
|
||||||
|
requirement = Requirement(sys.argv[1])
|
||||||
|
if requirement.marker and not requirement.marker.evaluate():
|
||||||
|
print('not applicable')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
installed = version(requirement.name)
|
||||||
|
except PackageNotFoundError:
|
||||||
|
if not requirement.specifier and not requirement.extras and not requirement.url:
|
||||||
|
if importlib.util.find_spec(requirement.name):
|
||||||
|
print(requirement.name)
|
||||||
|
else:
|
||||||
|
if requirement.specifier.contains(installed, prereleases=True):
|
||||||
|
print(installed)
|
||||||
|
except Exception:
|
||||||
|
pass`,
|
||||||
|
)} ${shellQuote(trimmedName)}`,
|
||||||
[DependenceTypes.linux]:
|
[DependenceTypes.linux]:
|
||||||
getOsTypeSync() === 'Alpine'
|
getOsTypeSync() === 'Alpine'
|
||||||
? `apk info -es ${trimmedName}`
|
? `apk info -es ${trimmedName}`
|
||||||
@@ -824,9 +847,13 @@ export function getUninstallCommand(
|
|||||||
'Remove installed Python distributions separately by package name.',
|
'Remove installed Python distributions separately by package name.',
|
||||||
)}`;
|
)}`;
|
||||||
}
|
}
|
||||||
|
const packageName =
|
||||||
|
name.match(
|
||||||
|
/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]+\])?(?=\s*(?:[<>=!~;@]|$))/,
|
||||||
|
)?.[1] || name;
|
||||||
return `${getPythonPip()} uninstall --disable-pip-version-check${
|
return `${getPythonPip()} uninstall --disable-pip-version-check${
|
||||||
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
|
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
|
||||||
} -y -- ${shellQuote(name)}`;
|
} -y -- ${shellQuote(packageName)}`;
|
||||||
}
|
}
|
||||||
const baseCommands = {
|
const baseCommands = {
|
||||||
[DependenceTypes.nodejs]: 'pnpm remove -g',
|
[DependenceTypes.nodejs]: 'pnpm remove -g',
|
||||||
@@ -917,7 +944,7 @@ async function _updateLinuxMirror(
|
|||||||
throw new Error('Unsupported OS type for updating mirrors.');
|
throw new Error('Unsupported OS type for updating mirrors.');
|
||||||
const target = mirror || defaults[osType];
|
const target = mirror || defaults[osType];
|
||||||
normalizeMirror(target);
|
normalizeMirror(target);
|
||||||
const S = isInContainer() ? 'sudo ' : '';
|
const S = osType !== 'Alpine' && isInContainer() ? 'sudo ' : '';
|
||||||
const update =
|
const update =
|
||||||
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
|
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
|
||||||
let file =
|
let file =
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ export enum AuthDataType {
|
|||||||
'removeLogFrequency' = 'removeLogFrequency',
|
'removeLogFrequency' = 'removeLogFrequency',
|
||||||
'systemConfig' = 'systemConfig',
|
'systemConfig' = 'systemConfig',
|
||||||
'authConfig' = 'authConfig',
|
'authConfig' = 'authConfig',
|
||||||
|
'notifyLog' = 'notifyLog',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const NotifyStatus = LoginStatus;
|
||||||
|
export type NotifyStatus = LoginStatus;
|
||||||
|
|
||||||
export interface SystemConfigInfo {
|
export interface SystemConfigInfo {
|
||||||
lang?: string;
|
lang?: string;
|
||||||
panelTitle?: string;
|
panelTitle?: string;
|
||||||
@@ -53,6 +57,17 @@ export interface LoginLogInfo {
|
|||||||
status?: LoginStatus;
|
status?: LoginStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NotifyLogInfo {
|
||||||
|
timestamp?: number;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
status?: NotifyStatus;
|
||||||
|
notifyType?: string;
|
||||||
|
source?: 'system' | 'script';
|
||||||
|
scriptName?: string;
|
||||||
|
taskId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TokenInfo {
|
export interface TokenInfo {
|
||||||
value: string;
|
value: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -88,6 +103,7 @@ export interface AuthInfo {
|
|||||||
export type SystemModelInfo = SystemConfigInfo &
|
export type SystemModelInfo = SystemConfigInfo &
|
||||||
Partial<NotificationInfo> &
|
Partial<NotificationInfo> &
|
||||||
LoginLogInfo &
|
LoginLogInfo &
|
||||||
|
Partial<NotifyLogInfo> &
|
||||||
Partial<AuthInfo>;
|
Partial<AuthInfo>;
|
||||||
|
|
||||||
export interface SystemInstance
|
export interface SystemInstance
|
||||||
|
|||||||
@@ -260,6 +260,15 @@ message SystemNotifyRequest {
|
|||||||
optional NotificationInfo notificationInfo = 3;
|
optional NotificationInfo notificationInfo = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message RecordNotifyLogRequest {
|
||||||
|
string title = 1;
|
||||||
|
string content = 2;
|
||||||
|
bool success = 3;
|
||||||
|
string notifyType = 4;
|
||||||
|
string scriptName = 5;
|
||||||
|
string taskId = 6;
|
||||||
|
}
|
||||||
|
|
||||||
service Api {
|
service Api {
|
||||||
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
|
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
|
||||||
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
|
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
|
||||||
@@ -271,6 +280,7 @@ service Api {
|
|||||||
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
|
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
|
||||||
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
|
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
|
||||||
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
|
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
|
||||||
|
rpc RecordNotifyLog(RecordNotifyLogRequest) returns (Response) {}
|
||||||
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
|
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
|
||||||
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
|
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
|
||||||
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
|
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
|
||||||
|
|||||||
+175
-1
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-ts_proto v2.6.1
|
// protoc-gen-ts_proto v2.6.1
|
||||||
// protoc v3.17.3
|
// protoc v6.31.1
|
||||||
// source: back/protos/api.proto
|
// source: back/protos/api.proto
|
||||||
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
@@ -417,6 +417,15 @@ export interface SystemNotifyRequest {
|
|||||||
notificationInfo?: NotificationInfo | undefined;
|
notificationInfo?: NotificationInfo | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecordNotifyLogRequest {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
success: boolean;
|
||||||
|
notifyType: string;
|
||||||
|
scriptName: string;
|
||||||
|
taskId: string;
|
||||||
|
}
|
||||||
|
|
||||||
function createBaseEnvItem(): EnvItem {
|
function createBaseEnvItem(): EnvItem {
|
||||||
return {
|
return {
|
||||||
id: undefined,
|
id: undefined,
|
||||||
@@ -4296,6 +4305,146 @@ export const SystemNotifyRequest: MessageFns<SystemNotifyRequest> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function createBaseRecordNotifyLogRequest(): RecordNotifyLogRequest {
|
||||||
|
return { title: "", content: "", success: false, notifyType: "", scriptName: "", taskId: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RecordNotifyLogRequest: MessageFns<RecordNotifyLogRequest> = {
|
||||||
|
encode(message: RecordNotifyLogRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||||
|
if (message.title !== "") {
|
||||||
|
writer.uint32(10).string(message.title);
|
||||||
|
}
|
||||||
|
if (message.content !== "") {
|
||||||
|
writer.uint32(18).string(message.content);
|
||||||
|
}
|
||||||
|
if (message.success !== false) {
|
||||||
|
writer.uint32(24).bool(message.success);
|
||||||
|
}
|
||||||
|
if (message.notifyType !== "") {
|
||||||
|
writer.uint32(34).string(message.notifyType);
|
||||||
|
}
|
||||||
|
if (message.scriptName !== "") {
|
||||||
|
writer.uint32(42).string(message.scriptName);
|
||||||
|
}
|
||||||
|
if (message.taskId !== "") {
|
||||||
|
writer.uint32(50).string(message.taskId);
|
||||||
|
}
|
||||||
|
return writer;
|
||||||
|
},
|
||||||
|
|
||||||
|
decode(input: BinaryReader | Uint8Array, length?: number): RecordNotifyLogRequest {
|
||||||
|
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||||
|
let end = length === undefined ? reader.len : reader.pos + length;
|
||||||
|
const message = createBaseRecordNotifyLogRequest();
|
||||||
|
while (reader.pos < end) {
|
||||||
|
const tag = reader.uint32();
|
||||||
|
switch (tag >>> 3) {
|
||||||
|
case 1: {
|
||||||
|
if (tag !== 10) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.title = reader.string();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 2: {
|
||||||
|
if (tag !== 18) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.content = reader.string();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 3: {
|
||||||
|
if (tag !== 24) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success = reader.bool();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 4: {
|
||||||
|
if (tag !== 34) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.notifyType = reader.string();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 5: {
|
||||||
|
if (tag !== 42) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.scriptName = reader.string();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case 6: {
|
||||||
|
if (tag !== 50) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.taskId = reader.string();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((tag & 7) === 4 || tag === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
reader.skip(tag & 7);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
},
|
||||||
|
|
||||||
|
fromJSON(object: any): RecordNotifyLogRequest {
|
||||||
|
return {
|
||||||
|
title: isSet(object.title) ? globalThis.String(object.title) : "",
|
||||||
|
content: isSet(object.content) ? globalThis.String(object.content) : "",
|
||||||
|
success: isSet(object.success) ? globalThis.Boolean(object.success) : false,
|
||||||
|
notifyType: isSet(object.notifyType) ? globalThis.String(object.notifyType) : "",
|
||||||
|
scriptName: isSet(object.scriptName) ? globalThis.String(object.scriptName) : "",
|
||||||
|
taskId: isSet(object.taskId) ? globalThis.String(object.taskId) : "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
toJSON(message: RecordNotifyLogRequest): unknown {
|
||||||
|
const obj: any = {};
|
||||||
|
if (message.title !== "") {
|
||||||
|
obj.title = message.title;
|
||||||
|
}
|
||||||
|
if (message.content !== "") {
|
||||||
|
obj.content = message.content;
|
||||||
|
}
|
||||||
|
if (message.success !== false) {
|
||||||
|
obj.success = message.success;
|
||||||
|
}
|
||||||
|
if (message.notifyType !== "") {
|
||||||
|
obj.notifyType = message.notifyType;
|
||||||
|
}
|
||||||
|
if (message.scriptName !== "") {
|
||||||
|
obj.scriptName = message.scriptName;
|
||||||
|
}
|
||||||
|
if (message.taskId !== "") {
|
||||||
|
obj.taskId = message.taskId;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
},
|
||||||
|
|
||||||
|
create<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(base?: I): RecordNotifyLogRequest {
|
||||||
|
return RecordNotifyLogRequest.fromPartial(base ?? ({} as any));
|
||||||
|
},
|
||||||
|
fromPartial<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(object: I): RecordNotifyLogRequest {
|
||||||
|
const message = createBaseRecordNotifyLogRequest();
|
||||||
|
message.title = object.title ?? "";
|
||||||
|
message.content = object.content ?? "";
|
||||||
|
message.success = object.success ?? false;
|
||||||
|
message.notifyType = object.notifyType ?? "";
|
||||||
|
message.scriptName = object.scriptName ?? "";
|
||||||
|
message.taskId = object.taskId ?? "";
|
||||||
|
return message;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export type ApiService = typeof ApiService;
|
export type ApiService = typeof ApiService;
|
||||||
export const ApiService = {
|
export const ApiService = {
|
||||||
getEnvs: {
|
getEnvs: {
|
||||||
@@ -4388,6 +4537,15 @@ export const ApiService = {
|
|||||||
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
|
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
|
||||||
responseDeserialize: (value: Buffer) => Response.decode(value),
|
responseDeserialize: (value: Buffer) => Response.decode(value),
|
||||||
},
|
},
|
||||||
|
recordNotifyLog: {
|
||||||
|
path: "/com.ql.api.Api/RecordNotifyLog",
|
||||||
|
requestStream: false,
|
||||||
|
responseStream: false,
|
||||||
|
requestSerialize: (value: RecordNotifyLogRequest) => Buffer.from(RecordNotifyLogRequest.encode(value).finish()),
|
||||||
|
requestDeserialize: (value: Buffer) => RecordNotifyLogRequest.decode(value),
|
||||||
|
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
|
||||||
|
responseDeserialize: (value: Buffer) => Response.decode(value),
|
||||||
|
},
|
||||||
getCronDetail: {
|
getCronDetail: {
|
||||||
path: "/com.ql.api.Api/GetCronDetail",
|
path: "/com.ql.api.Api/GetCronDetail",
|
||||||
requestStream: false,
|
requestStream: false,
|
||||||
@@ -4482,6 +4640,7 @@ export interface ApiServer extends UntypedServiceImplementation {
|
|||||||
updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>;
|
updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>;
|
||||||
getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>;
|
getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>;
|
||||||
systemNotify: handleUnaryCall<SystemNotifyRequest, Response>;
|
systemNotify: handleUnaryCall<SystemNotifyRequest, Response>;
|
||||||
|
recordNotifyLog: handleUnaryCall<RecordNotifyLogRequest, Response>;
|
||||||
getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>;
|
getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>;
|
||||||
createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
|
createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
|
||||||
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
|
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
|
||||||
@@ -4644,6 +4803,21 @@ export interface ApiClient extends Client {
|
|||||||
options: Partial<CallOptions>,
|
options: Partial<CallOptions>,
|
||||||
callback: (error: ServiceError | null, response: Response) => void,
|
callback: (error: ServiceError | null, response: Response) => void,
|
||||||
): ClientUnaryCall;
|
): ClientUnaryCall;
|
||||||
|
recordNotifyLog(
|
||||||
|
request: RecordNotifyLogRequest,
|
||||||
|
callback: (error: ServiceError | null, response: Response) => void,
|
||||||
|
): ClientUnaryCall;
|
||||||
|
recordNotifyLog(
|
||||||
|
request: RecordNotifyLogRequest,
|
||||||
|
metadata: Metadata,
|
||||||
|
callback: (error: ServiceError | null, response: Response) => void,
|
||||||
|
): ClientUnaryCall;
|
||||||
|
recordNotifyLog(
|
||||||
|
request: RecordNotifyLogRequest,
|
||||||
|
metadata: Metadata,
|
||||||
|
options: Partial<CallOptions>,
|
||||||
|
callback: (error: ServiceError | null, response: Response) => void,
|
||||||
|
): ClientUnaryCall;
|
||||||
getCronDetail(
|
getCronDetail(
|
||||||
request: CronDetailRequest,
|
request: CronDetailRequest,
|
||||||
callback: (error: ServiceError | null, response: CronDetailResponse) => void,
|
callback: (error: ServiceError | null, response: CronDetailResponse) => void,
|
||||||
|
|||||||
+25
-2
@@ -16,6 +16,7 @@ import {
|
|||||||
MoveEnvRequest,
|
MoveEnvRequest,
|
||||||
Response,
|
Response,
|
||||||
SystemNotifyRequest,
|
SystemNotifyRequest,
|
||||||
|
RecordNotifyLogRequest,
|
||||||
UpdateEnvNamesRequest,
|
UpdateEnvNamesRequest,
|
||||||
UpdateEnvRequest,
|
UpdateEnvRequest,
|
||||||
} from '../protos/api';
|
} from '../protos/api';
|
||||||
@@ -238,7 +239,8 @@ export const systemNotify = async (
|
|||||||
const data = await systemService.notify({
|
const data = await systemService.notify({
|
||||||
title: call.request.title,
|
title: call.request.title,
|
||||||
content: call.request.content,
|
content: call.request.content,
|
||||||
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo,
|
notificationInfo: call.request
|
||||||
|
.notificationInfo as unknown as NotificationInfo,
|
||||||
});
|
});
|
||||||
callback(null, data);
|
callback(null, data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -246,10 +248,31 @@ export const systemNotify = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const recordNotifyLog = async (
|
||||||
|
call: ServerUnaryCall<RecordNotifyLogRequest, Response>,
|
||||||
|
callback: sendUnaryData<Response>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await Container.get(SystemService).recordNotifyLog({
|
||||||
|
title: call.request.title,
|
||||||
|
content: call.request.content,
|
||||||
|
status: call.request.success ? 0 : 1,
|
||||||
|
notifyType: call.request.notifyType,
|
||||||
|
source: 'script',
|
||||||
|
scriptName: call.request.scriptName,
|
||||||
|
taskId: call.request.taskId,
|
||||||
|
});
|
||||||
|
callback(null, { code: 200 });
|
||||||
|
} catch (error: any) {
|
||||||
|
callback(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
|
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
|
||||||
if (!data) return undefined;
|
if (!data) return undefined;
|
||||||
// create() returns a Sequelize instance; spreading it omits attribute getters.
|
// create() returns a Sequelize instance; spreading it omits attribute getters.
|
||||||
const cron = data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
|
const cron =
|
||||||
|
data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
|
||||||
return {
|
return {
|
||||||
...cron,
|
...cron,
|
||||||
labels: cron.labels ?? [],
|
labels: cron.labels ?? [],
|
||||||
|
|||||||
+35
-12
@@ -20,6 +20,7 @@ import {
|
|||||||
getInstallCommand,
|
getInstallCommand,
|
||||||
getUninstallCommand,
|
getUninstallCommand,
|
||||||
getGetCommand,
|
getGetCommand,
|
||||||
|
isPythonSourceDependency,
|
||||||
} from '../config/util';
|
} from '../config/util';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import taskLimit from '../shared/pLimit';
|
import taskLimit from '../shared/pLimit';
|
||||||
@@ -76,21 +77,25 @@ export default class DependenceService {
|
|||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
taskLimit.removeQueuedDependency(doc);
|
taskLimit.removeQueuedDependency(doc);
|
||||||
}
|
}
|
||||||
const unInstalledDeps = docs.filter(
|
|
||||||
(x) => x.status !== DependenceStatus.installed,
|
|
||||||
);
|
|
||||||
const installedDeps = docs.filter(
|
const installedDeps = docs.filter(
|
||||||
(x) => x.status === DependenceStatus.installed,
|
(x) =>
|
||||||
|
x.status === DependenceStatus.installed ||
|
||||||
|
// Older list refreshes marked unprobeable sources as failed. They may
|
||||||
|
// still have installed distributions (including partial pip installs).
|
||||||
|
(x.status === DependenceStatus.installFailed &&
|
||||||
|
x.type === DependenceTypes.python3 &&
|
||||||
|
isPythonSourceDependency(x.name)),
|
||||||
);
|
);
|
||||||
|
const unInstalledDeps = docs.filter((x) => !installedDeps.includes(x));
|
||||||
await this.removeDb(unInstalledDeps.map((x) => x.id!));
|
await this.removeDb(unInstalledDeps.map((x) => x.id!));
|
||||||
|
|
||||||
if (installedDeps.length) {
|
if (installedDeps.length) {
|
||||||
await DependenceModel.update(
|
await DependenceModel.update(
|
||||||
{ status: DependenceStatus.queued, log: [] },
|
{ status: DependenceStatus.queued, log: [] },
|
||||||
{ where: { id: ids } },
|
{ where: { id: installedDeps.map((x) => x.id!) } },
|
||||||
);
|
);
|
||||||
|
|
||||||
this.installDependenceOneByOne(docs, false, force);
|
this.installDependenceOneByOne(installedDeps, false, force);
|
||||||
}
|
}
|
||||||
return docs;
|
return docs;
|
||||||
}
|
}
|
||||||
@@ -156,9 +161,15 @@ export default class DependenceService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const checks = await concurrentRun(
|
const checks = await concurrentRun(
|
||||||
docs.map((doc) => async () => {
|
docs
|
||||||
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
|
.filter(
|
||||||
}),
|
(doc) =>
|
||||||
|
doc.type !== DependenceTypes.python3 ||
|
||||||
|
!isPythonSourceDependency(doc.name),
|
||||||
|
)
|
||||||
|
.map((doc) => async () => {
|
||||||
|
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
|
||||||
|
}),
|
||||||
5,
|
5,
|
||||||
);
|
);
|
||||||
const missingIds = (checks || []).filter(
|
const missingIds = (checks || []).filter(
|
||||||
@@ -177,7 +188,10 @@ export default class DependenceService {
|
|||||||
let depName = dependency.name.trim();
|
let depName = dependency.name.trim();
|
||||||
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
||||||
let depVersion = '';
|
let depVersion = '';
|
||||||
if (depName.includes(depVersionStr)) {
|
if (
|
||||||
|
dependency.type !== DependenceTypes.python3 &&
|
||||||
|
depName.includes(depVersionStr)
|
||||||
|
) {
|
||||||
const symbolRegx = new RegExp(
|
const symbolRegx = new RegExp(
|
||||||
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
|
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
|
||||||
);
|
);
|
||||||
@@ -305,6 +319,9 @@ export default class DependenceService {
|
|||||||
: getUninstallCommand(dependency.type, dependency.name.trim());
|
: getUninstallCommand(dependency.type, dependency.name.trim());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
taskLimit.removeQueuedDependency(dependency);
|
taskLimit.removeQueuedDependency(dependency);
|
||||||
|
if (!isInstall && force) {
|
||||||
|
return this.removeDb([dependency.id!]).then(() => null);
|
||||||
|
}
|
||||||
return DependenceModel.update(
|
return DependenceModel.update(
|
||||||
{
|
{
|
||||||
status: isInstall
|
status: isInstall
|
||||||
@@ -387,7 +404,13 @@ export default class DependenceService {
|
|||||||
this.updateLog(depIds, message);
|
this.updateLog(depIds, message);
|
||||||
|
|
||||||
// 判断是否已经安装过依赖
|
// 判断是否已经安装过依赖
|
||||||
if (isInstall && !force) {
|
// pip must resolve optional dependencies even if the base distribution
|
||||||
|
// is already present; its version alone cannot prove extras are ready.
|
||||||
|
if (
|
||||||
|
isInstall &&
|
||||||
|
!force &&
|
||||||
|
!(isPythonDependence && depName.includes('['))
|
||||||
|
) {
|
||||||
let getCommand = getGetCommand(dependency.type, depName);
|
let getCommand = getGetCommand(dependency.type, depName);
|
||||||
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
||||||
if (isLinuxDependence) {
|
if (isLinuxDependence) {
|
||||||
@@ -395,7 +418,7 @@ export default class DependenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let depVersion = '';
|
let depVersion = '';
|
||||||
if (depName.includes(depVersionStr)) {
|
if (!isPythonDependence && depName.includes(depVersionStr)) {
|
||||||
const symbolRegx = new RegExp(
|
const symbolRegx = new RegExp(
|
||||||
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
|
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
|
||||||
);
|
);
|
||||||
|
|||||||
+41
-25
@@ -1,3 +1,4 @@
|
|||||||
|
import { persistNotificationAudit } from '../shared/notificationAudit';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { Inject, Service } from 'typedi';
|
import { Inject, Service } from 'typedi';
|
||||||
@@ -55,27 +56,33 @@ export default class NotificationService {
|
|||||||
content: string,
|
content: string,
|
||||||
notificationInfo?: NotificationInfo,
|
notificationInfo?: NotificationInfo,
|
||||||
): Promise<boolean | undefined> {
|
): Promise<boolean | undefined> {
|
||||||
let { type, ...rest } = await this.userService.getNotificationMode();
|
let type: string | undefined;
|
||||||
if (notificationInfo?.type) {
|
let success = false;
|
||||||
type = notificationInfo?.type;
|
try {
|
||||||
}
|
const saved = await this.userService.getNotificationMode();
|
||||||
if (type) {
|
type = notificationInfo?.type || saved.type;
|
||||||
this.title = title;
|
if (type) {
|
||||||
this.content = content;
|
this.title = title;
|
||||||
let params = rest;
|
this.content = content;
|
||||||
if (notificationInfo) {
|
const { type: _, ...overrides } = notificationInfo || {};
|
||||||
const { type: _, ...others } = notificationInfo;
|
const { type: _saved, ...rest } = saved;
|
||||||
params = { ...rest, ...others };
|
this.params = { ...rest, ...overrides };
|
||||||
}
|
try {
|
||||||
this.params = params;
|
success = !!(await this.modeMap.get(type)?.call(this));
|
||||||
const notificationModeAction = this.modeMap.get(type);
|
} catch (error) {
|
||||||
try {
|
console.error(error);
|
||||||
return await notificationModeAction?.call(this);
|
}
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
|
return success;
|
||||||
|
} finally {
|
||||||
|
await persistNotificationAudit({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
notifyType: type,
|
||||||
|
source: 'system',
|
||||||
|
status: success ? 0 : 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async testNotify(
|
public async testNotify(
|
||||||
@@ -84,14 +91,23 @@ export default class NotificationService {
|
|||||||
content: string,
|
content: string,
|
||||||
) {
|
) {
|
||||||
const { type, ...rest } = info;
|
const { type, ...rest } = info;
|
||||||
if (type) {
|
if (!type) return true;
|
||||||
|
let success = false;
|
||||||
|
try {
|
||||||
this.title = title;
|
this.title = title;
|
||||||
this.content = content;
|
this.content = content;
|
||||||
this.params = rest;
|
this.params = rest;
|
||||||
const notificationModeAction = this.modeMap.get(type);
|
success = !!(await this.modeMap.get(type)?.call(this));
|
||||||
return await notificationModeAction?.call(this);
|
return success;
|
||||||
|
} finally {
|
||||||
|
await persistNotificationAudit({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
notifyType: type,
|
||||||
|
source: 'system',
|
||||||
|
status: success ? 0 : 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseMailRecipients(value?: string) {
|
private parseMailRecipients(value?: string) {
|
||||||
@@ -921,7 +937,8 @@ export default class NotificationService {
|
|||||||
private async openiLink() {
|
private async openiLink() {
|
||||||
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
|
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
|
||||||
this.params;
|
this.params;
|
||||||
const baseUrl = openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
|
const baseUrl =
|
||||||
|
openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
|
||||||
const url = `${baseUrl}/bot/v1/message/send`;
|
const url = `${baseUrl}/bot/v1/message/send`;
|
||||||
const body: Record<string, string> = {
|
const body: Record<string, string> = {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -974,5 +991,4 @@ export default class NotificationService {
|
|||||||
throw new Error(error.response ? error.response.body : error);
|
throw new Error(error.response ? error.response.body : error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-15
@@ -1,3 +1,4 @@
|
|||||||
|
import { persistNotificationAudit } from '../shared/notificationAudit';
|
||||||
import { spawn } from 'cross-spawn';
|
import { spawn } from 'cross-spawn';
|
||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
@@ -32,6 +33,8 @@ import {
|
|||||||
SystemInstance,
|
SystemInstance,
|
||||||
SystemModel,
|
SystemModel,
|
||||||
SystemModelInfo,
|
SystemModelInfo,
|
||||||
|
NotifyStatus,
|
||||||
|
NotifyLogInfo,
|
||||||
} from '../data/system';
|
} from '../data/system';
|
||||||
import taskLimit from '../shared/pLimit';
|
import taskLimit from '../shared/pLimit';
|
||||||
import cronClient from '../schedule/client';
|
import cronClient from '../schedule/client';
|
||||||
@@ -53,7 +56,7 @@ export default class SystemService {
|
|||||||
@Inject('logger') private logger: winston.Logger,
|
@Inject('logger') private logger: winston.Logger,
|
||||||
private scheduleService: ScheduleService,
|
private scheduleService: ScheduleService,
|
||||||
private sockService: SockService,
|
private sockService: SockService,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
public async getSystemConfig() {
|
public async getSystemConfig() {
|
||||||
const doc = await this.getDb({ type: AuthDataType.systemConfig });
|
const doc = await this.getDb({ type: AuthDataType.systemConfig });
|
||||||
@@ -158,7 +161,9 @@ export default class SystemService {
|
|||||||
if (info.dependenceProxy) {
|
if (info.dependenceProxy) {
|
||||||
await fs.promises.writeFile(
|
await fs.promises.writeFile(
|
||||||
config.dependenceProxyFile,
|
config.dependenceProxyFile,
|
||||||
`export http_proxy=${shellQuote(info.dependenceProxy)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
|
`export http_proxy=${shellQuote(
|
||||||
|
info.dependenceProxy,
|
||||||
|
)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await fs.promises.rm(config.dependenceProxyFile);
|
await fs.promises.rm(config.dependenceProxyFile);
|
||||||
@@ -293,7 +298,7 @@ export default class SystemService {
|
|||||||
);
|
);
|
||||||
const text = await body.text();
|
const text = await body.text();
|
||||||
lastVersionContent = parseContentVersion(text);
|
lastVersionContent = parseContentVersion(text);
|
||||||
} catch (error) { }
|
} catch (error) {}
|
||||||
|
|
||||||
if (!lastVersionContent) {
|
if (!lastVersionContent) {
|
||||||
lastVersionContent = currentVersionContent;
|
lastVersionContent = currentVersionContent;
|
||||||
@@ -407,11 +412,13 @@ export default class SystemService {
|
|||||||
if (notificationInfo && typeString) {
|
if (notificationInfo && typeString) {
|
||||||
notificationInfo.type = typeString;
|
notificationInfo.type = typeString;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSuccess = await this.notificationService.notify(
|
const isSuccess = await this.notificationService.notify(
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
notificationInfo,
|
notificationInfo,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
return { code: 200, message: t('通知发送成功') };
|
return { code: 200, message: t('通知发送成功') };
|
||||||
} else {
|
} else {
|
||||||
@@ -419,16 +426,36 @@ export default class SystemService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) {
|
public async recordNotifyLog(info: NotifyLogInfo): Promise<void> {
|
||||||
|
await persistNotificationAudit(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getNotifyLog(): Promise<Array<NotifyLogInfo>> {
|
||||||
|
const docs = await SystemModel.findAll({
|
||||||
|
where: { type: AuthDataType.notifyLog },
|
||||||
|
order: [['id', 'DESC']],
|
||||||
|
limit: 200,
|
||||||
|
});
|
||||||
|
return docs.map((x) => ({ ...x.info, id: x.id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async run(
|
||||||
|
{ command, logPath }: { command: string; logPath?: string },
|
||||||
|
callback: TaskCallbacks,
|
||||||
|
) {
|
||||||
if (!command.startsWith(TASK_COMMAND)) {
|
if (!command.startsWith(TASK_COMMAND)) {
|
||||||
command = `${TASK_COMMAND} ${command}`;
|
command = `${TASK_COMMAND} ${command}`;
|
||||||
}
|
}
|
||||||
const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''
|
const logPathPrefix = logPath ? `real_log_path=${logPath}` : '';
|
||||||
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, {
|
this.scheduleService.runTask(
|
||||||
command,
|
`${logPathPrefix} real_time=true ${command}`,
|
||||||
id: command.replace(/ /g, '-'),
|
callback,
|
||||||
runOrigin: 'system',
|
{
|
||||||
});
|
command,
|
||||||
|
id: command.replace(/ /g, '-'),
|
||||||
|
runOrigin: 'system',
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop({ command, pid }: { command: string; pid: number }) {
|
public async stop({ command, pid }: { command: string; pid: number }) {
|
||||||
@@ -459,8 +486,20 @@ export default class SystemService {
|
|||||||
if (type && type.length) {
|
if (type && type.length) {
|
||||||
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
|
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
|
||||||
}
|
}
|
||||||
const allowed = new Set(['db', 'upload', 'config', 'scripts', 'log', 'deps',
|
const allowed = new Set([
|
||||||
'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d', 'script-history']);
|
'db',
|
||||||
|
'upload',
|
||||||
|
'config',
|
||||||
|
'scripts',
|
||||||
|
'log',
|
||||||
|
'deps',
|
||||||
|
'syslog',
|
||||||
|
'dep_cache',
|
||||||
|
'raw',
|
||||||
|
'repo',
|
||||||
|
'ssh.d',
|
||||||
|
'script-history',
|
||||||
|
]);
|
||||||
if (dataDirs.some((dir) => !allowed.has(dir))) {
|
if (dataDirs.some((dir) => !allowed.has(dir))) {
|
||||||
return res.status(400).send({ code: 400, message: t('参数错误') });
|
return res.status(400).send({ code: 400, message: t('参数错误') });
|
||||||
}
|
}
|
||||||
@@ -470,9 +509,13 @@ export default class SystemService {
|
|||||||
dataDirs = dataDirs.filter((dir) => dir !== 'script-history');
|
dataDirs = dataDirs.filter((dir) => dir !== 'script-history');
|
||||||
}
|
}
|
||||||
const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`);
|
const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`);
|
||||||
await promisify(execFile)('tar', ['-zcf', config.dataTgzFile, '--', ...dataPaths], {
|
await promisify(execFile)(
|
||||||
cwd: path.dirname(config.dataPath),
|
'tar',
|
||||||
});
|
['-zcf', config.dataTgzFile, '--', ...dataPaths],
|
||||||
|
{
|
||||||
|
cwd: path.dirname(config.dataPath),
|
||||||
|
},
|
||||||
|
);
|
||||||
res.download(config.dataTgzFile);
|
res.download(config.dataTgzFile);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return res.send({ code: 400, message: error.message });
|
return res.send({ code: 400, message: error.message });
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Op } from 'sequelize';
|
||||||
|
import {
|
||||||
|
SystemModel,
|
||||||
|
AuthDataType,
|
||||||
|
NotifyStatus,
|
||||||
|
NotifyLogInfo,
|
||||||
|
} from '../data/system';
|
||||||
|
|
||||||
|
// Both backend sends and script reports use the same bounded, best-effort sink.
|
||||||
|
export async function persistNotificationAudit(
|
||||||
|
info: NotifyLogInfo,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await SystemModel.create({
|
||||||
|
type: AuthDataType.notifyLog,
|
||||||
|
info: {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
title: String(info.title || '').slice(0, 512),
|
||||||
|
content: String(info.content || '').slice(0, 8192),
|
||||||
|
status:
|
||||||
|
info.status === NotifyStatus.success
|
||||||
|
? NotifyStatus.success
|
||||||
|
: NotifyStatus.fail,
|
||||||
|
notifyType: String(info.notifyType || '').slice(0, 64),
|
||||||
|
source: info.source === 'script' ? 'script' : 'system',
|
||||||
|
scriptName: String(info.scriptName || '').slice(0, 512),
|
||||||
|
taskId: /^\d+$/.test(info.taskId || '') ? info.taskId : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const oldest = await SystemModel.findAll({
|
||||||
|
where: { type: AuthDataType.notifyLog },
|
||||||
|
attributes: ['id'],
|
||||||
|
order: [['id', 'DESC']],
|
||||||
|
offset: 200,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
if (oldest[0]?.id) {
|
||||||
|
await SystemModel.destroy({
|
||||||
|
where: {
|
||||||
|
type: AuthDataType.notifyLog,
|
||||||
|
id: { [Op.lte]: oldest[0].id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to persist notification audit log');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,12 @@ export interface OpenOperation {
|
|||||||
anonymous?: boolean;
|
anonymous?: boolean;
|
||||||
}
|
}
|
||||||
export const openOperations: readonly OpenOperation[] = [
|
export const openOperations: readonly OpenOperation[] = [
|
||||||
|
{
|
||||||
|
name: 'system notification-logs',
|
||||||
|
method: 'GET',
|
||||||
|
path: 'system/notify-log',
|
||||||
|
},
|
||||||
|
|
||||||
{ name: 'task view-list', method: 'GET', path: 'crons/views' },
|
{ name: 'task view-list', method: 'GET', path: 'crons/views' },
|
||||||
{
|
{
|
||||||
name: 'task view-create',
|
name: 'task view-create',
|
||||||
@@ -478,7 +484,12 @@ export const openOperations: readonly OpenOperation[] = [
|
|||||||
path: 'system/config/dependence-clean',
|
path: 'system/config/dependence-clean',
|
||||||
body: 'json',
|
body: 'json',
|
||||||
},
|
},
|
||||||
{ name: 'dashboard record', method: 'POST', path: 'dashboard/record', body: 'json' },
|
{
|
||||||
|
name: 'dashboard record',
|
||||||
|
method: 'POST',
|
||||||
|
path: 'dashboard/record',
|
||||||
|
body: 'json',
|
||||||
|
},
|
||||||
{ name: 'dashboard overview', method: 'GET', path: 'dashboard/overview' },
|
{ name: 'dashboard overview', method: 'GET', path: 'dashboard/overview' },
|
||||||
{ name: 'dashboard trend', method: 'GET', path: 'dashboard/trend' },
|
{ name: 'dashboard trend', method: 'GET', path: 'dashboard/trend' },
|
||||||
{ name: 'dashboard top-time', method: 'GET', path: 'dashboard/top-time' },
|
{ name: 'dashboard top-time', method: 'GET', path: 'dashboard/top-time' },
|
||||||
|
|||||||
@@ -119,12 +119,12 @@ reload_pm2
|
|||||||
|
|
||||||
if [[ $AutoStartBot == true ]]; then
|
if [[ $AutoStartBot == true ]]; then
|
||||||
log_with_style "INFO" "🤖 3. 启动 bot..."
|
log_with_style "INFO" "🤖 3. 启动 bot..."
|
||||||
nohup ql bot >$dir_log/bot.log 2>&1 &
|
nohup ql bot >"$dir_log/bot.log" 2>&1 &
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $EnableExtraShell == true ]]; then
|
if [[ $EnableExtraShell == true ]]; then
|
||||||
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
|
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
|
||||||
nohup ql extra >$dir_log/extra.log 2>&1 &
|
nohup ql extra >"$dir_log/extra.log" 2>&1 &
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_with_style "SUCCESS" "🎉 容器启动成功!"
|
log_with_style "SUCCESS" "🎉 容器启动成功!"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 通知日志
|
||||||
|
|
||||||
|
通知日志记录系统统一通知入口(包括登录通知、配置测试和系统通知 API),以及任务使用内置 `notify.js`、`notify.py` 或 `QLAPI.notify` 发出的通知。脚本通知按实际配置的渠道各记录一条结果,未配置渠道和跳过的标题不记账。
|
||||||
|
|
||||||
|
日志包含时间、标题、正文、渠道、结果和来源;脚本来源另包含文件名与任务 ID。成功表示通知渠道返回成功或接受请求,不表示最终收件人已经收到。多目标渠道只在所有目标成功时记为成功。
|
||||||
|
|
||||||
|
所有入口共用数据库中的最近 200 条日志。标题最多 512 字符、正文最多 8192 字符;上报字段不包含通知密码、令牌或渠道配置。日志页受系统设置的管理员权限保护。
|
||||||
|
|
||||||
|
脚本通过现有的本机 mTLS gRPC 服务上报,独立客户端最多等待 1.5 秒。旧面板、服务不可用或数据库写入失败不会改变通知发送结果。Python 使用 Node 上报子进程,并限制在 3 秒内。
|
||||||
|
|
||||||
|
自行保存的旧版通知库需要更新为面板内置版本才能上报。脚本自行调用 HTTP、SMTP 或第三方 SDK 时不经过这些入口,无法自动纳入通知日志。
|
||||||
+4
-2
@@ -77,7 +77,9 @@
|
|||||||
"@codemirror/state": "6.5.4",
|
"@codemirror/state": "6.5.4",
|
||||||
"@codemirror/view": "6.39.16",
|
"@codemirror/view": "6.39.16",
|
||||||
"proxy-addr": "2.0.8",
|
"proxy-addr": "2.0.8",
|
||||||
"websocket-driver": "0.7.5"
|
"websocket-driver": "0.7.5",
|
||||||
|
"brace-expansion@1": "1.1.21",
|
||||||
|
"brace-expansion@2": "2.1.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -172,7 +174,7 @@
|
|||||||
"concurrently": "^7.0.0",
|
"concurrently": "^7.0.0",
|
||||||
"file-saver": "2.0.2",
|
"file-saver": "2.0.2",
|
||||||
"lint-staged": "^13.0.3",
|
"lint-staged": "^13.0.3",
|
||||||
"moment": "2.30.1",
|
"moment": "2.31.0",
|
||||||
"monaco-editor": "0.33.0",
|
"monaco-editor": "0.33.0",
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
|
|||||||
Generated
+14
-23
@@ -6,6 +6,8 @@ overrides:
|
|||||||
'@codemirror/view': 6.39.16
|
'@codemirror/view': 6.39.16
|
||||||
proxy-addr: 2.0.8
|
proxy-addr: 2.0.8
|
||||||
websocket-driver: 0.7.5
|
websocket-driver: 0.7.5
|
||||||
|
brace-expansion@1: 1.1.21
|
||||||
|
brace-expansion@2: 2.1.7
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ant-design/plots':
|
'@ant-design/plots':
|
||||||
@@ -278,8 +280,8 @@ devDependencies:
|
|||||||
specifier: ^13.0.3
|
specifier: ^13.0.3
|
||||||
version: 13.3.0
|
version: 13.3.0
|
||||||
moment:
|
moment:
|
||||||
specifier: 2.30.1
|
specifier: 2.31.0
|
||||||
version: 2.30.1
|
version: 2.31.0
|
||||||
monaco-editor:
|
monaco-editor:
|
||||||
specifier: 0.33.0
|
specifier: 0.33.0
|
||||||
version: 0.33.0
|
version: 0.33.0
|
||||||
@@ -876,7 +878,7 @@ packages:
|
|||||||
'@babel/runtime': 7.26.0
|
'@babel/runtime': 7.26.0
|
||||||
antd: 4.24.16(react-dom@18.3.1)(react@18.3.1)
|
antd: 4.24.16(react-dom@18.3.1)(react@18.3.1)
|
||||||
classnames: 2.5.1
|
classnames: 2.5.1
|
||||||
moment: 2.30.1
|
moment: 2.31.0
|
||||||
rc-util: 5.43.0(react-dom@18.3.1)(react@18.3.1)
|
rc-util: 5.43.0(react-dom@18.3.1)(react@18.3.1)
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
@@ -5369,7 +5371,7 @@ packages:
|
|||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
intl: 1.2.5
|
intl: 1.2.5
|
||||||
lodash: 4.17.21
|
lodash: 4.17.21
|
||||||
moment: 2.30.1
|
moment: 2.31.0
|
||||||
qiankun: 2.10.16
|
qiankun: 2.10.16
|
||||||
react-intl: 3.12.1(react@18.3.1)
|
react-intl: 3.12.1(react@18.3.1)
|
||||||
react-redux: 8.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1)(react@18.3.1)(redux@4.2.1)
|
react-redux: 8.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1)(react@18.3.1)(redux@4.2.1)
|
||||||
@@ -5866,7 +5868,7 @@ packages:
|
|||||||
classnames: 2.5.1
|
classnames: 2.5.1
|
||||||
copy-to-clipboard: 3.3.3
|
copy-to-clipboard: 3.3.3
|
||||||
lodash: 4.17.21
|
lodash: 4.17.21
|
||||||
moment: 2.30.1
|
moment: 2.31.0
|
||||||
rc-cascader: 3.7.3(react-dom@18.3.1)(react@18.3.1)
|
rc-cascader: 3.7.3(react-dom@18.3.1)(react@18.3.1)
|
||||||
rc-checkbox: 3.0.1(react-dom@18.3.1)(react@18.3.1)
|
rc-checkbox: 3.0.1(react-dom@18.3.1)(react@18.3.1)
|
||||||
rc-collapse: 3.4.2(react-dom@18.3.1)(react@18.3.1)
|
rc-collapse: 3.4.2(react-dom@18.3.1)(react@18.3.1)
|
||||||
@@ -6357,18 +6359,6 @@ packages:
|
|||||||
big-integer: 1.6.52
|
big-integer: 1.6.52
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/brace-expansion@1.1.12:
|
|
||||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
|
||||||
dependencies:
|
|
||||||
balanced-match: 1.0.2
|
|
||||||
concat-map: 0.0.1
|
|
||||||
|
|
||||||
/brace-expansion@2.0.2:
|
|
||||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
|
||||||
dependencies:
|
|
||||||
balanced-match: 1.0.2
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/braces@3.0.3:
|
/braces@3.0.3:
|
||||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -8662,7 +8652,7 @@ packages:
|
|||||||
/file-stream-rotator@0.6.1:
|
/file-stream-rotator@0.6.1:
|
||||||
resolution: {integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==}
|
resolution: {integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==}
|
||||||
dependencies:
|
dependencies:
|
||||||
moment: 2.30.1
|
moment: 2.31.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/fill-range@7.1.1:
|
/fill-range@7.1.1:
|
||||||
@@ -10733,11 +10723,12 @@ packages:
|
|||||||
/moment-timezone@0.5.46:
|
/moment-timezone@0.5.46:
|
||||||
resolution: {integrity: sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw==}
|
resolution: {integrity: sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
moment: 2.30.1
|
moment: 2.31.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/moment@2.30.1:
|
/moment@2.31.0:
|
||||||
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
|
resolution:
|
||||||
|
integrity: sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==
|
||||||
|
|
||||||
/monaco-editor@0.33.0:
|
/monaco-editor@0.33.0:
|
||||||
resolution: {integrity: sha512-VcRWPSLIUEgQJQIE0pVT8FcGBIgFoxz7jtqctE+IiCxWugD0DwgyQBcZBhdSrdMC84eumoqMZsGl2GTreOzwqw==}
|
resolution: {integrity: sha512-VcRWPSLIUEgQJQIE0pVT8FcGBIgFoxz7jtqctE+IiCxWugD0DwgyQBcZBhdSrdMC84eumoqMZsGl2GTreOzwqw==}
|
||||||
@@ -12724,7 +12715,7 @@ packages:
|
|||||||
classnames: 2.5.1
|
classnames: 2.5.1
|
||||||
date-fns: 2.30.0
|
date-fns: 2.30.0
|
||||||
dayjs: 1.11.13
|
dayjs: 1.11.13
|
||||||
moment: 2.30.1
|
moment: 2.31.0
|
||||||
rc-trigger: 5.3.4(react-dom@18.3.1)(react@18.3.1)
|
rc-trigger: 5.3.4(react-dom@18.3.1)(react@18.3.1)
|
||||||
rc-util: 5.43.0(react-dom@18.3.1)(react@18.3.1)
|
rc-util: 5.43.0(react-dom@18.3.1)(react@18.3.1)
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
@@ -13951,7 +13942,7 @@ packages:
|
|||||||
dottie: 2.0.6
|
dottie: 2.0.6
|
||||||
inflection: 1.13.4
|
inflection: 1.13.4
|
||||||
lodash: 4.17.21
|
lodash: 4.17.21
|
||||||
moment: 2.30.1
|
moment: 2.31.0
|
||||||
moment-timezone: 0.5.46
|
moment-timezone: 0.5.46
|
||||||
pg-connection-string: 2.7.0
|
pg-connection-string: 2.7.0
|
||||||
retry-as-promised: 7.0.4
|
retry-as-promised: 7.0.4
|
||||||
|
|||||||
+307
-134
File diff suppressed because it is too large
Load Diff
+162
-27
@@ -10,6 +10,8 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import smtplib
|
import smtplib
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.header import Header
|
from email.header import Header
|
||||||
from email.utils import formataddr
|
from email.utils import formataddr
|
||||||
@@ -161,10 +163,11 @@ for k in push_config:
|
|||||||
push_config[k] = v
|
push_config[k] = v
|
||||||
|
|
||||||
|
|
||||||
def bark(title: str, content: str) -> None:
|
def bark(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
使用 bark 推送消息。
|
使用 bark 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("BARK_PUSH"):
|
if not push_config.get("BARK_PUSH"):
|
||||||
return
|
return
|
||||||
print("bark 服务启动")
|
print("bark 服务启动")
|
||||||
@@ -200,22 +203,28 @@ def bark(title: str, content: str) -> None:
|
|||||||
).json()
|
).json()
|
||||||
|
|
||||||
if response["code"] == 200:
|
if response["code"] == 200:
|
||||||
|
audit_results.append(True)
|
||||||
print("bark 推送成功!")
|
print("bark 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("bark 推送失败!")
|
print("bark 推送失败!")
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def console(title: str, content: str) -> None:
|
def console(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
使用 控制台 推送消息。
|
使用 控制台 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
print(f"{title}\n\n{content}")
|
print(f"{title}\n\n{content}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def dingding_bot(title: str, content: str) -> None:
|
def dingding_bot(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
使用 钉钉机器人 推送消息。
|
使用 钉钉机器人 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"):
|
if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"):
|
||||||
return
|
return
|
||||||
print("钉钉机器人 服务启动")
|
print("钉钉机器人 服务启动")
|
||||||
@@ -236,15 +245,19 @@ def dingding_bot(title: str, content: str) -> None:
|
|||||||
).json()
|
).json()
|
||||||
|
|
||||||
if not response["errcode"]:
|
if not response["errcode"]:
|
||||||
|
audit_results.append(True)
|
||||||
print("钉钉机器人 推送成功!")
|
print("钉钉机器人 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("钉钉机器人 推送失败!")
|
print("钉钉机器人 推送失败!")
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def feishu_bot(title: str, content: str) -> None:
|
def feishu_bot(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
使用 飞书机器人 推送消息。
|
使用 飞书机器人 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("FSKEY"):
|
if not push_config.get("FSKEY"):
|
||||||
return
|
return
|
||||||
print("飞书 服务启动")
|
print("飞书 服务启动")
|
||||||
@@ -268,15 +281,19 @@ def feishu_bot(title: str, content: str) -> None:
|
|||||||
response = requests.post(url, data=json.dumps(data)).json()
|
response = requests.post(url, data=json.dumps(data)).json()
|
||||||
|
|
||||||
if response.get("StatusCode") == 0 or response.get("code") == 0:
|
if response.get("StatusCode") == 0 or response.get("code") == 0:
|
||||||
|
audit_results.append(True)
|
||||||
print("飞书 推送成功!")
|
print("飞书 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("飞书 推送失败!错误信息如下:\n", response)
|
print("飞书 推送失败!错误信息如下:\n", response)
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def go_cqhttp(title: str, content: str) -> None:
|
def go_cqhttp(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
使用 go_cqhttp 推送消息。
|
使用 go_cqhttp 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"):
|
if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"):
|
||||||
return
|
return
|
||||||
print("go-cqhttp 服务启动")
|
print("go-cqhttp 服务启动")
|
||||||
@@ -285,15 +302,19 @@ def go_cqhttp(title: str, content: str) -> None:
|
|||||||
response = requests.get(url).json()
|
response = requests.get(url).json()
|
||||||
|
|
||||||
if response["status"] == "ok":
|
if response["status"] == "ok":
|
||||||
|
audit_results.append(True)
|
||||||
print("go-cqhttp 推送成功!")
|
print("go-cqhttp 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("go-cqhttp 推送失败!")
|
print("go-cqhttp 推送失败!")
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def gotify(title: str, content: str) -> None:
|
def gotify(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
使用 gotify 推送消息。
|
使用 gotify 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"):
|
if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"):
|
||||||
return
|
return
|
||||||
print("gotify 服务启动")
|
print("gotify 服务启动")
|
||||||
@@ -307,15 +328,19 @@ def gotify(title: str, content: str) -> None:
|
|||||||
response = requests.post(url, data=data).json()
|
response = requests.post(url, data=data).json()
|
||||||
|
|
||||||
if response.get("id"):
|
if response.get("id"):
|
||||||
|
audit_results.append(True)
|
||||||
print("gotify 推送成功!")
|
print("gotify 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("gotify 推送失败!")
|
print("gotify 推送失败!")
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def iGot(title: str, content: str) -> None:
|
def iGot(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
使用 iGot 推送消息。
|
使用 iGot 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("IGOT_PUSH_KEY"):
|
if not push_config.get("IGOT_PUSH_KEY"):
|
||||||
return
|
return
|
||||||
print("iGot 服务启动")
|
print("iGot 服务启动")
|
||||||
@@ -326,15 +351,19 @@ def iGot(title: str, content: str) -> None:
|
|||||||
response = requests.post(url, data=data, headers=headers).json()
|
response = requests.post(url, data=data, headers=headers).json()
|
||||||
|
|
||||||
if response["ret"] == 0:
|
if response["ret"] == 0:
|
||||||
|
audit_results.append(True)
|
||||||
print("iGot 推送成功!")
|
print("iGot 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f'iGot 推送失败!{response["errMsg"]}')
|
print(f'iGot 推送失败!{response["errMsg"]}')
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def serverJ(title: str, content: str) -> None:
|
def serverJ(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
通过 serverJ 推送消息。
|
通过 serverJ 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("PUSH_KEY"):
|
if not push_config.get("PUSH_KEY"):
|
||||||
return
|
return
|
||||||
print("serverJ 服务启动")
|
print("serverJ 服务启动")
|
||||||
@@ -351,15 +380,19 @@ def serverJ(title: str, content: str) -> None:
|
|||||||
response = requests.post(url, data=data).json()
|
response = requests.post(url, data=data).json()
|
||||||
|
|
||||||
if response.get("errno") == 0 or response.get("code") == 0:
|
if response.get("errno") == 0 or response.get("code") == 0:
|
||||||
|
audit_results.append(True)
|
||||||
print("serverJ 推送成功!")
|
print("serverJ 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f'serverJ 推送失败!错误码:{response["message"]}')
|
print(f'serverJ 推送失败!错误码:{response["message"]}')
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def pushdeer(title: str, content: str) -> None:
|
def pushdeer(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
通过PushDeer 推送消息
|
通过PushDeer 推送消息
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("DEER_KEY"):
|
if not push_config.get("DEER_KEY"):
|
||||||
return
|
return
|
||||||
print("PushDeer 服务启动")
|
print("PushDeer 服务启动")
|
||||||
@@ -376,15 +409,19 @@ def pushdeer(title: str, content: str) -> None:
|
|||||||
response = requests.post(url, data=data).json()
|
response = requests.post(url, data=data).json()
|
||||||
|
|
||||||
if len(response.get("content").get("result")) > 0:
|
if len(response.get("content").get("result")) > 0:
|
||||||
|
audit_results.append(True)
|
||||||
print("PushDeer 推送成功!")
|
print("PushDeer 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("PushDeer 推送失败!错误信息:", response)
|
print("PushDeer 推送失败!错误信息:", response)
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def chat(title: str, content: str) -> None:
|
def chat(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
通过Chat 推送消息
|
通过Chat 推送消息
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"):
|
if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"):
|
||||||
return
|
return
|
||||||
print("chat 服务启动")
|
print("chat 服务启动")
|
||||||
@@ -393,15 +430,19 @@ def chat(title: str, content: str) -> None:
|
|||||||
response = requests.post(url, data=data)
|
response = requests.post(url, data=data)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
|
audit_results.append(True)
|
||||||
print("Chat 推送成功!")
|
print("Chat 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("Chat 推送失败!错误信息:", response)
|
print("Chat 推送失败!错误信息:", response)
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def pushplus_bot(title: str, content: str) -> None:
|
def pushplus_bot(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
通过 pushplus 推送消息。
|
通过 pushplus 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("PUSH_PLUS_TOKEN"):
|
if not push_config.get("PUSH_PLUS_TOKEN"):
|
||||||
return
|
return
|
||||||
print("PUSHPLUS 服务启动")
|
print("PUSHPLUS 服务启动")
|
||||||
@@ -424,7 +465,9 @@ def pushplus_bot(title: str, content: str) -> None:
|
|||||||
|
|
||||||
code = response["code"]
|
code = response["code"]
|
||||||
if code == 200:
|
if code == 200:
|
||||||
|
audit_results.append(True)
|
||||||
print("PUSHPLUS 推送请求成功,可根据流水号查询推送结果:" + response["data"])
|
print("PUSHPLUS 推送请求成功,可根据流水号查询推送结果:" + response["data"])
|
||||||
|
audit_results.append(True)
|
||||||
print(
|
print(
|
||||||
"注意:请求成功并不代表推送成功,如未收到消息,请到pushplus官网使用流水号查询推送最终结果"
|
"注意:请求成功并不代表推送成功,如未收到消息,请到pushplus官网使用流水号查询推送最终结果"
|
||||||
)
|
)
|
||||||
@@ -437,16 +480,20 @@ def pushplus_bot(title: str, content: str) -> None:
|
|||||||
response = requests.post(url=url_old, data=body, headers=headers).json()
|
response = requests.post(url=url_old, data=body, headers=headers).json()
|
||||||
|
|
||||||
if response["code"] == 200:
|
if response["code"] == 200:
|
||||||
|
audit_results.append(True)
|
||||||
print("PUSHPLUS(hxtrip) 推送成功!")
|
print("PUSHPLUS(hxtrip) 推送成功!")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("PUSHPLUS 推送失败!")
|
print("PUSHPLUS 推送失败!")
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def weplus_bot(title: str, content: str) -> None:
|
def weplus_bot(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
通过 微加机器人 推送消息。
|
通过 微加机器人 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("WE_PLUS_BOT_TOKEN"):
|
if not push_config.get("WE_PLUS_BOT_TOKEN"):
|
||||||
return
|
return
|
||||||
print("微加机器人 服务启动")
|
print("微加机器人 服务启动")
|
||||||
@@ -469,15 +516,19 @@ def weplus_bot(title: str, content: str) -> None:
|
|||||||
response = requests.post(url=url, data=body, headers=headers).json()
|
response = requests.post(url=url, data=body, headers=headers).json()
|
||||||
|
|
||||||
if response["code"] == 200:
|
if response["code"] == 200:
|
||||||
|
audit_results.append(True)
|
||||||
print("微加机器人 推送成功!")
|
print("微加机器人 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("微加机器人 推送失败!")
|
print("微加机器人 推送失败!")
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def qmsg_bot(title: str, content: str) -> None:
|
def qmsg_bot(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
使用 qmsg 推送消息。
|
使用 qmsg 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"):
|
if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"):
|
||||||
return
|
return
|
||||||
print("qmsg 服务启动")
|
print("qmsg 服务启动")
|
||||||
@@ -487,15 +538,19 @@ def qmsg_bot(title: str, content: str) -> None:
|
|||||||
response = requests.post(url=url, params=payload).json()
|
response = requests.post(url=url, params=payload).json()
|
||||||
|
|
||||||
if response["code"] == 0:
|
if response["code"] == 0:
|
||||||
|
audit_results.append(True)
|
||||||
print("qmsg 推送成功!")
|
print("qmsg 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f'qmsg 推送失败!{response["reason"]}')
|
print(f'qmsg 推送失败!{response["reason"]}')
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def wecom_app(title: str, content: str) -> None:
|
def wecom_app(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
通过 企业微信 APP 推送消息。
|
通过 企业微信 APP 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("QYWX_AM"):
|
if not push_config.get("QYWX_AM"):
|
||||||
return
|
return
|
||||||
QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM"))
|
QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM"))
|
||||||
@@ -521,9 +576,12 @@ def wecom_app(title: str, content: str) -> None:
|
|||||||
response = wx.send_mpnews(title, content, media_id, touser)
|
response = wx.send_mpnews(title, content, media_id, touser)
|
||||||
|
|
||||||
if response == "ok":
|
if response == "ok":
|
||||||
|
audit_results.append(True)
|
||||||
print("企业微信推送成功!")
|
print("企业微信推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("企业微信推送失败!错误信息如下:\n", response)
|
print("企业微信推送失败!错误信息如下:\n", response)
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
class WeCom:
|
class WeCom:
|
||||||
@@ -588,10 +646,11 @@ class WeCom:
|
|||||||
return respone["errmsg"]
|
return respone["errmsg"]
|
||||||
|
|
||||||
|
|
||||||
def wecom_bot(title: str, content: str) -> None:
|
def wecom_bot(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
通过 企业微信机器人 推送消息。
|
通过 企业微信机器人 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("QYWX_KEY"):
|
if not push_config.get("QYWX_KEY"):
|
||||||
return
|
return
|
||||||
print("企业微信机器人服务启动")
|
print("企业微信机器人服务启动")
|
||||||
@@ -608,15 +667,19 @@ def wecom_bot(title: str, content: str) -> None:
|
|||||||
).json()
|
).json()
|
||||||
|
|
||||||
if response["errcode"] == 0:
|
if response["errcode"] == 0:
|
||||||
|
audit_results.append(True)
|
||||||
print("企业微信机器人推送成功!")
|
print("企业微信机器人推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("企业微信机器人推送失败!")
|
print("企业微信机器人推送失败!")
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def telegram_bot(title: str, content: str) -> None:
|
def telegram_bot(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
使用 telegram 机器人 推送消息。
|
使用 telegram 机器人 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"):
|
if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"):
|
||||||
return
|
return
|
||||||
print("tg 服务启动")
|
print("tg 服务启动")
|
||||||
@@ -652,15 +715,19 @@ def telegram_bot(title: str, content: str) -> None:
|
|||||||
).json()
|
).json()
|
||||||
|
|
||||||
if response["ok"]:
|
if response["ok"]:
|
||||||
|
audit_results.append(True)
|
||||||
print("tg 推送成功!")
|
print("tg 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("tg 推送失败!")
|
print("tg 推送失败!")
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def aibotk(title: str, content: str) -> None:
|
def aibotk(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
使用 智能微秘书 推送消息。
|
使用 智能微秘书 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if (
|
if (
|
||||||
not push_config.get("AIBOTK_KEY")
|
not push_config.get("AIBOTK_KEY")
|
||||||
or not push_config.get("AIBOTK_TYPE")
|
or not push_config.get("AIBOTK_TYPE")
|
||||||
@@ -688,15 +755,19 @@ def aibotk(title: str, content: str) -> None:
|
|||||||
response = requests.post(url=url, data=body, headers=headers).json()
|
response = requests.post(url=url, data=body, headers=headers).json()
|
||||||
print(response)
|
print(response)
|
||||||
if response["code"] == 0:
|
if response["code"] == 0:
|
||||||
|
audit_results.append(True)
|
||||||
print("智能微秘书 推送成功!")
|
print("智能微秘书 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f'智能微秘书 推送失败!{response["error"]}')
|
print(f'智能微秘书 推送失败!{response["error"]}')
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def smtp(title: str, content: str) -> None:
|
def smtp(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
使用 SMTP 邮件 推送消息。
|
使用 SMTP 邮件 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if (
|
if (
|
||||||
not push_config.get("SMTP_SERVER")
|
not push_config.get("SMTP_SERVER")
|
||||||
or not push_config.get("SMTP_SSL")
|
or not push_config.get("SMTP_SSL")
|
||||||
@@ -736,15 +807,19 @@ def smtp(title: str, content: str) -> None:
|
|||||||
message.as_bytes(),
|
message.as_bytes(),
|
||||||
)
|
)
|
||||||
smtp_server.close()
|
smtp_server.close()
|
||||||
|
audit_results.append(True)
|
||||||
print("SMTP 邮件 推送成功!")
|
print("SMTP 邮件 推送成功!")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
audit_results.append(False)
|
||||||
print(f"SMTP 邮件 推送失败!{e}")
|
print(f"SMTP 邮件 推送失败!{e}")
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def pushme(title: str, content: str) -> None:
|
def pushme(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
使用 PushMe 推送消息。
|
使用 PushMe 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("PUSHME_KEY"):
|
if not push_config.get("PUSHME_KEY"):
|
||||||
return
|
return
|
||||||
print("PushMe 服务启动")
|
print("PushMe 服务启动")
|
||||||
@@ -764,15 +839,19 @@ def pushme(title: str, content: str) -> None:
|
|||||||
response = requests.post(url, data=data)
|
response = requests.post(url, data=data)
|
||||||
|
|
||||||
if response.status_code == 200 and response.text == "success":
|
if response.status_code == 200 and response.text == "success":
|
||||||
|
audit_results.append(True)
|
||||||
print("PushMe 推送成功!")
|
print("PushMe 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f"PushMe 推送失败!{response.status_code} {response.text}")
|
print(f"PushMe 推送失败!{response.status_code} {response.text}")
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def chronocat(title: str, content: str) -> None:
|
def chronocat(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
使用 CHRONOCAT 推送消息。
|
使用 CHRONOCAT 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if (
|
if (
|
||||||
not push_config.get("CHRONOCAT_URL")
|
not push_config.get("CHRONOCAT_URL")
|
||||||
or not push_config.get("CHRONOCAT_QQ")
|
or not push_config.get("CHRONOCAT_QQ")
|
||||||
@@ -807,21 +886,27 @@ def chronocat(title: str, content: str) -> None:
|
|||||||
response = requests.post(url, headers=headers, data=json.dumps(data))
|
response = requests.post(url, headers=headers, data=json.dumps(data))
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
if chat_type == 1:
|
if chat_type == 1:
|
||||||
|
audit_results.append(True)
|
||||||
print(f"QQ个人消息:{ids}推送成功!")
|
print(f"QQ个人消息:{ids}推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(True)
|
||||||
print(f"QQ群消息:{ids}推送成功!")
|
print(f"QQ群消息:{ids}推送成功!")
|
||||||
else:
|
else:
|
||||||
if chat_type == 1:
|
if chat_type == 1:
|
||||||
|
audit_results.append(False)
|
||||||
print(f"QQ个人消息:{ids}推送失败!")
|
print(f"QQ个人消息:{ids}推送失败!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f"QQ群消息:{ids}推送失败!")
|
print(f"QQ群消息:{ids}推送失败!")
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def ntfy(title: str, content: str) -> None:
|
def ntfy(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
通过 Ntfy 推送消息
|
通过 Ntfy 推送消息
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
audit_results = []
|
||||||
def encode_rfc2047(text: str) -> str:
|
def encode_rfc2047(text: str) -> str:
|
||||||
"""将文本编码为符合 RFC 2047 标准的格式"""
|
"""将文本编码为符合 RFC 2047 标准的格式"""
|
||||||
encoded_bytes = base64.b64encode(text.encode("utf-8"))
|
encoded_bytes = base64.b64encode(text.encode("utf-8"))
|
||||||
@@ -853,12 +938,15 @@ def ntfy(title: str, content: str) -> None:
|
|||||||
url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC")
|
url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC")
|
||||||
response = requests.post(url, data=data, headers=headers)
|
response = requests.post(url, data=data, headers=headers)
|
||||||
if response.status_code == 200: # 使用 response.status_code 进行检查
|
if response.status_code == 200: # 使用 response.status_code 进行检查
|
||||||
|
audit_results.append(True)
|
||||||
print("Ntfy 推送成功!")
|
print("Ntfy 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print("Ntfy 推送失败!错误信息:", response.text)
|
print("Ntfy 推送失败!错误信息:", response.text)
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def wxpusher_bot(title: str, content: str) -> None:
|
def wxpusher_bot(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
通过 wxpusher 推送消息。
|
通过 wxpusher 推送消息。
|
||||||
支持的环境变量:
|
支持的环境变量:
|
||||||
@@ -866,6 +954,7 @@ def wxpusher_bot(title: str, content: str) -> None:
|
|||||||
- WXPUSHER_TOPIC_IDS: 主题ID, 多个用英文分号;分隔
|
- WXPUSHER_TOPIC_IDS: 主题ID, 多个用英文分号;分隔
|
||||||
- WXPUSHER_UIDS: 用户ID, 多个用英文分号;分隔
|
- WXPUSHER_UIDS: 用户ID, 多个用英文分号;分隔
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("WXPUSHER_APP_TOKEN"):
|
if not push_config.get("WXPUSHER_APP_TOKEN"):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -909,17 +998,21 @@ def wxpusher_bot(title: str, content: str) -> None:
|
|||||||
response = requests.post(url=url, json=data, headers=headers).json()
|
response = requests.post(url=url, json=data, headers=headers).json()
|
||||||
|
|
||||||
if response.get("code") == 1000:
|
if response.get("code") == 1000:
|
||||||
|
audit_results.append(True)
|
||||||
print("wxpusher 推送成功!")
|
print("wxpusher 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f"wxpusher 推送失败!错误信息:{response.get('msg')}")
|
print(f"wxpusher 推送失败!错误信息:{response.get('msg')}")
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def wxpusher_spt(title: str, content: str) -> None:
|
def wxpusher_spt(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
通过 wxpusher 极简推送(SPT)推送消息。
|
通过 wxpusher 极简推送(SPT)推送消息。
|
||||||
支持的环境变量:
|
支持的环境变量:
|
||||||
- WXPUSHER_SPT_LIST: SPT, 多个用英文逗号,分隔, 最多10个
|
- WXPUSHER_SPT_LIST: SPT, 多个用英文逗号,分隔, 最多10个
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("WXPUSHER_SPT_LIST"):
|
if not push_config.get("WXPUSHER_SPT_LIST"):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -956,17 +1049,21 @@ def wxpusher_spt(title: str, content: str) -> None:
|
|||||||
response = requests.post(url=url, json=data, headers=headers).json()
|
response = requests.post(url=url, json=data, headers=headers).json()
|
||||||
|
|
||||||
if response.get("code") == 1000:
|
if response.get("code") == 1000:
|
||||||
|
audit_results.append(True)
|
||||||
print("wxpusher SPT 推送成功!")
|
print("wxpusher SPT 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f"wxpusher SPT 推送失败!错误信息:{response.get('msg')}")
|
print(f"wxpusher SPT 推送失败!错误信息:{response.get('msg')}")
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def wpush(title: str, content: str) -> None:
|
def wpush(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
通过 WPUSH 推送消息。
|
通过 WPUSH 推送消息。
|
||||||
官方文档: https://wpush.cn/docs
|
官方文档: https://wpush.cn/docs
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("WPUSH_APIKEY"):
|
if not push_config.get("WPUSH_APIKEY"):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -986,12 +1083,15 @@ def wpush(title: str, content: str) -> None:
|
|||||||
response = requests.post(url=url, json=data, headers=headers, timeout=15).json()
|
response = requests.post(url=url, json=data, headers=headers, timeout=15).json()
|
||||||
|
|
||||||
if response.get("code") == 0:
|
if response.get("code") == 0:
|
||||||
|
audit_results.append(True)
|
||||||
print("WPUSH 推送成功!")
|
print("WPUSH 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f'WPUSH 推送失败!错误信息:{response.get("message") or response}')
|
print(f'WPUSH 推送失败!错误信息:{response.get("message") or response}')
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def openilink(title: str, content: str) -> None:
|
def openilink(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
通过 OpeniLink 推送消息。
|
通过 OpeniLink 推送消息。
|
||||||
支持的环境变量:
|
支持的环境变量:
|
||||||
@@ -999,6 +1099,7 @@ def openilink(title: str, content: str) -> None:
|
|||||||
- OPENILINK_HUB_URL: OpeniLink Hub 地址,默认为 https://hub.openilink.com
|
- OPENILINK_HUB_URL: OpeniLink Hub 地址,默认为 https://hub.openilink.com
|
||||||
- OPENILINK_CONTEXT_TOKEN: 消息会话上下文 token,可从消息事件中获取
|
- OPENILINK_CONTEXT_TOKEN: 消息会话上下文 token,可从消息事件中获取
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("OPENILINK_APP_TOKEN"):
|
if not push_config.get("OPENILINK_APP_TOKEN"):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1023,9 +1124,12 @@ def openilink(title: str, content: str) -> None:
|
|||||||
response = requests.post(url=url, json=data, headers=headers).json()
|
response = requests.post(url=url, json=data, headers=headers).json()
|
||||||
|
|
||||||
if response.get("ok"):
|
if response.get("ok"):
|
||||||
|
audit_results.append(True)
|
||||||
print("OpeniLink 推送成功!")
|
print("OpeniLink 推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f'OpeniLink 推送失败!错误信息:{response.get("error")}')
|
print(f'OpeniLink 推送失败!错误信息:{response.get("error")}')
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def parse_headers(headers):
|
def parse_headers(headers):
|
||||||
@@ -1079,10 +1183,11 @@ def parse_body(body, content_type, value_format_fn=None):
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def custom_notify(title: str, content: str) -> None:
|
def custom_notify(title: str, content: str) -> Optional[bool]:
|
||||||
"""
|
"""
|
||||||
通过 自定义通知 推送消息。
|
通过 自定义通知 推送消息。
|
||||||
"""
|
"""
|
||||||
|
audit_results = []
|
||||||
if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"):
|
if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1114,9 +1219,12 @@ def custom_notify(title: str, content: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
|
audit_results.append(True)
|
||||||
print("自定义通知推送成功!")
|
print("自定义通知推送成功!")
|
||||||
else:
|
else:
|
||||||
|
audit_results.append(False)
|
||||||
print(f"自定义通知推送失败!{response.status_code} {response.text}")
|
print(f"自定义通知推送失败!{response.status_code} {response.text}")
|
||||||
|
return all(audit_results) if audit_results else None
|
||||||
|
|
||||||
|
|
||||||
def one() -> str:
|
def one() -> str:
|
||||||
@@ -1204,6 +1312,33 @@ def add_notify_function():
|
|||||||
return notify_function
|
return notify_function
|
||||||
|
|
||||||
|
|
||||||
|
def report_notification(channel, title, content, success):
|
||||||
|
ql_dir = os.getenv("QL_DIR")
|
||||||
|
if not ql_dir:
|
||||||
|
return
|
||||||
|
payload = {"title": str(title or "")[:512], "content": str(content or "")[:8192],
|
||||||
|
"success": success, "notifyType": channel,
|
||||||
|
"scriptName": os.path.basename(__import__("sys").argv[0]), "taskId": os.getenv("ID", "")}
|
||||||
|
code = """const fs=require('node:fs'),path=require('node:path');
|
||||||
|
const report=require(path.join(process.env.QL_DIR,'shell/preload/notificationAudit.js'));
|
||||||
|
report(JSON.parse(fs.readFileSync(0,'utf8'))).catch(()=>{});"""
|
||||||
|
try:
|
||||||
|
subprocess.run(["node", "-e", code], input=json.dumps(payload), text=True,
|
||||||
|
capture_output=True, timeout=3)
|
||||||
|
except Exception:
|
||||||
|
pass # Audit availability never changes notification delivery.
|
||||||
|
|
||||||
|
|
||||||
|
def audited_notification(mode, title, content):
|
||||||
|
result = None
|
||||||
|
try:
|
||||||
|
result = mode(title, content)
|
||||||
|
except Exception:
|
||||||
|
result = False
|
||||||
|
print(f"{mode.__name__} 推送失败")
|
||||||
|
report_notification(mode.__name__, title, content, result is True)
|
||||||
|
|
||||||
|
|
||||||
def send(title: str, content: str, ignore_default_config: bool = False, **kwargs):
|
def send(title: str, content: str, ignore_default_config: bool = False, **kwargs):
|
||||||
if kwargs:
|
if kwargs:
|
||||||
global push_config
|
global push_config
|
||||||
@@ -1228,7 +1363,7 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs
|
|||||||
|
|
||||||
notify_function = add_notify_function()
|
notify_function = add_notify_function()
|
||||||
ts = [
|
ts = [
|
||||||
threading.Thread(target=mode, args=(title, content), name=mode.__name__)
|
threading.Thread(target=audited_notification, args=(mode, title, content), name=mode.__name__)
|
||||||
for mode in notify_function
|
for mode in notify_function
|
||||||
]
|
]
|
||||||
[t.start() for t in ts]
|
[t.start() for t in ts]
|
||||||
|
|||||||
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
create_token() {
|
create_token() {
|
||||||
local token_command="ts-node-transpile-only ${dir_root}/back/token.ts"
|
|
||||||
local token_file="${dir_root}/static/build/token.js"
|
local token_file="${dir_root}/static/build/token.js"
|
||||||
if [[ -f $token_file ]]; then
|
if [[ -f $token_file ]]; then
|
||||||
token_command="node ${token_file}"
|
__ql_token__=$(node "$token_file")
|
||||||
|
else
|
||||||
|
__ql_token__=$(ts-node-transpile-only "${dir_root}/back/token.ts")
|
||||||
fi
|
fi
|
||||||
__ql_token__=$(eval "$token_command")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get_token() {
|
get_token() {
|
||||||
|
|||||||
+30
-28
@@ -39,16 +39,16 @@ random_delay() {
|
|||||||
gen_array_scripts() {
|
gen_array_scripts() {
|
||||||
local dir_current=$(pwd)
|
local dir_current=$(pwd)
|
||||||
local i="-1"
|
local i="-1"
|
||||||
cd $dir_scripts
|
cd "$dir_scripts" || return $?
|
||||||
for file in $(ls); do
|
for file in *; do
|
||||||
if [[ -f $file ]] && [[ $file == *.js && $file != sendNotify.js ]]; then
|
if [[ -f $file ]] && [[ $file == *.js && $file != sendNotify.js ]]; then
|
||||||
let i++
|
let i++
|
||||||
array_scripts[i]=$(echo "$file" | perl -pe "s|$dir_scripts/||g")
|
array_scripts[i]=$file
|
||||||
array_scripts_name[i]=$(grep "new Env" $file | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1)
|
array_scripts_name[i]=$(grep -- "new Env" "$file" | awk -F '[(]' '{print $2}' | awk -F '[)]' '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1)
|
||||||
[[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="<未识别出活动名称>"
|
[[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="<未识别出活动名称>"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
cd $dir_current
|
cd "$dir_current" || return $?
|
||||||
}
|
}
|
||||||
|
|
||||||
## 使用说明
|
## 使用说明
|
||||||
@@ -72,11 +72,11 @@ usage() {
|
|||||||
## run nohup,$1:文件名,不含路径,带后缀
|
## run nohup,$1:文件名,不含路径,带后缀
|
||||||
run_nohup() {
|
run_nohup() {
|
||||||
local file_name=$1
|
local file_name=$1
|
||||||
nohup node $file_name &>$log_path &
|
nohup node "$file_name" &>"$log_path" &
|
||||||
}
|
}
|
||||||
|
|
||||||
env_str_to_array() {
|
env_str_to_array() {
|
||||||
. $file_env
|
. "$file_env"
|
||||||
local IFS="&"
|
local IFS="&"
|
||||||
read -ra array <<<"${!env_param}"
|
read -ra array <<<"${!env_param}"
|
||||||
array_length=${#array[@]}
|
array_length=${#array[@]}
|
||||||
@@ -122,7 +122,7 @@ enter_script_workdir() {
|
|||||||
_target_dir="${dir_scripts}/${work_dir}"
|
_target_dir="${dir_scripts}/${work_dir}"
|
||||||
fi
|
fi
|
||||||
if [[ -d "${_target_dir}" ]]; then
|
if [[ -d "${_target_dir}" ]]; then
|
||||||
cd "${_target_dir}"
|
cd "${_target_dir}" || return $?
|
||||||
if [[ ${file_param} =~ "/" ]]; then
|
if [[ ${file_param} =~ "/" ]]; then
|
||||||
local script_name="${file_param##*/}"
|
local script_name="${file_param##*/}"
|
||||||
if [[ "${use_dot_prefix}" == "true" ]]; then
|
if [[ "${use_dot_prefix}" == "true" ]]; then
|
||||||
@@ -136,13 +136,13 @@ enter_script_workdir() {
|
|||||||
t '警告:工作目录不存在 %s' "${_target_dir}"
|
t '警告:工作目录不存在 %s' "${_target_dir}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd $dir_scripts
|
cd "$dir_scripts" || return $?
|
||||||
if [[ ${file_param} =~ "/" ]]; then
|
if [[ ${file_param} =~ "/" ]]; then
|
||||||
local script_dir="${file_param%/*}"
|
local script_dir="${file_param%/*}"
|
||||||
local script_name="${file_param##*/}"
|
local script_name="${file_param##*/}"
|
||||||
|
|
||||||
if [[ -d ${script_dir} ]]; then
|
if [[ -d ${script_dir} ]]; then
|
||||||
cd ${script_dir}
|
cd "$script_dir" || return $?
|
||||||
if [[ "${use_dot_prefix}" == "true" ]]; then
|
if [[ "${use_dot_prefix}" == "true" ]]; then
|
||||||
file_param="./${script_name}"
|
file_param="./${script_name}"
|
||||||
else
|
else
|
||||||
@@ -159,12 +159,12 @@ run_normal() {
|
|||||||
random_delay "$file_param"
|
random_delay "$file_param"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
enter_script_workdir
|
enter_script_workdir || return $?
|
||||||
|
|
||||||
if [[ $isJsOrPythonFile == 'false' ]]; then
|
if [[ $isJsOrPythonFile == 'false' ]]; then
|
||||||
clear_non_sh_env
|
clear_non_sh_env
|
||||||
fi
|
fi
|
||||||
run_task_command $which_program $file_param "${script_params[@]}"
|
run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
handle_env_split() {
|
handle_env_split() {
|
||||||
@@ -175,7 +175,7 @@ handle_env_split() {
|
|||||||
env_str_to_array
|
env_str_to_array
|
||||||
local tempArr=$(echo $num_param | sed "s/-max/-${array_length}/g" | sed "s/max-/${array_length}-/g" | perl -pe "s|(\d+)(-\|~\|_)(\d+)|{\1..\3}|g")
|
local tempArr=$(echo $num_param | sed "s/-max/-${array_length}/g" | sed "s/max-/${array_length}-/g" | perl -pe "s|(\d+)(-\|~\|_)(\d+)|{\1..\3}|g")
|
||||||
local runArr=($(eval echo $tempArr))
|
local runArr=($(eval echo $tempArr))
|
||||||
array_run=($(awk -v RS=' ' '!a[$1]++' <<<${runArr[@]}))
|
array_run=($(awk -v RS=' ' '!a[$1]++' <<<"${runArr[*]}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
## 并发执行时,设定的 RandomDelay 不会生效,即所有任务立即执行
|
## 并发执行时,设定的 RandomDelay 不会生效,即所有任务立即执行
|
||||||
@@ -192,10 +192,10 @@ run_concurrent() {
|
|||||||
time=$(date "+$mtime_format")
|
time=$(date "+$mtime_format")
|
||||||
single_log_time=$(format_log_time "$mtime_format" "$time")
|
single_log_time=$(format_log_time "$mtime_format" "$time")
|
||||||
|
|
||||||
enter_script_workdir
|
enter_script_workdir || return $?
|
||||||
|
|
||||||
local j=0
|
local j=0
|
||||||
for i in ${array_run[@]}; do
|
for i in "${array_run[@]}"; do
|
||||||
single_log_path="$dir_log/$log_dir/${single_log_time}_$((j + 1)).log"
|
single_log_path="$dir_log/$log_dir/${single_log_time}_$((j + 1)).log"
|
||||||
let j++
|
let j++
|
||||||
|
|
||||||
@@ -203,16 +203,16 @@ run_concurrent() {
|
|||||||
export "${env_param}=${array[$i - 1]}"
|
export "${env_param}=${array[$i - 1]}"
|
||||||
clear_non_sh_env
|
clear_non_sh_env
|
||||||
fi
|
fi
|
||||||
eval envParam="${env_param}" numParam="${i}" run_task_command $which_program $file_param "${script_params[@]}" &>$single_log_path &
|
envParam="${env_param}" numParam="${i}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}" &>"$single_log_path" &
|
||||||
done
|
done
|
||||||
|
|
||||||
wait
|
wait
|
||||||
local k=0
|
local k=0
|
||||||
for i in ${array_run[@]}; do
|
for i in "${array_run[@]}"; do
|
||||||
single_log_path="$dir_log/$log_dir/${single_log_time}_$((k + 1)).log"
|
single_log_path="$dir_log/$log_dir/${single_log_time}_$((k + 1)).log"
|
||||||
let k++
|
let k++
|
||||||
cat $single_log_path
|
cat "$single_log_path"
|
||||||
[[ -f $single_log_path ]] && rm -f $single_log_path
|
[[ -f $single_log_path ]] && rm -f "$single_log_path"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ run_designated() {
|
|||||||
|
|
||||||
if [[ $isJsOrPythonFile == 'false' ]]; then
|
if [[ $isJsOrPythonFile == 'false' ]]; then
|
||||||
local n=0
|
local n=0
|
||||||
for i in ${array_run[@]}; do
|
for i in "${array_run[@]}"; do
|
||||||
array_str[n]=${array[$i - 1]}
|
array_str[n]=${array[$i - 1]}
|
||||||
let n++
|
let n++
|
||||||
done
|
done
|
||||||
@@ -241,9 +241,9 @@ run_designated() {
|
|||||||
clear_non_sh_env
|
clear_non_sh_env
|
||||||
fi
|
fi
|
||||||
|
|
||||||
enter_script_workdir
|
enter_script_workdir || return $?
|
||||||
|
|
||||||
envParam="${env_param}" numParam="${num_param}" run_task_command $which_program $file_param "${script_params[@]}"
|
envParam="${env_param}" numParam="${num_param}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
## 运行其他命令
|
## 运行其他命令
|
||||||
@@ -273,7 +273,7 @@ run_else() {
|
|||||||
if [[ -n "$script_for_dir" ]]; then
|
if [[ -n "$script_for_dir" ]]; then
|
||||||
local saved_file_param="$file_param"
|
local saved_file_param="$file_param"
|
||||||
file_param="$script_for_dir"
|
file_param="$script_for_dir"
|
||||||
enter_script_workdir true
|
enter_script_workdir true || return $?
|
||||||
local adjusted_script="$file_param"
|
local adjusted_script="$file_param"
|
||||||
file_param="$saved_file_param"
|
file_param="$saved_file_param"
|
||||||
|
|
||||||
@@ -289,17 +289,17 @@ run_else() {
|
|||||||
set -- "${new_args[@]}"
|
set -- "${new_args[@]}"
|
||||||
else
|
else
|
||||||
# 没有找到脚本参数,只 cd 到 scripts 目录
|
# 没有找到脚本参数,只 cd 到 scripts 目录
|
||||||
enter_script_workdir true
|
enter_script_workdir true || return $?
|
||||||
shift
|
shift
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# file_param 本身就是脚本,直接用 enter_script_workdir 处理
|
# file_param 本身就是脚本,直接用 enter_script_workdir 处理
|
||||||
enter_script_workdir true
|
enter_script_workdir true || return $?
|
||||||
shift
|
shift
|
||||||
fi
|
fi
|
||||||
|
|
||||||
clear_non_sh_env
|
clear_non_sh_env
|
||||||
run_task_command $which_program $file_param "$@"
|
run_task_command ${which_program:+"$which_program"} "$file_param" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
check_file() {
|
check_file() {
|
||||||
@@ -313,12 +313,14 @@ check_file() {
|
|||||||
export PREV_NODE_OPTIONS="${NODE_OPTIONS:=}"
|
export PREV_NODE_OPTIONS="${NODE_OPTIONS:=}"
|
||||||
export PREV_PYTHONPATH="${PYTHONPATH:=}"
|
export PREV_PYTHONPATH="${PYTHONPATH:=}"
|
||||||
if [[ $1 == *.js ]] || [[ $1 == *.ts ]] || [[ $1 == *.mjs ]]; then
|
if [[ $1 == *.js ]] || [[ $1 == *.ts ]] || [[ $1 == *.mjs ]]; then
|
||||||
export NODE_OPTIONS="-r ${file_preload_js} ${NODE_OPTIONS}"
|
local preload_path=${file_preload_js//\\/\\\\}
|
||||||
|
preload_path=${preload_path//\"/\\\"}
|
||||||
|
export NODE_OPTIONS="-r \"${preload_path}\" ${NODE_OPTIONS}"
|
||||||
else
|
else
|
||||||
export PYTHONPATH="${dir_preload}:${dir_config}:${PYTHONPATH}"
|
export PYTHONPATH="${dir_preload}:${dir_config}:${PYTHONPATH}"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
. $file_env
|
. "$file_env"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class GrpcClient {
|
|||||||
'updateEnvNames',
|
'updateEnvNames',
|
||||||
'getEnvById',
|
'getEnvById',
|
||||||
'systemNotify',
|
'systemNotify',
|
||||||
|
'recordNotifyLog',
|
||||||
'getCronDetail',
|
'getCronDetail',
|
||||||
'createCron',
|
'createCron',
|
||||||
'updateCron',
|
'updateCron',
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
|
||||||
|
// A reporting failure must never terminate a script or change its send result.
|
||||||
|
module.exports = async function reportNotificationAudit(payload) {
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
if (!process.env.QL_DIR) return;
|
||||||
|
const grpc = require('@grpc/grpc-js');
|
||||||
|
const loader = require('@grpc/proto-loader');
|
||||||
|
const certDir = path.join(
|
||||||
|
process.env.QL_DATA_DIR || path.join(process.env.QL_DIR, 'data'),
|
||||||
|
'config/grpc',
|
||||||
|
);
|
||||||
|
const credentials = grpc.credentials.createSsl(
|
||||||
|
fs.readFileSync(path.join(certDir, 'ca.crt')),
|
||||||
|
fs.readFileSync(path.join(certDir, 'client.key')),
|
||||||
|
fs.readFileSync(path.join(certDir, 'client.crt')),
|
||||||
|
);
|
||||||
|
const definition = loader.loadSync(
|
||||||
|
path.join(process.env.QL_DIR, 'back/protos/api.proto'),
|
||||||
|
{ keepCase: true, defaults: true },
|
||||||
|
);
|
||||||
|
const Api = grpc.loadPackageDefinition(definition).com.ql.api.Api;
|
||||||
|
client = new Api(
|
||||||
|
`localhost:${process.env.GRPC_PORT || '5500'}`,
|
||||||
|
credentials,
|
||||||
|
{ 'grpc.enable_http_proxy': 0 },
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
client.RecordNotifyLog(
|
||||||
|
payload,
|
||||||
|
new grpc.Metadata(),
|
||||||
|
{ deadline: new Date(Date.now() + 1500) },
|
||||||
|
() => resolve(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Missing certificates, old panels and offline services are best effort.
|
||||||
|
} finally {
|
||||||
|
if (client) client.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
+8
-2
@@ -1,4 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# BSD grep accepts \d as a digit; GNU grep treats it as a literal d.
|
||||||
|
string=$(awk '$1 == "version:" { print $2; exit }' version.yaml) || exit 1
|
||||||
|
if [[ ! $string =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[[:alnum:].-]+)?(\+[[:alnum:].-]+)?$ ]]; then
|
||||||
|
printf 'Invalid release version: %s\n' "$string" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
version="v$string"
|
||||||
echo -e "开始发布"
|
echo -e "开始发布"
|
||||||
|
|
||||||
echo -e "切换master分支"
|
echo -e "切换master分支"
|
||||||
@@ -9,8 +17,6 @@ git push --set-upstream origin master -f
|
|||||||
echo -e "更新cdn文件"
|
echo -e "更新cdn文件"
|
||||||
ts-node-transpile-only sample/tool.ts
|
ts-node-transpile-only sample/tool.ts
|
||||||
|
|
||||||
string=$(cat version.yaml | grep "version" | egrep "[^ ]*" -o | egrep "\d\.*")
|
|
||||||
version="v$string"
|
|
||||||
echo -e "当前版本$version"
|
echo -e "当前版本$version"
|
||||||
|
|
||||||
echo -e "删除已经存在的本地tag"
|
echo -e "删除已经存在的本地tag"
|
||||||
|
|||||||
+21
-16
@@ -3,42 +3,47 @@
|
|||||||
days=$1
|
days=$1
|
||||||
|
|
||||||
remove_js_log() {
|
remove_js_log() {
|
||||||
local log_full_path_list=$(find $dir_log -name "*.log")
|
local log log_date log_timestamp diff_time log_path encoded_path result
|
||||||
local diff_time
|
local now
|
||||||
for log in $log_full_path_list; do
|
now=$(date +%s) || return $?
|
||||||
local log_date=$(echo $log | awk -F "/" '{print $NF}' | cut -c1-10)
|
while IFS= read -r -d '' log; do
|
||||||
|
log_date=${log##*/}
|
||||||
|
log_date=${log_date:0:10}
|
||||||
if ! [[ $log_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
if ! [[ $log_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
||||||
if [[ $is_macos -eq 1 ]]; then
|
if [[ $is_macos -eq 1 ]]; then
|
||||||
log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log")
|
log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log") || continue
|
||||||
else
|
else
|
||||||
log_date=$(stat -c %y "$log" | cut -d ' ' -f 1)
|
log_date=$(stat -c %y "$log" | cut -d ' ' -f 1)
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if [[ $is_macos -eq 1 ]]; then
|
if [[ $is_macos -eq 1 ]]; then
|
||||||
diff_time=$(($(date +%s) - $(date -j -f "%Y-%m-%d" "$log_date" +%s)))
|
# BSD date otherwise fills the omitted time from the current clock.
|
||||||
|
log_timestamp=$(date -j -f "%Y-%m-%d %H:%M:%S" "$log_date 00:00:00" +%s) || continue
|
||||||
else
|
else
|
||||||
diff_time=$(($(date +%s) - $(date +%s -d "$log_date")))
|
log_timestamp=$(date +%s -d "$log_date") || continue
|
||||||
fi
|
fi
|
||||||
|
diff_time=$((now - log_timestamp))
|
||||||
if [[ $diff_time -gt $((${days} * 86400)) ]]; then
|
if [[ $diff_time -gt $((${days} * 86400)) ]]; then
|
||||||
local log_path=$(echo "$log" | sed "s,${dir_log}/,,g")
|
log_path=${log#"$dir_log"/}
|
||||||
local result=$(find_cron_api "log_path=$log_path")
|
encoded_path=$(printf '%s' "$log_path" | jq -sRr @uri) || continue
|
||||||
|
result=$(find_cron_api "log_path=$encoded_path")
|
||||||
t '查询文件 %s' "$log_path"
|
t '查询文件 %s' "$log_path"
|
||||||
if [[ -z $result ]]; then
|
if [[ -z $result ]]; then
|
||||||
t '删除中~'
|
t '删除中~'
|
||||||
rm -vf $log
|
rm -vf -- "$log"
|
||||||
else
|
else
|
||||||
t '正在被 %s 使用,跳过~' "$result"
|
t '正在被 %s 使用,跳过~' "$result"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done < <(find "$dir_log" -type f -name "*.log" -print0)
|
||||||
}
|
}
|
||||||
|
|
||||||
remove_empty_dir() {
|
remove_empty_dir() {
|
||||||
cd $dir_log
|
local dir
|
||||||
for dir in $(ls); do
|
for dir in "$dir_log"/*/ "$dir_log"/.[!.]*/ "$dir_log"/..?*/; do
|
||||||
if [[ -d $dir ]] && [[ -z $(ls $dir) ]]; then
|
[[ -d "$dir" ]] || continue
|
||||||
rm -rf $dir
|
# rmdir also protects a directory populated between discovery and removal.
|
||||||
fi
|
rmdir -- "$dir" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+45
-41
@@ -77,16 +77,19 @@ load_ql_envs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import_config() {
|
import_config() {
|
||||||
[[ -f $file_config_user ]] && . $file_config_user
|
[[ -f $file_config_user ]] && . "$file_config_user"
|
||||||
[[ -f $dir_preload/lang_env.sh ]] && . $dir_preload/lang_env.sh
|
[[ -f $dir_preload/lang_env.sh ]] && . "$dir_preload/lang_env.sh"
|
||||||
|
|
||||||
# 加载语言包(bash 4+ 支持 declare -A,不兼容时回退输出中文 key)
|
# Language packs need global associative arrays (Bash 4.2+). Probe the
|
||||||
|
# capability so Bash 3.2 and 4.0/4.1 retain the Chinese-key fallback.
|
||||||
local lang=${QL_LANG:-zh}
|
local lang=${QL_LANG:-zh}
|
||||||
local lang_file="$dir_shell/lang/${lang}.sh"
|
local lang_file="$dir_shell/lang/${lang}.sh"
|
||||||
if [[ ${BASH_VERSINFO[0]} -ge 4 ]] && [[ -f $lang_file ]]; then
|
if (declare -gA __ql_lang_probe) 2>/dev/null; then
|
||||||
. $lang_file
|
if [[ -f $lang_file ]]; then
|
||||||
elif [[ ${BASH_VERSINFO[0]} -ge 4 ]]; then
|
. "$lang_file"
|
||||||
. "$dir_shell/lang/zh.sh"
|
elif [[ -f "$dir_shell/lang/zh.sh" ]]; then
|
||||||
|
. "$dir_shell/lang/zh.sh"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
load_ql_envs
|
load_ql_envs
|
||||||
@@ -132,7 +135,7 @@ unset_proxy() {
|
|||||||
make_dir() {
|
make_dir() {
|
||||||
local dir=$1
|
local dir=$1
|
||||||
if [[ ! -d $dir ]]; then
|
if [[ ! -d $dir ]]; then
|
||||||
mkdir -p $dir
|
mkdir -p -- "$dir"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,57 +179,57 @@ define_cmd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fix_config() {
|
fix_config() {
|
||||||
make_dir $dir_tmp
|
make_dir "$dir_tmp"
|
||||||
make_dir $dir_static
|
make_dir "$dir_static"
|
||||||
make_dir $dir_data
|
make_dir "$dir_data"
|
||||||
make_dir $dir_config
|
make_dir "$dir_config"
|
||||||
make_dir $dir_log
|
make_dir "$dir_log"
|
||||||
make_dir $dir_db
|
make_dir "$dir_db"
|
||||||
make_dir $dir_scripts
|
make_dir "$dir_scripts"
|
||||||
make_dir $dir_list_tmp
|
make_dir "$dir_list_tmp"
|
||||||
make_dir $dir_repo
|
make_dir "$dir_repo"
|
||||||
make_dir $dir_raw
|
make_dir "$dir_raw"
|
||||||
make_dir $dir_update_log
|
make_dir "$dir_update_log"
|
||||||
make_dir $dir_dep
|
make_dir "$dir_dep"
|
||||||
|
|
||||||
if [[ ! -s $file_config_user ]]; then
|
if [[ ! -s $file_config_user ]]; then
|
||||||
cp -f $file_config_sample $file_config_user
|
cp -f "$file_config_sample" "$file_config_user"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f $file_task_before ]]; then
|
if [[ ! -f $file_task_before ]]; then
|
||||||
cp -f $file_task_sample $file_task_before
|
cp -f "$file_task_sample" "$file_task_before"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f $file_task_after ]]; then
|
if [[ ! -f $file_task_after ]]; then
|
||||||
cp -f $file_task_sample $file_task_after
|
cp -f "$file_task_sample" "$file_task_after"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f $file_extra_shell ]]; then
|
if [[ ! -f $file_extra_shell ]]; then
|
||||||
cp -f $file_extra_sample $file_extra_shell
|
cp -f "$file_extra_sample" "$file_extra_shell"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -s $file_notify_py ]]; then
|
if [[ ! -s $file_notify_py ]]; then
|
||||||
cp -f $file_notify_py_sample $file_notify_py
|
cp -f "$file_notify_py_sample" "$file_notify_py"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -s $file_notify_js ]]; then
|
if [[ ! -s $file_notify_js ]]; then
|
||||||
cp -f $file_notify_js_sample $file_notify_js
|
cp -f "$file_notify_js_sample" "$file_notify_js"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -s $file_test_js ]]; then
|
if [[ ! -s $file_test_js ]]; then
|
||||||
cp -f $file_test_js_sample $file_test_js
|
cp -f "$file_test_js_sample" "$file_test_js"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -s $file_test_py ]]; then
|
if [[ ! -s $file_test_py ]]; then
|
||||||
cp -f $file_test_py_sample $file_test_py
|
cp -f "$file_test_py_sample" "$file_test_py"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -s $dep_notify_js ]]; then
|
if [[ ! -s $dep_notify_js ]]; then
|
||||||
cp -f $file_notify_js_sample $dep_notify_js
|
cp -f "$file_notify_js_sample" "$dep_notify_js"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -s $dep_notify_py ]]; then
|
if [[ ! -s $dep_notify_py ]]; then
|
||||||
cp -f $file_notify_py_sample $dep_notify_py
|
cp -f "$file_notify_py_sample" "$dep_notify_py"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -246,17 +249,17 @@ npm_install_2() {
|
|||||||
local dir_current=$(pwd)
|
local dir_current=$(pwd)
|
||||||
local dir_work=$1
|
local dir_work=$1
|
||||||
|
|
||||||
cd $dir_work
|
cd "$dir_work" || return $?
|
||||||
t '安装 %s 依赖包...\n' "$dir_work"
|
t '安装 %s 依赖包...\n' "$dir_work"
|
||||||
npm_install_sub
|
npm_install_sub
|
||||||
cd $dir_current
|
cd "$dir_current" || return $?
|
||||||
}
|
}
|
||||||
|
|
||||||
diff_and_copy() {
|
diff_and_copy() {
|
||||||
local copy_source=$1
|
local copy_source=$1
|
||||||
local copy_to=$2
|
local copy_to=$2
|
||||||
if [[ ! -s $copy_to ]] || [[ $(diff $copy_source $copy_to) ]]; then
|
if [[ ! -s $copy_to ]] || [[ $(diff "$copy_source" "$copy_to") ]]; then
|
||||||
cp -f $copy_source $copy_to
|
cp -f "$copy_source" "$copy_to"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,12 +268,13 @@ git_clone_scripts() {
|
|||||||
local dir="$2"
|
local dir="$2"
|
||||||
local branch="$3"
|
local branch="$3"
|
||||||
local proxy="$4"
|
local proxy="$4"
|
||||||
[[ $branch ]] && local part_cmd="-b $branch "
|
local branch_args=()
|
||||||
|
[[ $branch ]] && branch_args=(-b "$branch")
|
||||||
t '开始拉取仓库 %s 到 %s\n' "${uniq_path}" "$dir"
|
t '开始拉取仓库 %s 到 %s\n' "${uniq_path}" "$dir"
|
||||||
|
|
||||||
set_proxy "$proxy"
|
set_proxy "$proxy"
|
||||||
|
|
||||||
git clone -q --depth=1 $part_cmd $url $dir
|
git clone -q --depth=1 "${branch_args[@]}" "$url" "$dir"
|
||||||
exit_status=$?
|
exit_status=$?
|
||||||
|
|
||||||
unset_proxy
|
unset_proxy
|
||||||
@@ -283,7 +287,7 @@ random_range() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete_pm2() {
|
delete_pm2() {
|
||||||
cd $dir_root
|
cd "$dir_root" || return $?
|
||||||
# Try to delete PM2 processes, but don't fail if PM2 is not available
|
# Try to delete PM2 processes, but don't fail if PM2 is not available
|
||||||
pm2 delete ecosystem.config.js 2>/dev/null || true
|
pm2 delete ecosystem.config.js 2>/dev/null || true
|
||||||
# Also try to kill any directly spawned node processes
|
# Also try to kill any directly spawned node processes
|
||||||
@@ -291,7 +295,7 @@ delete_pm2() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reload_pm2() {
|
reload_pm2() {
|
||||||
cd $dir_root
|
cd "$dir_root" || return $?
|
||||||
restore_env_vars
|
restore_env_vars
|
||||||
|
|
||||||
# Try to start PM2, but handle failures gracefully
|
# Try to start PM2, but handle failures gracefully
|
||||||
@@ -310,7 +314,7 @@ reload_pm2() {
|
|||||||
if [[ "$QL_CONTAINER" == "true" ]]; then
|
if [[ "$QL_CONTAINER" == "true" ]]; then
|
||||||
nohup node static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
|
nohup node static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
|
||||||
else
|
else
|
||||||
nohup node static/build/app.js >$dir_log/qinglong.log 2>&1 &
|
nohup node static/build/app.js >"$dir_log/qinglong.log" 2>&1 &
|
||||||
fi
|
fi
|
||||||
local node_pid=$!
|
local node_pid=$!
|
||||||
|
|
||||||
@@ -398,7 +402,7 @@ handle_task_start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
run_task_before() {
|
run_task_before() {
|
||||||
. $file_task_before "$@"
|
. "$file_task_before" "$@"
|
||||||
|
|
||||||
if [[ ${task_before:=} ]]; then
|
if [[ ${task_before:=} ]]; then
|
||||||
t '执行前置命令\n'
|
t '执行前置命令\n'
|
||||||
@@ -408,7 +412,7 @@ run_task_before() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
run_task_after() {
|
run_task_after() {
|
||||||
. $file_task_after "$@"
|
. "$file_task_after" "$@"
|
||||||
|
|
||||||
if [[ ${task_after:=} ]]; then
|
if [[ ${task_after:=} ]]; then
|
||||||
t '\n执行后置命令\n'
|
t '\n执行后置命令\n'
|
||||||
|
|||||||
@@ -63,12 +63,12 @@ reload_pm2
|
|||||||
if [[ $command != "reload" ]]; then
|
if [[ $command != "reload" ]]; then
|
||||||
if [[ $AutoStartBot == true ]]; then
|
if [[ $AutoStartBot == true ]]; then
|
||||||
log_with_style "INFO" "🤖 4. 启动 bot..."
|
log_with_style "INFO" "🤖 4. 启动 bot..."
|
||||||
nohup ql bot >$dir_log/bot.log 2>&1 &
|
nohup ql bot >"$dir_log/bot.log" 2>&1 &
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $EnableExtraShell == true ]]; then
|
if [[ $EnableExtraShell == true ]]; then
|
||||||
log_with_style "INFO" "🛠️ 5. 执行自定义脚本..."
|
log_with_style "INFO" "🛠️ 5. 执行自定义脚本..."
|
||||||
nohup ql extra >$dir_log/extra.log 2>&1 &
|
nohup ql extra >"$dir_log/extra.log" 2>&1 &
|
||||||
fi
|
fi
|
||||||
|
|
||||||
pm2 startup
|
pm2 startup
|
||||||
|
|||||||
+30
-11
@@ -4,31 +4,46 @@
|
|||||||
set -e
|
set -e
|
||||||
set -x
|
set -x
|
||||||
|
|
||||||
|
# Configuration and language helpers are not loaded during bootstrap errors.
|
||||||
|
startup_message() {
|
||||||
|
local format="$1"
|
||||||
|
shift
|
||||||
|
printf -- "$format\n" "$@" >&2
|
||||||
|
}
|
||||||
|
|
||||||
if [[ ! $QL_DIR ]]; then
|
if [[ ! $QL_DIR ]]; then
|
||||||
npm_dir=$(npm root -g)
|
npm_dir=$(npm root -g 2>/dev/null) || npm_dir=""
|
||||||
pnpm_dir=$(pnpm root -g)
|
pnpm_dir=""
|
||||||
if [[ -d "$npm_dir/@whyour/qinglong" ]]; then
|
if command -v pnpm >/dev/null 2>&1; then
|
||||||
|
pnpm_dir=$(pnpm root -g 2>/dev/null) || pnpm_dir=""
|
||||||
|
fi
|
||||||
|
if [[ -n "$npm_dir" && -d "$npm_dir/@whyour/qinglong" ]]; then
|
||||||
QL_DIR="$npm_dir/@whyour/qinglong"
|
QL_DIR="$npm_dir/@whyour/qinglong"
|
||||||
elif [[ -d "$pnpm_dir/@whyour/qinglong" ]]; then
|
elif [[ -n "$pnpm_dir" && -d "$pnpm_dir/@whyour/qinglong" ]]; then
|
||||||
QL_DIR="$pnpm_dir/@whyour/qinglong"
|
QL_DIR="$pnpm_dir/@whyour/qinglong"
|
||||||
else
|
else
|
||||||
t '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装'
|
startup_message '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $QL_DIR ]]; then
|
if [[ $QL_DIR ]]; then
|
||||||
t '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR"
|
startup_message '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ $QL_DIR != /* ]]; then
|
||||||
|
startup_message 'QL_DIR 必须为绝对路径,例如 /ql'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! $QL_DATA_DIR ]]; then
|
if [[ ! $QL_DATA_DIR ]]; then
|
||||||
t '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量'
|
startup_message '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量'
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $QL_DATA_DIR != */data ]]; then
|
if [[ $QL_DATA_DIR != /*/data ]]; then
|
||||||
t 'QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中'
|
startup_message 'QL_DATA_DIR 必须为以 /data 结尾的绝对路径,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中'
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -38,7 +53,11 @@ if [[ $command != "reload" ]]; then
|
|||||||
# 安装依赖
|
# 安装依赖
|
||||||
os_name="${QL_OS_TYPE:-}"
|
os_name="${QL_OS_TYPE:-}"
|
||||||
if [ -z "$os_name" ]; then
|
if [ -z "$os_name" ]; then
|
||||||
os_name=$(source /etc/os-release && echo "$ID")
|
if [[ -r /etc/os-release ]]; then
|
||||||
|
os_name=$(source /etc/os-release && echo "$ID")
|
||||||
|
else
|
||||||
|
os_name=$(uname -s)
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 非 root 用户使用 sudo
|
# 非 root 用户使用 sudo
|
||||||
@@ -69,7 +88,7 @@ if [[ $command != "reload" ]]; then
|
|||||||
$SUDO apt-get install -y git curl wget tzdata perl openssl jq nginx procps netcat-openbsd openssh-client
|
$SUDO apt-get install -y git curl wget tzdata perl openssl jq nginx procps netcat-openbsd openssh-client
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
t '暂不支持此系统部署 %s' "$os_name"
|
startup_message '暂不支持此系统部署 %s' "$os_name"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
+22
-10
@@ -36,19 +36,31 @@ run_task_command() {
|
|||||||
) &
|
) &
|
||||||
timeout_watcher=$!
|
timeout_watcher=$!
|
||||||
set +m
|
set +m
|
||||||
trap 'kill -KILL -- "-$timeout_watcher" 2>/dev/null || :
|
finish_task_timeout() {
|
||||||
|
local code="$1"
|
||||||
|
trap - EXIT
|
||||||
|
{
|
||||||
|
# Bash can report a killed job between any two cleanup commands.
|
||||||
|
# Keep redirection active until the watchdog is fully reaped.
|
||||||
|
kill -KILL -- "-$timeout_watcher" || :
|
||||||
|
wait "$timeout_watcher" || :
|
||||||
|
} 2>/dev/null
|
||||||
if kill -TERM -- "-$timeout_worker" 2>/dev/null; then sleep 0.1; fi
|
if kill -TERM -- "-$timeout_worker" 2>/dev/null; then sleep 0.1; fi
|
||||||
kill -KILL -- "-$timeout_worker" 2>/dev/null || :
|
kill -KILL -- "-$timeout_worker" 2>/dev/null || :
|
||||||
wait "$timeout_worker" 2>/dev/null || :
|
wait "$timeout_worker" 2>/dev/null || :
|
||||||
wait "$timeout_watcher" 2>/dev/null || :
|
rm -rf -- "$timeout_dir"
|
||||||
rm -rf -- "$timeout_dir"' EXIT
|
exit "$code"
|
||||||
trap 'exit 130' INT
|
}
|
||||||
trap 'exit 143' TERM
|
# Older Bash can omit a subshell's EXIT trap in sourced pipelines. Call
|
||||||
trap 'exit 129' HUP
|
# cleanup explicitly on completion/signals, retaining EXIT as a fallback.
|
||||||
trap 'exit 131' QUIT
|
trap 'finish_task_timeout "$?"' EXIT
|
||||||
|
trap 'finish_task_timeout 130' INT
|
||||||
|
trap 'finish_task_timeout 143' TERM
|
||||||
|
trap 'finish_task_timeout 129' HUP
|
||||||
|
trap 'finish_task_timeout 131' QUIT
|
||||||
# Do not clean up a timer whose process group has not initialized yet.
|
# Do not clean up a timer whose process group has not initialized yet.
|
||||||
while [[ ! -f "$timeout_dir/ready" ]]; do
|
while [[ ! -f "$timeout_dir/ready" ]]; do
|
||||||
kill -0 "$timeout_watcher" 2>/dev/null || exit 125
|
kill -0 "$timeout_watcher" 2>/dev/null || finish_task_timeout 125
|
||||||
sleep 0.001
|
sleep 0.001
|
||||||
done
|
done
|
||||||
wait "$timeout_worker"
|
wait "$timeout_worker"
|
||||||
@@ -56,8 +68,8 @@ run_task_command() {
|
|||||||
if [[ -f "$timeout_dir/expired" ]]; then
|
if [[ -f "$timeout_dir/expired" ]]; then
|
||||||
# Let the timer finish escalation even if the task leader exited first.
|
# Let the timer finish escalation even if the task leader exited first.
|
||||||
wait "$timeout_watcher" 2>/dev/null || :
|
wait "$timeout_watcher" 2>/dev/null || :
|
||||||
exit 124
|
finish_task_timeout 124
|
||||||
fi
|
fi
|
||||||
exit "$timeout_code"
|
finish_task_timeout "$timeout_code"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -1,9 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
dir_shell=$QL_DIR/shell
|
dir_shell=$QL_DIR/shell
|
||||||
. $dir_shell/share.sh
|
. "$dir_shell/share.sh"
|
||||||
. $dir_shell/api.sh
|
. "$dir_shell/api.sh"
|
||||||
. $dir_shell/task-timeout.sh
|
. "$dir_shell/task-timeout.sh"
|
||||||
|
|
||||||
trap 'single_hanle SIGINT' INT
|
trap 'single_hanle SIGINT' INT
|
||||||
trap 'single_hanle SIGTERM' TERM
|
trap 'single_hanle SIGTERM' TERM
|
||||||
@@ -48,7 +48,7 @@ handle_log_path() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z ${ID:=} ]]; then
|
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}')
|
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
|
fi
|
||||||
local suffix=""
|
local suffix=""
|
||||||
if [[ ! -z $ID ]]; then
|
if [[ ! -z $ID ]]; then
|
||||||
@@ -89,10 +89,10 @@ handle_log_path() {
|
|||||||
log_path="$real_log_path"
|
log_path="$real_log_path"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cmd="2>&1 | tee -a $dir_log/$log_path"
|
printf -v cmd '2>&1 | tee -a %q' "$dir_log/$log_path"
|
||||||
make_dir "$dir_log/$log_dir"
|
make_dir "$dir_log/$log_dir"
|
||||||
if [[ "${no_tee:=}" == "true" ]]; then
|
if [[ "${no_tee:=}" == "true" ]]; then
|
||||||
cmd=">> $dir_log/$log_path 2>&1"
|
printf -v cmd '>> %q 2>&1' "$dir_log/$log_path"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${real_time:=}" == "true" ]]; then
|
if [[ "${real_time:=}" == "true" ]]; then
|
||||||
@@ -162,5 +162,5 @@ define_program "${task_shell_params[@]}"
|
|||||||
handle_log_path "${task_shell_params[@]}"
|
handle_log_path "${task_shell_params[@]}"
|
||||||
init_begin_time
|
init_begin_time
|
||||||
|
|
||||||
eval . $dir_shell/otask.sh "$cmd"
|
eval '. "$dir_shell/otask.sh"' "$cmd"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
+23
-17
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
dir_shell=$QL_DIR/shell
|
dir_shell=$QL_DIR/shell
|
||||||
. $dir_shell/share.sh
|
. "$dir_shell/share.sh"
|
||||||
. $dir_shell/api.sh
|
. "$dir_shell/api.sh"
|
||||||
load_ql_envs
|
load_ql_envs
|
||||||
. $dir_shell/env.sh
|
. "$dir_shell/env.sh"
|
||||||
|
|
||||||
send_mark=$dir_shell/send_mark
|
send_mark=$dir_shell/send_mark
|
||||||
|
|
||||||
@@ -165,17 +165,20 @@ update_raw() {
|
|||||||
t '开始下载:%s 保存路径:%s\n' "${raw_url}" "$dir_raw/${raw_file_name}"
|
t '开始下载:%s 保存路径:%s\n' "${raw_url}" "$dir_raw/${raw_file_name}"
|
||||||
|
|
||||||
set_proxy "$proxy"
|
set_proxy "$proxy"
|
||||||
wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" ${raw_url}
|
if wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" "$raw_url"; then
|
||||||
exit_status=$?
|
exit_status=0
|
||||||
|
else
|
||||||
|
exit_status=$?
|
||||||
|
fi
|
||||||
unset_proxy
|
unset_proxy
|
||||||
|
|
||||||
if [[ $? -eq 0 ]]; then
|
if [[ $exit_status -eq 0 ]]; then
|
||||||
mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}"
|
mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}" || return $?
|
||||||
t '下载 %s 成功...\n' "${raw_file_name}"
|
t '下载 %s 成功...\n' "${raw_file_name}"
|
||||||
cd $dir_raw
|
cd "$dir_raw" || return $?
|
||||||
local filename="raw_${raw_file_name}"
|
local filename="raw_${raw_file_name}"
|
||||||
local cron_id=$(cat $list_crontab_user | grep -E "$cmd_task.* $filename" | perl -pe "s|.*ID=(.*) $cmd_task.* $filename\.*|\1|" | head -1 | awk -F " " '{print $1}')
|
local cron_id=$(cat "$list_crontab_user" | grep -E "$cmd_task.* $filename" | perl -pe "s|.*ID=(.*) $cmd_task.* $filename\.*|\1|" | head -1 | awk -F " " '{print $1}')
|
||||||
cp -f $raw_file_name $dir_scripts/${filename}
|
cp -f "$raw_file_name" "$dir_scripts/${filename}" || return $?
|
||||||
if [[ -z $cron_id ]] && [[ ${autoAddCron} == true ]]; then
|
if [[ -z $cron_id ]] && [[ ${autoAddCron} == true ]]; then
|
||||||
cron_line=$(
|
cron_line=$(
|
||||||
perl -ne "{
|
perl -ne "{
|
||||||
@@ -202,6 +205,7 @@ update_raw() {
|
|||||||
else
|
else
|
||||||
t '下载 %s 失败,保留之前正常下载的版本...\n' "${raw_file_name}"
|
t '下载 %s 失败,保留之前正常下载的版本...\n' "${raw_file_name}"
|
||||||
[[ -f "$dir_raw/${raw_file_name}.new" ]] && rm -f "$dir_raw/${raw_file_name}.new"
|
[[ -f "$dir_raw/${raw_file_name}.new" ]] && rm -f "$dir_raw/${raw_file_name}.new"
|
||||||
|
return "$exit_status"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -209,7 +213,7 @@ update_raw() {
|
|||||||
## 调用用户自定义的extra.sh
|
## 调用用户自定义的extra.sh
|
||||||
run_extra_shell() {
|
run_extra_shell() {
|
||||||
if [[ -f $file_extra_shell ]]; then
|
if [[ -f $file_extra_shell ]]; then
|
||||||
. $file_extra_shell
|
. "$file_extra_shell"
|
||||||
else
|
else
|
||||||
t '%s文件不存在,跳过执行...\n' "$file_extra_shell"
|
t '%s文件不存在,跳过执行...\n' "$file_extra_shell"
|
||||||
fi
|
fi
|
||||||
@@ -266,7 +270,8 @@ show_service_logs() {
|
|||||||
# Winston writes the active runtime log by date. Follow the filename so
|
# Winston writes the active runtime log by date. Follow the filename so
|
||||||
# size-based rotations are handled without involving the PM2 log files.
|
# size-based rotations are handled without involving the PM2 log files.
|
||||||
local current_system_log="$system_log_dir/$(date +%F).log"
|
local current_system_log="$system_log_dir/$(date +%F).log"
|
||||||
tail -n 0 --retry -F "$current_system_log"
|
# GNU and BSD tail both support -F; --retry is GNU-only and implied by -F.
|
||||||
|
tail -n 0 -F "$current_system_log"
|
||||||
}
|
}
|
||||||
|
|
||||||
## 脚本用法
|
## 脚本用法
|
||||||
@@ -484,9 +489,9 @@ main() {
|
|||||||
local log_path="${log_dir}/${log_time}.log"
|
local log_path="${log_dir}/${log_time}.log"
|
||||||
local file_path="$dir_log/$log_path"
|
local file_path="$dir_log/$log_path"
|
||||||
|
|
||||||
cmd="2>&1 | tee -a $file_path"
|
printf -v cmd '2>&1 | tee -a %q' "$file_path"
|
||||||
if [[ "$no_tee" == "true" ]]; then
|
if [[ "$no_tee" == "true" ]]; then
|
||||||
cmd=">> $file_path 2>&1"
|
printf -v cmd '>> %q 2>&1' "$file_path"
|
||||||
fi
|
fi
|
||||||
if [[ "$real_time" == "true" ]]; then
|
if [[ "$real_time" == "true" ]]; then
|
||||||
cmd=""
|
cmd=""
|
||||||
@@ -532,19 +537,20 @@ main() {
|
|||||||
get_uniq_path "$p2"
|
get_uniq_path "$p2"
|
||||||
if [[ -n $p2 ]]; then
|
if [[ -n $p2 ]]; then
|
||||||
update_raw "$p2" "$p3" "$p4" "$p5"
|
update_raw "$p2" "$p3" "$p4" "$p5"
|
||||||
|
command_status=$?
|
||||||
else
|
else
|
||||||
t '命令输入错误...\n'
|
t '命令输入错误...\n'
|
||||||
eval usage $cmd
|
eval usage $cmd
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
rmlog)
|
rmlog)
|
||||||
eval . $dir_shell/rmlog.sh "$p2" $cmd
|
eval '. "$dir_shell/rmlog.sh" "$p2"' "$cmd"
|
||||||
;;
|
;;
|
||||||
bot)
|
bot)
|
||||||
eval . $dir_shell/bot.sh $cmd
|
eval '. "$dir_shell/bot.sh"' "$cmd"
|
||||||
;;
|
;;
|
||||||
check)
|
check)
|
||||||
eval . $dir_shell/check.sh $cmd
|
eval '. "$dir_shell/check.sh"' "$cmd"
|
||||||
;;
|
;;
|
||||||
resetlet)
|
resetlet)
|
||||||
eval update_auth_config "\\\"retries\\\":0" "重置登录错误次数" $cmd
|
eval update_auth_config "\\\"retries\\\":0" "重置登录错误次数" $cmd
|
||||||
|
|||||||
@@ -699,6 +699,15 @@
|
|||||||
"已保存,本次未记录历史版本": "Saved without a history snapshot",
|
"已保存,本次未记录历史版本": "Saved without a history snapshot",
|
||||||
"已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.",
|
"已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.",
|
||||||
"保存失败": "Unable to save",
|
"保存失败": "Unable to save",
|
||||||
|
"通知日志": "Notification log",
|
||||||
|
"标题": "Title",
|
||||||
|
"内容": "Content",
|
||||||
|
"发送时间": "Sent at",
|
||||||
|
"推送渠道": "Channel",
|
||||||
|
"发送状态": "Delivery status",
|
||||||
|
"通知来源": "Notification source",
|
||||||
|
"脚本通知": "Script notification",
|
||||||
|
"系统通知": "System notification",
|
||||||
"Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python supports package names, Git URLs, and existing requirements.txt or pyproject.toml files under the scripts or repository directory.",
|
"Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python supports package names, Git URLs, and existing requirements.txt or pyproject.toml files under the scripts or repository directory.",
|
||||||
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "Package name, Git URL, requirements.txt or pyproject.toml path"
|
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "Package name, Git URL, requirements.txt or pyproject.toml path"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -699,6 +699,15 @@
|
|||||||
"已保存,本次未记录历史版本": "已保存,本次未记录历史版本",
|
"已保存,本次未记录历史版本": "已保存,本次未记录历史版本",
|
||||||
"已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间",
|
"已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间",
|
||||||
"保存失败": "保存失败",
|
"保存失败": "保存失败",
|
||||||
|
"通知日志": "通知日志",
|
||||||
|
"标题": "标题",
|
||||||
|
"内容": "内容",
|
||||||
|
"发送时间": "发送时间",
|
||||||
|
"推送渠道": "推送渠道",
|
||||||
|
"发送状态": "发送状态",
|
||||||
|
"通知来源": "通知来源",
|
||||||
|
"脚本通知": "脚本通知",
|
||||||
|
"系统通知": "系统通知",
|
||||||
"Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径",
|
"Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径",
|
||||||
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "支持包名、GitHub链接、requirements.txt或pyproject.toml路径"
|
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "支持包名、GitHub链接、requirements.txt或pyproject.toml路径"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { SharedContext } from '@/layouts';
|
|||||||
import { depthFirstSearch, findNode, getEditorMode } from '@/utils';
|
import { depthFirstSearch, findNode, getEditorMode } from '@/utils';
|
||||||
import config from '@/utils/config';
|
import config from '@/utils/config';
|
||||||
import { request } from '@/utils/http';
|
import { request } from '@/utils/http';
|
||||||
|
import { getCodeMirrorExtensions } from '@/utils/codemirror/language';
|
||||||
import { canPreviewInMonaco } from '@/utils/monaco';
|
import { canPreviewInMonaco } from '@/utils/monaco';
|
||||||
import {
|
import {
|
||||||
CloudDownloadOutlined,
|
CloudDownloadOutlined,
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { PageContainer } from '@ant-design/pro-layout';
|
import { PageContainer } from '@ant-design/pro-layout';
|
||||||
import Editor from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import { langs } from '@uiw/codemirror-extensions-langs';
|
|
||||||
import CodeMirror from '@uiw/react-codemirror';
|
import CodeMirror from '@uiw/react-codemirror';
|
||||||
import { history, useOutletContext } from '@umijs/max';
|
import { history, useOutletContext } from '@umijs/max';
|
||||||
import {
|
import {
|
||||||
@@ -747,9 +747,7 @@ const Script = () => {
|
|||||||
{isPhone && (
|
{isPhone && (
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
value={value}
|
value={value}
|
||||||
extensions={
|
extensions={getCodeMirrorExtensions(mode)}
|
||||||
mode ? [langs[mode as keyof typeof langs]()] : undefined
|
|
||||||
}
|
|
||||||
theme={theme.includes('dark') ? 'dark' : 'light'}
|
theme={theme.includes('dark') ? 'dark' : 'light'}
|
||||||
readOnly={!isEditing || saving}
|
readOnly={!isEditing || saving}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import SecuritySettings from './security';
|
import SecuritySettings from './security';
|
||||||
import LoginLog from './loginLog';
|
import LoginLog from './loginLog';
|
||||||
|
import NotifyLog from './notifyLog';
|
||||||
import NotificationSetting from './notification';
|
import NotificationSetting from './notification';
|
||||||
import Other from './other';
|
import Other from './other';
|
||||||
import About from './about';
|
import About from './about';
|
||||||
@@ -126,6 +127,7 @@ const Setting = () => {
|
|||||||
const [editedApp, setEditedApp] = useState<any>();
|
const [editedApp, setEditedApp] = useState<any>();
|
||||||
const [tabActiveKey, setTabActiveKey] = useState('security');
|
const [tabActiveKey, setTabActiveKey] = useState('security');
|
||||||
const [loginLogData, setLoginLogData] = useState<any[]>([]);
|
const [loginLogData, setLoginLogData] = useState<any[]>([]);
|
||||||
|
const [notifyLogData, setNotifyLogData] = useState<any[]>([]);
|
||||||
const [notificationInfo, setNotificationInfo] = useState<any>();
|
const [notificationInfo, setNotificationInfo] = useState<any>();
|
||||||
const containergRef = useRef<HTMLDivElement>(null);
|
const containergRef = useRef<HTMLDivElement>(null);
|
||||||
const [height, setHeight] = useState<number>(0);
|
const [height, setHeight] = useState<number>(0);
|
||||||
@@ -254,6 +256,8 @@ const Setting = () => {
|
|||||||
getApps();
|
getApps();
|
||||||
} else if (activeKey === 'login') {
|
} else if (activeKey === 'login') {
|
||||||
getLoginLog();
|
getLoginLog();
|
||||||
|
} else if (activeKey === 'notifylog') {
|
||||||
|
getNotifyLog();
|
||||||
} else if (activeKey === 'notification') {
|
} else if (activeKey === 'notification') {
|
||||||
getNotification();
|
getNotification();
|
||||||
}
|
}
|
||||||
@@ -272,6 +276,19 @@ const Setting = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getNotifyLog = () => {
|
||||||
|
request
|
||||||
|
.get(`${config.apiPrefix}system/notify-log`)
|
||||||
|
.then(({ code, data }) => {
|
||||||
|
if (code === 200) {
|
||||||
|
setNotifyLogData(data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
console.log(error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDemoEnv) {
|
if (isDemoEnv) {
|
||||||
getApps();
|
getApps();
|
||||||
@@ -345,6 +362,11 @@ const Setting = () => {
|
|||||||
label: intl.get('登录日志'),
|
label: intl.get('登录日志'),
|
||||||
children: <LoginLog height={height} data={loginLogData} />,
|
children: <LoginLog height={height} data={loginLogData} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'notifylog',
|
||||||
|
label: intl.get('通知日志'),
|
||||||
|
children: <NotifyLog height={height} data={notifyLogData} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'client-ip',
|
key: 'client-ip',
|
||||||
label: intl.get('客户端 IP'),
|
label: intl.get('客户端 IP'),
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import intl from 'react-intl-universal';
|
||||||
|
import React from 'react';
|
||||||
|
import { Table, Tag } from 'antd';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
interface NotifyLogItem {
|
||||||
|
id?: number;
|
||||||
|
timestamp?: number;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
status?: number;
|
||||||
|
notifyType?: string;
|
||||||
|
source?: 'system' | 'script';
|
||||||
|
scriptName?: string;
|
||||||
|
taskId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotifyStatusLabel: Record<number, string> = {
|
||||||
|
0: '成功',
|
||||||
|
1: '失败',
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotifyStatusColor: Record<number, string> = {
|
||||||
|
0: 'success',
|
||||||
|
1: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: intl.get('通知来源'),
|
||||||
|
key: 'source',
|
||||||
|
width: 220,
|
||||||
|
render: (_: unknown, record: NotifyLogItem) => (
|
||||||
|
<span>
|
||||||
|
{intl.get(record.source === 'script' ? '脚本通知' : '系统通知')}
|
||||||
|
{record.scriptName ? ` · ${record.scriptName}` : ''}
|
||||||
|
{record.taskId ? ` (#${record.taskId})` : ''}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.get('序号'),
|
||||||
|
width: 50,
|
||||||
|
render: (text: string, record: any, index: number) => {
|
||||||
|
return index + 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.get('发送时间'),
|
||||||
|
dataIndex: 'timestamp',
|
||||||
|
key: 'timestamp',
|
||||||
|
width: 160,
|
||||||
|
render: (text: string, record: any) => {
|
||||||
|
return dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.get('标题'),
|
||||||
|
dataIndex: 'title',
|
||||||
|
key: 'title',
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.get('内容'),
|
||||||
|
dataIndex: 'content',
|
||||||
|
key: 'content',
|
||||||
|
render: (text: string) => {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.length > 100 ? text.slice(0, 100) + '...' : text;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.get('推送渠道'),
|
||||||
|
dataIndex: 'notifyType',
|
||||||
|
key: 'notifyType',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.get('发送状态'),
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (text: string, record: NotifyLogItem) => {
|
||||||
|
const statusKey = record.status ?? 1;
|
||||||
|
return (
|
||||||
|
<Tag color={NotifyStatusColor[statusKey]} style={{ marginRight: 0 }}>
|
||||||
|
{intl.get(NotifyStatusLabel[statusKey])}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const NotifyLog = ({
|
||||||
|
data,
|
||||||
|
height,
|
||||||
|
}: {
|
||||||
|
data: Array<NotifyLogItem>;
|
||||||
|
height: number;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
pagination={false}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
size="middle"
|
||||||
|
scroll={{ x: 1000, y: height }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotifyLog;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { langs } from '@uiw/codemirror-extensions-langs';
|
||||||
|
|
||||||
|
export function getCodeMirrorExtensions(mode: string) {
|
||||||
|
// Monaco calls this language "ini"; CodeMirror exposes it as "properties".
|
||||||
|
const language = mode === 'ini' ? 'properties' : mode;
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(langs, language)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const factory = langs[language as keyof typeof langs];
|
||||||
|
return typeof factory === 'function' ? [factory()] : [];
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
|
|||||||
fileExist: async () => false,
|
fileExist: async () => false,
|
||||||
getGetCommand: (_type, name) => `check:${name}`,
|
getGetCommand: (_type, name) => `check:${name}`,
|
||||||
getInstallCommand: () => '',
|
getInstallCommand: () => '',
|
||||||
|
isPythonSourceDependency: () => false,
|
||||||
getPid: async () => 0,
|
getPid: async () => 0,
|
||||||
getUninstallCommand: () => '',
|
getUninstallCommand: () => '',
|
||||||
killTask: async () => {},
|
killTask: async () => {},
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
|
const load = require('../helpers/load-security-module.cjs');
|
||||||
|
|
||||||
|
const types = { nodejs: 0, python3: 1, linux: 2 };
|
||||||
|
const statuses = { installed: 1, installFailed: 2, queued: 6, cancelled: 7 };
|
||||||
|
const util = load('back/config/util.ts', {
|
||||||
|
'./index': {},
|
||||||
|
'./const': {},
|
||||||
|
'../loaders/logger': {},
|
||||||
|
'../shared/utils': {},
|
||||||
|
'../data/dependence': { DependenceTypes: types },
|
||||||
|
});
|
||||||
|
|
||||||
|
function serviceFor(docs) {
|
||||||
|
const probes = [],
|
||||||
|
removed = [],
|
||||||
|
uninstalls = [];
|
||||||
|
const Service = load('back/services/dependence.ts', {
|
||||||
|
'../config': {},
|
||||||
|
'../data/dependence': {
|
||||||
|
Dependence: class {},
|
||||||
|
DependenceTypes: types,
|
||||||
|
DependenceStatus: statuses,
|
||||||
|
versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' },
|
||||||
|
DependenceModel: {
|
||||||
|
findAll: async ({ where }) =>
|
||||||
|
docs.filter(
|
||||||
|
(d) =>
|
||||||
|
(where.status === undefined || d.status === where.status) &&
|
||||||
|
(!where.id || where.id.includes(d.id)),
|
||||||
|
),
|
||||||
|
update: async (values, { where }) => {
|
||||||
|
docs
|
||||||
|
.filter((d) => where.id.includes(d.id))
|
||||||
|
.forEach((d) => Object.assign(d, values));
|
||||||
|
},
|
||||||
|
destroy: async ({ where }) => removed.push(...where.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'../config/util': {
|
||||||
|
...util,
|
||||||
|
concurrentRun: (tasks) => Promise.all(tasks.map((fn) => fn())),
|
||||||
|
getGetCommand: (type, name) => {
|
||||||
|
probes.push(name);
|
||||||
|
return util.getGetCommand(type, name);
|
||||||
|
},
|
||||||
|
promiseExecSuccess: async () => '',
|
||||||
|
},
|
||||||
|
'../shared/pLimit': { removeQueuedDependency() {} },
|
||||||
|
'./sock': class {},
|
||||||
|
'../shared/i18n': {},
|
||||||
|
}).default;
|
||||||
|
const service = new Service({}, {});
|
||||||
|
service.installDependenceOneByOne = (dependencies, install, force) => {
|
||||||
|
uninstalls.push({ ids: dependencies.map((d) => d.id), install, force });
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
return { service, probes, removed, uninstalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('listing preserves Python source results and still detects missing named packages', async () => {
|
||||||
|
const docs = [
|
||||||
|
' requirements with spaces/requirements.txt ',
|
||||||
|
'requirements-dev.in',
|
||||||
|
'project/pyproject.toml',
|
||||||
|
'git+https://example.invalid/repo.git',
|
||||||
|
'https://example.invalid/pkg.whl',
|
||||||
|
'missing-package',
|
||||||
|
].map((name, i) => ({ id: i + 1, name, type: 1, status: 1 }));
|
||||||
|
const { service, probes } = serviceFor(docs);
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
await service.dependencies({
|
||||||
|
searchValue: '',
|
||||||
|
type: 'python3',
|
||||||
|
status: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
docs.map((d) => d.status),
|
||||||
|
[1, 1, 1, 1, 1, 2],
|
||||||
|
);
|
||||||
|
assert.deepEqual(probes, ['missing-package']);
|
||||||
|
// Source installs must continue to execute pip rather than treating the source as a package name.
|
||||||
|
assert.equal(util.getGetCommand(1, docs[0].name), 'echo ""');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletion cleans previously misclassified Python sources and only queues intended records', async () => {
|
||||||
|
const docs = [
|
||||||
|
{ id: 1, name: 'requirements.txt', type: 1, status: 2 },
|
||||||
|
{ id: 2, name: 'present-package', type: 0, status: 1 },
|
||||||
|
{ id: 3, name: 'missing-package', type: 0, status: 2 },
|
||||||
|
{ id: 4, name: 'queued/requirements.txt', type: 1, status: 6 },
|
||||||
|
{ id: 5, name: 'cancelled/requirements.txt', type: 1, status: 7 },
|
||||||
|
{ id: 6, name: 'git+https://example.invalid/repo.git', type: 1, status: 2 },
|
||||||
|
];
|
||||||
|
const { service, removed, uninstalls } = serviceFor(docs);
|
||||||
|
await service.remove(docs.map((d) => d.id));
|
||||||
|
assert.deepEqual(removed, [3, 4, 5]);
|
||||||
|
assert.deepEqual(uninstalls, [
|
||||||
|
{ ids: [1, 2, 6], install: false, force: false },
|
||||||
|
]);
|
||||||
|
assert.equal(docs[2].status, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Alpine container mirror command runs without sudo and writes valid repositories', async (t) => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-alpine-mirror-'));
|
||||||
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||||
|
const source =
|
||||||
|
'https://dl-cdn.alpinelinux.org/alpine/v3.23/main\nhttps://dl-cdn.alpinelinux.org/alpine/v3.23/community\n';
|
||||||
|
const api = load('back/config/util.ts', {
|
||||||
|
'fs/promises': { readFile: async () => source },
|
||||||
|
'./index': {},
|
||||||
|
'./const': {},
|
||||||
|
'./share': {},
|
||||||
|
'./container': { isInContainer: () => true },
|
||||||
|
'../loaders/logger': {},
|
||||||
|
'../shared/utils': {},
|
||||||
|
'../shared/fileAccess': {},
|
||||||
|
'../data/dependence': {},
|
||||||
|
});
|
||||||
|
const previous = process.env.QL_OS_TYPE;
|
||||||
|
let command;
|
||||||
|
try {
|
||||||
|
process.env.QL_OS_TYPE = 'alpine';
|
||||||
|
command = await api.updateLinuxMirrorFile('https://mirror.example/alpine');
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.QL_OS_TYPE;
|
||||||
|
else process.env.QL_OS_TYPE = previous;
|
||||||
|
}
|
||||||
|
// Redirect the generated command's paths and executable into the fixture only.
|
||||||
|
const file = path.join(root, 'repositories');
|
||||||
|
const update = path.join(root, 'updated');
|
||||||
|
const sandbox = command
|
||||||
|
.replaceAll('/etc/apk/repositories', file)
|
||||||
|
.replaceAll('/etc/apk', root);
|
||||||
|
execFileSync('/bin/bash', [
|
||||||
|
'-c',
|
||||||
|
`sudo(){ return 127; }; apk(){ test "$1" = update && touch '${update}'; }; ${sandbox}`,
|
||||||
|
]);
|
||||||
|
assert.ok(fs.existsSync(update));
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(file, 'utf8'),
|
||||||
|
source.replaceAll(
|
||||||
|
'https://dl-cdn.alpinelinux.org/alpine',
|
||||||
|
'https://mirror.example/alpine',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('force deletion removes a source record even when its requirements file is gone', async () => {
|
||||||
|
const removed = [],
|
||||||
|
updates = [];
|
||||||
|
const Service = load('back/services/dependence.ts', {
|
||||||
|
'../config': {},
|
||||||
|
'../config/util': {
|
||||||
|
getUninstallCommand() {
|
||||||
|
throw Error('Python dependency file must be inside scripts or repo');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'../data/dependence': {
|
||||||
|
Dependence: class {},
|
||||||
|
DependenceTypes: types,
|
||||||
|
DependenceStatus: { ...statuses, removeFailed: 5 },
|
||||||
|
DependenceModel: {
|
||||||
|
destroy: async ({ where }) => removed.push(...where.id),
|
||||||
|
update: async (values) => updates.push(values),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'../shared/pLimit': {
|
||||||
|
runDependeny: (_dep, fn) => fn(),
|
||||||
|
removeQueuedDependency() {},
|
||||||
|
},
|
||||||
|
'./sock': class {},
|
||||||
|
'../shared/i18n': {},
|
||||||
|
}).default;
|
||||||
|
const service = new Service({}, {});
|
||||||
|
const dep = { id: 1, name: 'gone/requirements.txt', type: 1, status: 2 };
|
||||||
|
await service.installOrUninstallDependency(dep, false, false);
|
||||||
|
assert.deepEqual(removed, []);
|
||||||
|
assert.equal(updates[0].status, 5);
|
||||||
|
await service.installOrUninstallDependency(dep, false, true);
|
||||||
|
assert.deepEqual(removed, [1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Python extras run pip even when the base package version is already installed', async (t) => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-extra-install-'));
|
||||||
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||||
|
const marker = path.join(root, 'pip-ran');
|
||||||
|
const dep = {
|
||||||
|
id: 1,
|
||||||
|
name: 'fixture[optional]>=1',
|
||||||
|
type: 1,
|
||||||
|
status: 6,
|
||||||
|
log: [],
|
||||||
|
};
|
||||||
|
let probes = 0;
|
||||||
|
const Service = load('back/services/dependence.ts', {
|
||||||
|
'../config': {},
|
||||||
|
'../config/util': {
|
||||||
|
getInstallCommand: () => `printf installed > '${marker}'`,
|
||||||
|
getGetCommand: () => {
|
||||||
|
probes++;
|
||||||
|
return 'probe';
|
||||||
|
},
|
||||||
|
promiseExecSuccess: async () => '1.0',
|
||||||
|
detectOS: async () => 'Alpine',
|
||||||
|
fileExist: async () => false,
|
||||||
|
},
|
||||||
|
'../data/dependence': {
|
||||||
|
Dependence: class {},
|
||||||
|
DependenceTypes: types,
|
||||||
|
DependenceStatus: { ...statuses, installing: 0, cancelled: 7 },
|
||||||
|
versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' },
|
||||||
|
DependenceModel: {
|
||||||
|
findAll: async () => [dep],
|
||||||
|
update: async (values) => Object.assign(dep, values),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'../shared/pLimit': {
|
||||||
|
firstDependencyId: 1,
|
||||||
|
runDependeny: (_dep, fn) => fn(),
|
||||||
|
removeQueuedDependency() {},
|
||||||
|
updateDepLog: (fn) => fn(),
|
||||||
|
},
|
||||||
|
'./sock': class {},
|
||||||
|
'../shared/i18n': { t: (text) => text, tf: (text) => text },
|
||||||
|
}).default;
|
||||||
|
const service = new Service({}, { sendMessage() {} });
|
||||||
|
await service.installOrUninstallDependency(dep, true, false);
|
||||||
|
assert.equal(probes, 0);
|
||||||
|
assert.equal(fs.readFileSync(marker, 'utf8'), 'installed');
|
||||||
|
assert.equal(dep.status, 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
const test = require('node:test'),
|
||||||
|
assert = require('node:assert/strict'),
|
||||||
|
{ Sequelize, DataTypes } = require('sequelize');
|
||||||
|
const load = require('../helpers/load-security-module.cjs');
|
||||||
|
const decorators = { Service: () => (x) => x, Inject: () => () => {} };
|
||||||
|
async function fixture(t) {
|
||||||
|
const db = new Sequelize({
|
||||||
|
dialect: 'sqlite',
|
||||||
|
storage: ':memory:',
|
||||||
|
logging: false,
|
||||||
|
});
|
||||||
|
t.after(() => db.close());
|
||||||
|
const rows = db.define('System', {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
info: DataTypes.JSON,
|
||||||
|
});
|
||||||
|
await db.sync();
|
||||||
|
const logger = { error() {} };
|
||||||
|
const S = load('back/services/system.ts', {
|
||||||
|
typedi: decorators,
|
||||||
|
'../config': {},
|
||||||
|
'../config/const': { NotificationModeStringMap: { 0: 'gotify' } },
|
||||||
|
'../config/util': {},
|
||||||
|
'../data/dependence': {},
|
||||||
|
'../data/system': {
|
||||||
|
SystemModel: rows,
|
||||||
|
AuthDataType: { notifyLog: 'notifyLog', notification: 'notification' },
|
||||||
|
NotifyStatus: { success: 0, fail: 1 },
|
||||||
|
},
|
||||||
|
'./schedule': class {},
|
||||||
|
'./sock': class {},
|
||||||
|
'./notify': class {},
|
||||||
|
'../shared/pLimit': {},
|
||||||
|
'../schedule/client': {},
|
||||||
|
'../config/index': {},
|
||||||
|
'../shared/store': {},
|
||||||
|
'../shared/i18n': { t: (s) => s },
|
||||||
|
}).default;
|
||||||
|
const service = Object.assign(Object.create(S.prototype), {
|
||||||
|
logger,
|
||||||
|
notificationService: new (load('back/services/notify.ts', {
|
||||||
|
typedi: decorators,
|
||||||
|
'../config/util': {},
|
||||||
|
'../data/system': {
|
||||||
|
SystemModel: rows,
|
||||||
|
AuthDataType: { notifyLog: 'notifyLog' },
|
||||||
|
NotifyStatus: { success: 0, fail: 1 },
|
||||||
|
},
|
||||||
|
'../shared/i18n': { t: (s) => s },
|
||||||
|
'./user': {},
|
||||||
|
}).default)(),
|
||||||
|
});
|
||||||
|
service.notificationService.userService = {
|
||||||
|
getNotificationMode: async () => ({ type: 'email' }),
|
||||||
|
};
|
||||||
|
service.notificationService.modeMap.set('email', async () => true);
|
||||||
|
service.getDb = async () => ({ info: { type: 'email' } });
|
||||||
|
return { rows, service };
|
||||||
|
}
|
||||||
|
test('writes cap logs without reading the page and preserve other system data', async (t) => {
|
||||||
|
const { rows, service } = await fixture(t);
|
||||||
|
await rows.create({ type: 'authConfig', info: { keep: true } });
|
||||||
|
for (let i = 0; i < 205; i++)
|
||||||
|
await service.notify({ title: String(i), content: 'body' });
|
||||||
|
assert.equal(await rows.count({ where: { type: 'notifyLog' } }), 200);
|
||||||
|
const logs = await service.getNotifyLog();
|
||||||
|
assert.equal(logs[0].title, '204');
|
||||||
|
assert.equal(logs.at(-1).title, '5');
|
||||||
|
assert.equal(logs[0].notifyType, 'email');
|
||||||
|
assert.equal(await rows.count({ where: { type: 'authConfig' } }), 1);
|
||||||
|
});
|
||||||
|
test('audit DB failure does not turn a successful notification into failure', async (t) => {
|
||||||
|
const { rows, service } = await fixture(t);
|
||||||
|
const create = rows.create;
|
||||||
|
rows.create = async () => {
|
||||||
|
throw Error('disk full');
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
assert.equal(
|
||||||
|
(await service.notify({ title: 'ok', content: 'ok' })).code,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
rows.create = create;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('failed sends are recorded and message size is bounded', async (t) => {
|
||||||
|
const { service } = await fixture(t);
|
||||||
|
service.notificationService.modeMap.set('gotify', async () => false);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await service.notify({
|
||||||
|
title: 'x'.repeat(1000),
|
||||||
|
content: 'y'.repeat(20000),
|
||||||
|
notificationInfo: { type: 0 },
|
||||||
|
})
|
||||||
|
).code,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
const [row] = await service.getNotifyLog();
|
||||||
|
assert.equal(row.status, 1);
|
||||||
|
assert.equal(row.title.length, 512);
|
||||||
|
assert.equal(row.content.length, 8192);
|
||||||
|
assert.equal(row.notifyType, 'gotify');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('script reports share retention and preserve source without sending twice', async (t) => {
|
||||||
|
const { service } = await fixture(t);
|
||||||
|
await service.recordNotifyLog({
|
||||||
|
title: 'task result',
|
||||||
|
content: 'body',
|
||||||
|
status: 1,
|
||||||
|
notifyType: 'smtp',
|
||||||
|
source: 'script',
|
||||||
|
scriptName: 'job.py',
|
||||||
|
taskId: '42',
|
||||||
|
});
|
||||||
|
const [row] = await service.getNotifyLog();
|
||||||
|
assert.equal(row.source, 'script');
|
||||||
|
assert.equal(row.scriptName, 'job.py');
|
||||||
|
assert.equal(row.taskId, '42');
|
||||||
|
assert.equal(row.status, 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
|
const load = require('../helpers/load-security-module.cjs');
|
||||||
|
|
||||||
|
test('#3097 Python requirement probes preserve version constraints, extras and installed distributions', async (t) => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-specifiers-'));
|
||||||
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||||
|
const venv = path.join(root, 'virtual env');
|
||||||
|
execFileSync('python3', ['-m', 'venv', venv]);
|
||||||
|
execFileSync('python3', [
|
||||||
|
'-c',
|
||||||
|
`import zipfile,sys,os
|
||||||
|
for version in ['1.2.3','2.1.0']:
|
||||||
|
name='ql_specifier_fixture'
|
||||||
|
with zipfile.ZipFile(os.path.join(sys.argv[1],name+'-'+version+'-py3-none-any.whl'),'w') as z:
|
||||||
|
z.writestr(name+'.py','value=42\\n')
|
||||||
|
info=name+'-'+version+'.dist-info/'
|
||||||
|
z.writestr(info+'METADATA','Metadata-Version: 2.1\\nName: ql-specifier-fixture\\nVersion: '+version+'\\nProvides-Extra: optional\\n')
|
||||||
|
z.writestr(info+'WHEEL','Wheel-Version: 1.0\\nGenerator: fixture\\nRoot-Is-Purelib: true\\nTag: py3-none-any\\n')
|
||||||
|
z.writestr(info+'RECORD','')`,
|
||||||
|
root,
|
||||||
|
]);
|
||||||
|
const api = load('back/config/util.ts', {
|
||||||
|
'./index': { rootPath: root },
|
||||||
|
'./const': { PYTHON_VENV_DIR: venv, PYTHON_INSTALL_DIR: '/unused-prefix' },
|
||||||
|
'../loaders/logger': {},
|
||||||
|
'../shared/utils': {},
|
||||||
|
'../data/dependence': {
|
||||||
|
DependenceTypes: { nodejs: 0, python3: 1, linux: 2 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const env = { ...process.env, PIP_NO_INDEX: '1', PIP_FIND_LINKS: root };
|
||||||
|
for (const key of ['PYTHONPATH', 'PYTHONUSERBASE', 'PYTHON_HOME'])
|
||||||
|
delete env[key];
|
||||||
|
const run = (command) =>
|
||||||
|
execFileSync('/bin/bash', ['-c', command], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
run(api.getInstallCommand(1, 'ql-specifier-fixture==1.2.3'));
|
||||||
|
const Service = load('back/services/dependence.ts', {
|
||||||
|
'../config': {},
|
||||||
|
'../config/util': {
|
||||||
|
...api,
|
||||||
|
promiseExecSuccess: async (command) => run(command),
|
||||||
|
},
|
||||||
|
'../data/dependence': {
|
||||||
|
Dependence: class {},
|
||||||
|
DependenceTypes: { nodejs: 0, python3: 1, linux: 2 },
|
||||||
|
versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' },
|
||||||
|
},
|
||||||
|
'../shared/pLimit': {},
|
||||||
|
'./sock': class {},
|
||||||
|
'../shared/i18n': {},
|
||||||
|
}).default;
|
||||||
|
const service = new Service({}, {});
|
||||||
|
assert.equal(
|
||||||
|
await service.isDependenceInstalled({
|
||||||
|
type: 1,
|
||||||
|
name: 'ql-specifier-fixture==1.2.3.0',
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await service.isDependenceInstalled({
|
||||||
|
type: 1,
|
||||||
|
name: 'ql-specifier-fixture>=2',
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
for (const suffix of [
|
||||||
|
'',
|
||||||
|
'>=1.0',
|
||||||
|
'<2',
|
||||||
|
'>=1,<2',
|
||||||
|
'~=1.2',
|
||||||
|
'==1.*',
|
||||||
|
'!=2.0',
|
||||||
|
'[optional]>=1',
|
||||||
|
'==1.2.3.0',
|
||||||
|
]) {
|
||||||
|
assert.equal(
|
||||||
|
run(api.getGetCommand(1, 'ql-specifier-fixture' + suffix)),
|
||||||
|
'1.2.3',
|
||||||
|
suffix,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const suffix of ['>=2', '<1', '!=1.2.3', '==1.2.30']) {
|
||||||
|
assert.equal(
|
||||||
|
run(api.getGetCommand(1, 'ql-specifier-fixture' + suffix)),
|
||||||
|
'',
|
||||||
|
suffix,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(run(api.getGetCommand(1, 'ql_specifier_fixture')), '1.2.3');
|
||||||
|
assert.equal(run(api.getGetCommand(1, 'json')), 'json');
|
||||||
|
assert.equal(
|
||||||
|
run(api.getGetCommand(1, 'ql-missing-specifier-package>=1')),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
run(api.getGetCommand(1, 'ql-specifier-fixture; python_version < "0"')),
|
||||||
|
'not applicable',
|
||||||
|
);
|
||||||
|
run(api.getInstallCommand(1, 'ql-specifier-fixture>=2'));
|
||||||
|
assert.equal(
|
||||||
|
run(api.getGetCommand(1, 'ql-specifier-fixture>=2,<3')),
|
||||||
|
'2.1.0',
|
||||||
|
);
|
||||||
|
run(api.getUninstallCommand(1, 'ql-specifier-fixture[optional]>=2,<3'));
|
||||||
|
assert.equal(run(api.getGetCommand(1, 'ql-specifier-fixture')), '');
|
||||||
|
assert.equal(fs.existsSync(path.join(root, 'INJECTED')), false);
|
||||||
|
assert.equal(
|
||||||
|
run(
|
||||||
|
api.getGetCommand(
|
||||||
|
1,
|
||||||
|
`ql-specifier-fixture; __import__('os').system('touch ${root}/INJECTED')`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -101,7 +101,7 @@ test('ql log follows the current Winston system log without PM2', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(result.status, 0, result.stderr);
|
assert.equal(result.status, 0, result.stderr);
|
||||||
assert.equal(result.stdout, `tail <-n> <0> <--retry> <-F> <${currentLog}>\n`);
|
assert.equal(result.stdout, `tail <-n> <0> <-F> <${currentLog}>\n`);
|
||||||
assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false);
|
assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
const test = require('node:test'),
|
||||||
|
assert = require('node:assert/strict'),
|
||||||
|
fs = require('node:fs'),
|
||||||
|
path = require('node:path'),
|
||||||
|
vm = require('node:vm');
|
||||||
|
const load = require('../helpers/load-security-module.cjs');
|
||||||
|
|
||||||
|
function script(config = {}, response = {}, auditError = false) {
|
||||||
|
const logs = [],
|
||||||
|
source = fs.readFileSync('sample/notify.js', 'utf8');
|
||||||
|
const transport = async () => ({
|
||||||
|
statusCode: response.httpStatus || 200,
|
||||||
|
body: { text: async () => JSON.stringify(response) },
|
||||||
|
});
|
||||||
|
const context = {
|
||||||
|
module: { exports: {} },
|
||||||
|
console: { log() {}, info() {} },
|
||||||
|
process: {
|
||||||
|
env: { QL_DIR: '/fixture', HITOKOTO: 'false', ...config },
|
||||||
|
argv: ['node', '/fixture/scripts/test-job.js'],
|
||||||
|
},
|
||||||
|
Buffer,
|
||||||
|
URL,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
};
|
||||||
|
context.require = (name) => {
|
||||||
|
if (name === 'undici')
|
||||||
|
return { request: transport, ProxyAgent: class {}, FormData };
|
||||||
|
if (name === '/fixture/shell/preload/notificationAudit.js')
|
||||||
|
return async (x) => {
|
||||||
|
if (auditError) throw Error('offline');
|
||||||
|
logs.push(x);
|
||||||
|
};
|
||||||
|
if (name === 'nodemailer')
|
||||||
|
return {
|
||||||
|
createTransport: () => ({
|
||||||
|
sendMail: async () => {
|
||||||
|
if (response.smtpFailure) throw Error('auth rejected');
|
||||||
|
return { messageId: 'fixture' };
|
||||||
|
},
|
||||||
|
close() {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return require(name);
|
||||||
|
};
|
||||||
|
vm.runInNewContext(source, context, {
|
||||||
|
filename: path.resolve('sample/notify.js'),
|
||||||
|
});
|
||||||
|
return { send: context.module.exports.sendNotify, logs };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('JS helper logs only configured channels, success/failure and script/task provenance', async () => {
|
||||||
|
for (const fail of [false, true]) {
|
||||||
|
const s = script(
|
||||||
|
{
|
||||||
|
SMTP_EMAIL: 'sender@example.test',
|
||||||
|
SMTP_PASSWORD: 'private-password',
|
||||||
|
SMTP_SERVICE: 'fixture',
|
||||||
|
ID: '17',
|
||||||
|
},
|
||||||
|
{ smtpFailure: fail },
|
||||||
|
);
|
||||||
|
await s.send('标题', '正文');
|
||||||
|
assert.equal(s.logs.length, 1);
|
||||||
|
assert.equal(s.logs[0].success, !fail);
|
||||||
|
assert.equal(s.logs[0].notifyType, 'smtp');
|
||||||
|
assert.equal(s.logs[0].scriptName, 'test-job.js');
|
||||||
|
assert.equal(s.logs[0].taskId, '17');
|
||||||
|
assert.ok(!JSON.stringify(s.logs).includes('private-password'));
|
||||||
|
}
|
||||||
|
const s = script();
|
||||||
|
await s.send('none', 'none');
|
||||||
|
assert.equal(s.logs.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP business rejection is audited as failure and logging errors do not change sends', async () => {
|
||||||
|
for (const [code, success] of [
|
||||||
|
[200, true],
|
||||||
|
[500, false],
|
||||||
|
]) {
|
||||||
|
const s = script({ BARK_PUSH: 'https://fixture.invalid' }, { code });
|
||||||
|
await s.send('title', 'body');
|
||||||
|
assert.equal(s.logs.length, 1);
|
||||||
|
assert.equal(s.logs[0].success, success);
|
||||||
|
}
|
||||||
|
const offline = script(
|
||||||
|
{
|
||||||
|
SMTP_EMAIL: 'sender@example.test',
|
||||||
|
SMTP_PASSWORD: 'fixture',
|
||||||
|
SMTP_SERVICE: 'fixture',
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await offline.send('still sends', 'body');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-channel helpers write one result per actual channel', async () => {
|
||||||
|
const s = script(
|
||||||
|
{
|
||||||
|
GOTIFY_URL: 'https://fixture.invalid',
|
||||||
|
GOTIFY_TOKEN: 'private-token',
|
||||||
|
SMTP_EMAIL: 'sender@example.test',
|
||||||
|
SMTP_PASSWORD: 'fixture',
|
||||||
|
SMTP_SERVICE: 'fixture',
|
||||||
|
},
|
||||||
|
{ id: 123 },
|
||||||
|
);
|
||||||
|
await s.send('title', 'body');
|
||||||
|
assert.deepEqual(s.logs.map((x) => x.notifyType).sort(), ['gotify', 'smtp']);
|
||||||
|
assert.ok(s.logs.every((x) => x.success));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('script audit protobuf round trips without notification credentials', () => {
|
||||||
|
const p = load('back/protos/api.ts');
|
||||||
|
const value = p.RecordNotifyLogRequest.create({
|
||||||
|
title: '中文',
|
||||||
|
content: '内容',
|
||||||
|
success: false,
|
||||||
|
notifyType: 'smtp',
|
||||||
|
scriptName: 'job.py',
|
||||||
|
taskId: '12',
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
p.RecordNotifyLogRequest.decode(
|
||||||
|
p.RecordNotifyLogRequest.encode(value).finish(),
|
||||||
|
),
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
assert.ok(p.ApiService.recordNotifyLog);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-target Chronocat records one failure if any recipient fails', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const response = {};
|
||||||
|
Object.defineProperty(response, 'httpStatus', {
|
||||||
|
get: () => (++calls === 1 ? 200 : 500),
|
||||||
|
});
|
||||||
|
const s = script(
|
||||||
|
{
|
||||||
|
CHRONOCAT_URL: 'https://fixture.invalid',
|
||||||
|
CHRONOCAT_TOKEN: 'private-token',
|
||||||
|
CHRONOCAT_QQ: 'user_id=123&group_id=456',
|
||||||
|
},
|
||||||
|
response,
|
||||||
|
);
|
||||||
|
await s.send('title', 'body');
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.equal(s.logs.length, 1);
|
||||||
|
assert.equal(s.logs[0].success, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const { promisify } = require('node:util');
|
||||||
|
const { execFile } = require('node:child_process');
|
||||||
|
const grpc = require('@grpc/grpc-js');
|
||||||
|
const loader = require('@grpc/proto-loader');
|
||||||
|
const load = require('../helpers/load-security-module.cjs');
|
||||||
|
const run = promisify(execFile);
|
||||||
|
|
||||||
|
test('JS and Python script reports reach the same authenticated RPC with bounded failure', async (t) => {
|
||||||
|
const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-audit-rpc-'));
|
||||||
|
t.after(() => fs.rmSync(data, { recursive: true, force: true }));
|
||||||
|
fs.mkdirSync(path.join(data, 'config'));
|
||||||
|
const certs = await load('back/config/grpcCerts.ts', {
|
||||||
|
'./index': { configPath: path.join(data, 'config') },
|
||||||
|
'./util': { fileExist: async (p) => fs.existsSync(p) },
|
||||||
|
'../loaders/logger': { info() {}, error() {} },
|
||||||
|
}).initGrpcCerts();
|
||||||
|
const definition = loader.loadSync(path.resolve('back/protos/api.proto'), {
|
||||||
|
keepCase: true,
|
||||||
|
defaults: true,
|
||||||
|
});
|
||||||
|
const Api = grpc.loadPackageDefinition(definition).com.ql.api.Api;
|
||||||
|
const received = [];
|
||||||
|
const server = new grpc.Server();
|
||||||
|
server.addService(Api.service, {
|
||||||
|
RecordNotifyLog: (call, callback) => {
|
||||||
|
received.push(call.request);
|
||||||
|
callback(null, { code: 200 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const credentials = grpc.ServerCredentials.createSsl(
|
||||||
|
Buffer.from(certs.caCert),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
private_key: Buffer.from(certs.serverKey),
|
||||||
|
cert_chain: Buffer.from(certs.serverCert),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const port = await new Promise((resolve, reject) =>
|
||||||
|
server.bindAsync('127.0.0.1:0', credentials, (error, port) =>
|
||||||
|
error ? reject(error) : resolve(port),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
t.after(() => server.forceShutdown());
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
QL_DIR: process.cwd(),
|
||||||
|
QL_DATA_DIR: data,
|
||||||
|
GRPC_PORT: String(port),
|
||||||
|
ID: '9',
|
||||||
|
HITOKOTO: 'false',
|
||||||
|
};
|
||||||
|
await run(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'-e',
|
||||||
|
`require('./shell/preload/notificationAudit.js')({title:'JS task',content:'body',success:true,notifyType:'smtp',scriptName:'task.js',taskId:process.env.ID})`,
|
||||||
|
],
|
||||||
|
{ env, timeout: 8000 },
|
||||||
|
);
|
||||||
|
const python = `import importlib.util,sys,types,smtplib,os
|
||||||
|
sys.modules['requests']=types.SimpleNamespace()
|
||||||
|
spec=importlib.util.spec_from_file_location('notify',os.path.join(os.environ['QL_DIR'],'sample/notify.py'))
|
||||||
|
n=importlib.util.module_from_spec(spec);spec.loader.exec_module(n)
|
||||||
|
class Mail:
|
||||||
|
def __init__(self,*a,**kw): pass
|
||||||
|
def login(self,*a): pass
|
||||||
|
def sendmail(self,*a): return {}
|
||||||
|
def close(self): pass
|
||||||
|
smtplib.SMTP_SSL=Mail
|
||||||
|
n.push_config={'SMTP_SERVER':'fixture','SMTP_SSL':'true','SMTP_EMAIL':'sender@example.test','SMTP_PASSWORD':'private-password','SMTP_NAME':'fixture','HITOKOTO':False}
|
||||||
|
n.send('Python success','body')
|
||||||
|
def rejected(*a,**kw): raise RuntimeError('private-password')
|
||||||
|
smtplib.SMTP_SSL=rejected
|
||||||
|
n.send('Python failure','body')
|
||||||
|
`;
|
||||||
|
await run('python3', ['-c', python], { env, timeout: 12000 });
|
||||||
|
assert.deepEqual(
|
||||||
|
received.map((x) => [x.title, x.success]),
|
||||||
|
[
|
||||||
|
['JS task', true],
|
||||||
|
['Python success', true],
|
||||||
|
['Python failure', false],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.ok(received.every((x) => x.taskId === '9'));
|
||||||
|
assert.ok(!JSON.stringify(received).includes('private-password'));
|
||||||
|
// Missing certificates and an older/unavailable backend must not kill scripts.
|
||||||
|
await run(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'-e',
|
||||||
|
`require('./shell/preload/notificationAudit.js')({title:'offline'}).then(()=>console.log('done'))`,
|
||||||
|
],
|
||||||
|
{ env: { ...env, QL_DATA_DIR: path.join(data, 'missing') }, timeout: 8000 },
|
||||||
|
);
|
||||||
|
server.forceShutdown();
|
||||||
|
const started = Date.now();
|
||||||
|
await run(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'-e',
|
||||||
|
`require('./shell/preload/notificationAudit.js')({title:'offline'})`,
|
||||||
|
],
|
||||||
|
{ env, timeout: 8000 },
|
||||||
|
);
|
||||||
|
assert.ok(Date.now() - started < 5000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, '../..');
|
||||||
|
const bash = process.env.QL_TEST_BASH || '/bin/bash';
|
||||||
|
const source = (file) => fs.readFileSync(path.join(repo, file), 'utf8');
|
||||||
|
function extract(file, name) {
|
||||||
|
const text = source(file);
|
||||||
|
const start = text.indexOf(`${name}() {`);
|
||||||
|
assert.ok(start >= 0, name);
|
||||||
|
return text.slice(start, text.indexOf('\n}', start) + 2);
|
||||||
|
}
|
||||||
|
function fixture(t, prefix = `ql compatibility '" $literal `) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||||
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||||
|
const write = (name, content, executable = false) => {
|
||||||
|
const file = path.join(root, name);
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, content, { mode: executable ? 0o755 : 0o644 });
|
||||||
|
return file;
|
||||||
|
};
|
||||||
|
const run = (code, env = {}, args = [], cwd = root) =>
|
||||||
|
spawnSync(bash, ['-c', code, 'compatibility-test', ...args], {
|
||||||
|
cwd,
|
||||||
|
env: { ...process.env, ...env },
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
return { root, write, run };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [label, args] of [
|
||||||
|
['normal', ['nested folder/probe [1] $name.sh', '--']],
|
||||||
|
['explicit bash', ['bash', 'nested folder/probe [1] $name.sh']],
|
||||||
|
[
|
||||||
|
'designated',
|
||||||
|
['nested folder/probe [1] $name.sh', 'desi', 'QA_PANEL', '1-2', '--'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'concurrent',
|
||||||
|
['nested folder/probe [1] $name.sh', 'conc', 'QA_PANEL', '1-2', '--'],
|
||||||
|
],
|
||||||
|
['automatic Node', ['nested folder/probe [1] $name.js', '--']],
|
||||||
|
['explicit Node', ['node', 'nested folder/probe [1] $name.js']],
|
||||||
|
]) {
|
||||||
|
for (const logging of ['tee', 'file']) {
|
||||||
|
test(`${label} task keeps literal paths and arguments with ${logging} logging`, (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
for (const name of ['share.sh', 'task.sh', 'otask.sh', 'task-timeout.sh'])
|
||||||
|
f.write(`shell/${name}`, source(`shell/${name}`));
|
||||||
|
f.write(
|
||||||
|
'shell/api.sh',
|
||||||
|
'update_cron(){ :; }; record_cron_stat(){ :; }\n',
|
||||||
|
);
|
||||||
|
f.write('shell/preload/env.sh', 'export QA_PANEL="one&two"\n');
|
||||||
|
f.write('shell/preload/sitecustomize.js', '// fixture preloader\n');
|
||||||
|
f.write(
|
||||||
|
'data/config/config.sh',
|
||||||
|
'DefaultCronRule="0 0 * * *"\nCommandTimeoutTime="2s"\n',
|
||||||
|
);
|
||||||
|
f.write('data/config/task_before.sh', 'printf "BEFORE\\n"\n');
|
||||||
|
f.write('data/config/task_after.sh', 'printf "AFTER\\n"\n');
|
||||||
|
f.write('bin/pnpm', '#!/bin/bash\nprintf /fixture/global\n', true);
|
||||||
|
const literal = `argument with 'quotes' $literal ; echo NOT_EXECUTED`;
|
||||||
|
f.write(
|
||||||
|
'data/scripts/nested folder/probe [1] $name.sh',
|
||||||
|
'printf "ARG=%s\\nACCOUNT=%s\\n" "$1" "$QA_PANEL"\n',
|
||||||
|
);
|
||||||
|
f.write(
|
||||||
|
'data/scripts/nested folder/probe [1] $name.js',
|
||||||
|
'console.log("ARG=" + process.argv[2]);\n',
|
||||||
|
);
|
||||||
|
const started = Date.now();
|
||||||
|
const result = f.run(
|
||||||
|
'exec "$QA_BASH" "$QL_DIR/shell/task.sh" "$@"',
|
||||||
|
{
|
||||||
|
QA_BASH: bash,
|
||||||
|
QL_DIR: f.root,
|
||||||
|
QL_DATA_DIR: path.join(f.root, 'data'),
|
||||||
|
ID: '42',
|
||||||
|
no_delay: 'true',
|
||||||
|
real_time: 'false',
|
||||||
|
no_tee: logging === 'file' ? 'true' : 'false',
|
||||||
|
real_log_path: 'literal [log] $name.log',
|
||||||
|
PATH: path.join(f.root, 'bin') + path.delimiter + process.env.PATH,
|
||||||
|
},
|
||||||
|
[...args, literal],
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stderr, '');
|
||||||
|
assert.ok(
|
||||||
|
Date.now() - started < 1800,
|
||||||
|
'completion must not wait for the two-second watchdog',
|
||||||
|
);
|
||||||
|
const log = fs.readFileSync(
|
||||||
|
path.join(f.root, 'data/log/literal [log] $name.log'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(log.includes(`ARG=${literal}\n`), log);
|
||||||
|
assert.match(log, /AFTER\n/);
|
||||||
|
assert.match(log, /完成 ✅/);
|
||||||
|
if (label === 'concurrent') {
|
||||||
|
assert.match(log, /ACCOUNT=one\n/);
|
||||||
|
assert.match(log, /ACCOUNT=two\n/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shared initialization copies configuration and hooks under a directory containing spaces', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.write('shell/share.sh', source('shell/share.sh'));
|
||||||
|
for (const name of [
|
||||||
|
'config.sample.sh',
|
||||||
|
'task.sample.sh',
|
||||||
|
'extra.sample.sh',
|
||||||
|
'notify.py',
|
||||||
|
'notify.js',
|
||||||
|
'ql_sample.js',
|
||||||
|
'ql_sample.py',
|
||||||
|
])
|
||||||
|
f.write(
|
||||||
|
`sample/${name}`,
|
||||||
|
name === 'config.sample.sh'
|
||||||
|
? 'DefaultCronRule="0 0 * * *"\n'
|
||||||
|
: '# fixture\n',
|
||||||
|
);
|
||||||
|
const result = f.run(
|
||||||
|
'. "$QL_DIR/shell/share.sh"; fix_config; import_config; printf "%s" "$default_cron"',
|
||||||
|
{
|
||||||
|
QL_DIR: f.root,
|
||||||
|
QL_DATA_DIR: path.join(f.root, 'custom data'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stderr, '');
|
||||||
|
assert.equal(result.stdout, '0 0 * * *');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(f.root, 'custom data/config/task_after.sh')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('token generation passes a compiled token filename literally', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.write('static/build/token.js', 'console.log("fixture-token");\n');
|
||||||
|
const result = f.run(
|
||||||
|
extract('shell/api.sh', 'create_token') +
|
||||||
|
'\ncreate_token; printf "%s" "$__ql_token__"',
|
||||||
|
{ dir_root: f.root },
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stdout, 'fixture-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('script discovery keeps filenames with spaces, glob characters and leading dashes', (t) => {
|
||||||
|
const f = fixture(t, 'ql-script-list | ');
|
||||||
|
for (const name of ['-dash name.js', 'probe [1].js', 'sendNotify.js'])
|
||||||
|
f.write(`scripts/${name}`, "new Env('fixture');\n");
|
||||||
|
const result = f.run(
|
||||||
|
extract('shell/otask.sh', 'gen_array_scripts') +
|
||||||
|
'\ngen_array_scripts; printf "%s\\n" "${array_scripts[@]}"; printf "NAME=%s\\n" "${array_scripts_name[@]}"',
|
||||||
|
{
|
||||||
|
dir_scripts: path.join(f.root, 'scripts'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stderr, '');
|
||||||
|
assert.deepEqual(result.stdout.trim().split('\n').sort(), [
|
||||||
|
'-dash name.js',
|
||||||
|
'NAME=fixture',
|
||||||
|
'NAME=fixture',
|
||||||
|
'probe [1].js',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('token generation passes the TypeScript fallback filename literally', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const result = f.run(
|
||||||
|
extract('shell/api.sh', 'create_token') +
|
||||||
|
`
|
||||||
|
ts-node-transpile-only(){ [[ "$1" == "$dir_root/back/token.ts" ]] || return 99; printf fallback-token; }
|
||||||
|
create_token || exit $?
|
||||||
|
printf '%s' "$__ql_token__"
|
||||||
|
`,
|
||||||
|
{ dir_root: f.root },
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stdout, 'fallback-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repository cloning preserves a literal destination with and without a branch', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const origin = path.join(f.root, 'origin repo');
|
||||||
|
for (const args of [
|
||||||
|
['init', '-b', 'develop', origin],
|
||||||
|
['-C', origin, 'config', 'user.name', 'Fixture'],
|
||||||
|
['-C', origin, 'config', 'user.email', 'fixture@example.invalid'],
|
||||||
|
]) {
|
||||||
|
const result = spawnSync('git', args, { encoding: 'utf8' });
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(origin, 'probe.txt'), 'fixture');
|
||||||
|
for (const args of [
|
||||||
|
['-C', origin, 'add', '.'],
|
||||||
|
['-C', origin, 'commit', '-m', 'fixture'],
|
||||||
|
]) {
|
||||||
|
const result = spawnSync('git', args, { encoding: 'utf8' });
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
}
|
||||||
|
for (const branch of ['', 'develop']) {
|
||||||
|
const target = path.join(f.root, branch || 'default clone');
|
||||||
|
const result = f.run(
|
||||||
|
[
|
||||||
|
extract('shell/share.sh', 'set_proxy'),
|
||||||
|
extract('shell/share.sh', 'unset_proxy'),
|
||||||
|
extract('shell/share.sh', 'git_clone_scripts'),
|
||||||
|
't(){ :; }; git_clone_scripts "$QA_ORIGIN" "$QA_TARGET" "$QA_BRANCH"; exit "$exit_status"',
|
||||||
|
].join('\n'),
|
||||||
|
{ QA_ORIGIN: origin, QA_TARGET: target, QA_BRANCH: branch },
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(target, 'probe.txt'), 'utf8'),
|
||||||
|
'fixture',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const condition of [
|
||||||
|
'missing data',
|
||||||
|
'invalid data',
|
||||||
|
'invalid root',
|
||||||
|
'npm only',
|
||||||
|
'missing module',
|
||||||
|
'unsupported OS',
|
||||||
|
]) {
|
||||||
|
test(`native startup reports ${condition} without depending on translation initialization`, (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const npmRoot = path.join(f.root, 'npm global');
|
||||||
|
fs.mkdirSync(path.join(npmRoot, '@whyour/qinglong'), { recursive: true });
|
||||||
|
f.write('bin/npm', '#!/bin/bash\nprintf "%s" "$QA_NPM_ROOT"\n', true);
|
||||||
|
f.write('bin/id', '#!/bin/bash\nprintf 1000\n', true);
|
||||||
|
const env = {
|
||||||
|
PATH: path.join(f.root, 'bin'),
|
||||||
|
QL_DIR: f.root,
|
||||||
|
QL_DATA_DIR: path.join(f.root, 'data'),
|
||||||
|
QA_NPM_ROOT: npmRoot,
|
||||||
|
QL_OS_TYPE: 'unsupported-fixture',
|
||||||
|
};
|
||||||
|
if (condition === 'missing data') env.QL_DATA_DIR = '';
|
||||||
|
if (condition === 'invalid data') env.QL_DATA_DIR = 'relative/data';
|
||||||
|
if (condition === 'invalid root') env.QL_DIR = 'relative';
|
||||||
|
if (condition === 'npm only' || condition === 'missing module')
|
||||||
|
env.QL_DIR = '';
|
||||||
|
if (condition === 'missing module')
|
||||||
|
fs.rmdirSync(path.join(npmRoot, '@whyour/qinglong'));
|
||||||
|
const result = f.run(source('shell/start.sh'), env);
|
||||||
|
assert.equal(result.status, 1, result.stderr);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
result.stderr,
|
||||||
|
/command not found|No such file|integer expression/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
result.stderr,
|
||||||
|
condition === 'npm only'
|
||||||
|
? /export QL_DIR=/
|
||||||
|
: condition === 'missing module'
|
||||||
|
? /npm i -g @whyour\/qinglong/
|
||||||
|
: condition === 'unsupported OS'
|
||||||
|
? /暂不支持此系统/
|
||||||
|
: condition === 'invalid root'
|
||||||
|
? /QL_DIR/
|
||||||
|
: /QL_DATA_DIR/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('language loading falls back when global associative arrays are unavailable', (t) => {
|
||||||
|
const f = fixture(t, 'ql-locale-compat-');
|
||||||
|
f.write(
|
||||||
|
'shell/lang/en.sh',
|
||||||
|
'printf loaded > "$QA_MARKER"\ndeclare -gA LANG_MESSAGES=([probe]=translated)\n',
|
||||||
|
);
|
||||||
|
const result = f.run(
|
||||||
|
source('shell/share.sh') +
|
||||||
|
`
|
||||||
|
declare(){ if [[ "$1" == -gA ]]; then return 2; fi; builtin declare "$@"; }
|
||||||
|
import_config
|
||||||
|
t probe
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
QL_DIR: f.root,
|
||||||
|
QL_LANG: 'en',
|
||||||
|
QA_MARKER: path.join(f.root, 'loaded'),
|
||||||
|
DefaultCronRule: '0 0 * * *',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stdout, 'probe\n');
|
||||||
|
assert.equal(fs.existsSync(path.join(f.root, 'loaded')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'supported Bash keeps translated messages global after configuration loading',
|
||||||
|
{
|
||||||
|
skip:
|
||||||
|
spawnSync(bash, ['-c', 'declare -gA probe'], { stdio: 'ignore' })
|
||||||
|
.status !== 0,
|
||||||
|
},
|
||||||
|
(t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.write(
|
||||||
|
'shell/lang/en.sh',
|
||||||
|
'declare -gA LANG_MESSAGES=([probe]=translated)\n',
|
||||||
|
);
|
||||||
|
const result = f.run(
|
||||||
|
source('shell/share.sh') + '\nimport_config; t probe',
|
||||||
|
{
|
||||||
|
QL_DIR: f.root,
|
||||||
|
QL_LANG: 'en',
|
||||||
|
DefaultCronRule: '0 0 * * *',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stdout, 'translated\n');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const fail of [false, true]) {
|
||||||
|
test(`raw download ${
|
||||||
|
fail
|
||||||
|
? 'failure preserves the installed script'
|
||||||
|
: 'success installs the new script'
|
||||||
|
} and clears its proxy`, (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.write('raw/probe.js', 'previous-good-version');
|
||||||
|
f.write('scripts/raw_probe.js', 'previous-good-version');
|
||||||
|
f.write('crontab.list', '');
|
||||||
|
const result = f.run(
|
||||||
|
[
|
||||||
|
extract('shell/share.sh', 'set_proxy'),
|
||||||
|
extract('shell/share.sh', 'unset_proxy'),
|
||||||
|
extract('shell/update.sh', 'update_raw'),
|
||||||
|
`t(){ :; }
|
||||||
|
wget(){ printf '%s' "$QA_CONTENT" > "$4"; return "$QA_STATUS"; }
|
||||||
|
update_raw 'https://fixture.invalid/probe.js' 'http://fixture.invalid:8080' false false
|
||||||
|
result=$?
|
||||||
|
if env | grep -qE '^https?_proxy='; then exit 99; fi
|
||||||
|
exit "$result"`,
|
||||||
|
].join('\n'),
|
||||||
|
{
|
||||||
|
dir_raw: path.join(f.root, 'raw'),
|
||||||
|
dir_scripts: path.join(f.root, 'scripts'),
|
||||||
|
list_crontab_user: path.join(f.root, 'crontab.list'),
|
||||||
|
cmd_task: 'task',
|
||||||
|
uniq_path: 'probe',
|
||||||
|
QA_CONTENT: fail ? 'partial-download' : 'new-version',
|
||||||
|
QA_STATUS: fail ? '8' : '0',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(result.status, fail ? 8 : 0, result.stderr);
|
||||||
|
const expected = fail ? 'previous-good-version' : 'new-version';
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(f.root, 'raw/probe.js'), 'utf8'),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(f.root, 'scripts/raw_probe.js'), 'utf8'),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
assert.equal(fs.existsSync(path.join(f.root, 'raw/probe.js.new')), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ql raw propagates download failure without replacing either installed copy', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
for (const name of ['share.sh', 'update.sh', 'env.sh'])
|
||||||
|
f.write(`shell/${name}`, source(`shell/${name}`));
|
||||||
|
f.write('shell/api.sh', '');
|
||||||
|
f.write('data/config/config.sh', 'DefaultCronRule="0 0 * * *"\n');
|
||||||
|
f.write('data/config/crontab.list', '');
|
||||||
|
f.write('data/raw/invalid_probe.js', 'old-version');
|
||||||
|
f.write('data/scripts/raw_invalid_probe.js', 'old-version');
|
||||||
|
f.write('bin/wget', '#!/bin/bash\nprintf partial > "$4"\nexit 8\n', true);
|
||||||
|
const result = f.run(
|
||||||
|
'exec "$QA_BASH" "$QL_DIR/shell/update.sh" raw https://fixture.invalid/probe.js "" false false',
|
||||||
|
{
|
||||||
|
QA_BASH: bash,
|
||||||
|
QL_DIR: f.root,
|
||||||
|
QL_DATA_DIR: path.join(f.root, 'data'),
|
||||||
|
ID: '',
|
||||||
|
PATH: path.join(f.root, 'bin') + path.delimiter + process.env.PATH,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 8, result.stderr);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(f.root, 'data/raw/invalid_probe.js'), 'utf8'),
|
||||||
|
'old-version',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(f.root, 'data/scripts/raw_invalid_probe.js'),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
'old-version',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateTools = [
|
||||||
|
{
|
||||||
|
name: 'host',
|
||||||
|
command: 'date',
|
||||||
|
macos: process.platform === 'darwin' ? '1' : '0',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
process.platform === 'darwin' &&
|
||||||
|
spawnSync('gdate', ['--version']).status === 0
|
||||||
|
)
|
||||||
|
dateTools.push({ name: 'GNU', command: 'gdate', macos: '0' });
|
||||||
|
for (const dateTool of dateTools) {
|
||||||
|
test(`${dateTool.name} retention handles literal filenames, active references and empty directories`, (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const old = f.write('logs/2020-01-01 two [words].log', 'old');
|
||||||
|
const active = f.write('logs/2020-01-01 active & protected.log', 'active');
|
||||||
|
f.write('logs/keep/important.txt', 'keep');
|
||||||
|
fs.mkdirSync(path.join(f.root, 'logs/empty directory'));
|
||||||
|
const result = f.run(
|
||||||
|
`
|
||||||
|
t(){ :; }
|
||||||
|
date(){ command "$QA_DATE" "$@"; }
|
||||||
|
find_cron_api(){ printf '%s\n' "$1" >> "$QA_QUERIES"; [[ "$1" == *active* ]] && printf active; }
|
||||||
|
. "$QA_SCRIPT" 7
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
dir_log: path.join(f.root, 'logs'),
|
||||||
|
is_macos: dateTool.macos,
|
||||||
|
QA_DATE: dateTool.command,
|
||||||
|
QA_QUERIES: path.join(f.root, 'queries'),
|
||||||
|
QA_SCRIPT: path.join(repo, 'shell/rmlog.sh'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stderr, '');
|
||||||
|
assert.equal(fs.existsSync(old), false);
|
||||||
|
assert.equal(fs.existsSync(active), true);
|
||||||
|
assert.equal(
|
||||||
|
fs.existsSync(path.join(f.root, 'logs/empty directory')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.ok(fs.existsSync(path.join(f.root, 'logs/keep/important.txt')));
|
||||||
|
const queries = fs
|
||||||
|
.readFileSync(path.join(f.root, 'queries'), 'utf8')
|
||||||
|
.trim()
|
||||||
|
.split('\n');
|
||||||
|
for (const query of queries)
|
||||||
|
assert.equal(
|
||||||
|
new URLSearchParams(query).get('log_path').includes('.log'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
queries.some(
|
||||||
|
(query) =>
|
||||||
|
new URLSearchParams(query).get('log_path') ===
|
||||||
|
'2020-01-01 active & protected.log',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${dateTool.name} retention measures a dated log from local midnight`, (t) => {
|
||||||
|
const f = fixture(t, 'ql-retention-boundary-');
|
||||||
|
const file = f.write('logs/2026-01-01.log', 'boundary');
|
||||||
|
const result = f.run(
|
||||||
|
`
|
||||||
|
t(){ :; }; find_cron_api(){ :; }
|
||||||
|
date(){ if [[ "$*" == +%s ]]; then printf 1767830401; else command "$QA_DATE" "$@"; fi; }
|
||||||
|
. "$QA_SCRIPT" 7
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
dir_log: path.join(f.root, 'logs'),
|
||||||
|
is_macos: dateTool.macos,
|
||||||
|
QA_DATE: dateTool.command,
|
||||||
|
QA_SCRIPT: path.join(repo, 'shell/rmlog.sh'),
|
||||||
|
TZ: 'UTC',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(
|
||||||
|
fs.existsSync(file),
|
||||||
|
false,
|
||||||
|
'seven days plus one second is past retention',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('log streaming options are accepted by the actual platform tail', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const today = spawnSync('date', ['+%F'], { encoding: 'utf8' }).stdout.trim();
|
||||||
|
f.write(`syslog/${today}.log`, 'ready\n');
|
||||||
|
const result = f.run(
|
||||||
|
extract('shell/update.sh', 'show_service_logs') +
|
||||||
|
`
|
||||||
|
tail(){ printf '%s\\0' "$@"; }
|
||||||
|
show_service_logs --lines 0
|
||||||
|
`,
|
||||||
|
{ dir_data: f.root },
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
const args = result.stdout.split('\0').slice(0, -1);
|
||||||
|
const native = spawnSync('tail', args, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 250,
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
|
});
|
||||||
|
assert.equal(native.error?.code, 'ETIMEDOUT', native.stderr);
|
||||||
|
assert.equal(native.stderr, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const version of ['2.23.0', '2.23.0-beta.1+build.2', 'invalid']) {
|
||||||
|
test(`legacy publishing ${
|
||||||
|
version === 'invalid'
|
||||||
|
? 'rejects an invalid version before Git changes'
|
||||||
|
: 'uses the complete version with GNU-compatible tools'
|
||||||
|
}`, (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.write('version.yaml', `version: ${version}\n`);
|
||||||
|
f.write(
|
||||||
|
'bin/git',
|
||||||
|
'#!/bin/bash\nprintf "%s\\n" "$*" >> "$QA_GIT_CALLS"\n',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
f.write('bin/ts-node-transpile-only', '#!/bin/bash\nexit 0\n', true);
|
||||||
|
const result = f.run(source('shell/pub.sh'), {
|
||||||
|
PATH:
|
||||||
|
path.join(f.root, 'bin') +
|
||||||
|
path.delimiter +
|
||||||
|
(process.platform === 'darwin' &&
|
||||||
|
fs.existsSync('/opt/homebrew/opt/grep/libexec/gnubin')
|
||||||
|
? '/opt/homebrew/opt/grep/libexec/gnubin:'
|
||||||
|
: '') +
|
||||||
|
process.env.PATH,
|
||||||
|
QA_GIT_CALLS: path.join(f.root, 'git-calls'),
|
||||||
|
});
|
||||||
|
if (version === 'invalid') {
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.equal(fs.existsSync(path.join(f.root, 'git-calls')), false);
|
||||||
|
} else {
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.ok(
|
||||||
|
fs
|
||||||
|
.readFileSync(path.join(f.root, 'git-calls'), 'utf8')
|
||||||
|
.includes(`tag -a v${version} -m release v${version}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
path.resolve(__dirname, '../../shell/task-timeout.sh'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('timed task cleanup preserves output and exit codes without Bash job notifications', () => {
|
||||||
|
const result = spawnSync(
|
||||||
|
process.env.QL_TEST_BASH || '/bin/bash',
|
||||||
|
[
|
||||||
|
'-c',
|
||||||
|
source +
|
||||||
|
`
|
||||||
|
command_timeout_time=1h
|
||||||
|
for code in 0 7 0 7 0 7 0 7 0 7; do
|
||||||
|
# Keep a descendant alive after the task leader exits so cleanup must
|
||||||
|
# terminate the worker group as well as the sleeping watchdog.
|
||||||
|
run_task_command bash -c 'sleep 10 & echo TASK_OUTPUT; echo TASK_ERROR >&2; exit "$1"' task "$code"
|
||||||
|
actual=$?
|
||||||
|
[[ $actual == "$code" ]] || exit 99
|
||||||
|
done
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8', timeout: 15000 },
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stdout, 'TASK_OUTPUT\n'.repeat(10));
|
||||||
|
assert.equal(result.stderr, 'TASK_ERROR\n'.repeat(10));
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const psTree = require('ps-tree');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const bash = process.env.QL_TEST_BASH || '/bin/bash';
|
||||||
|
const helper = path.resolve(__dirname, '../../shell/task-timeout.sh');
|
||||||
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const tree = (pid) =>
|
||||||
|
new Promise((resolve, reject) =>
|
||||||
|
psTree(pid, (error, children) =>
|
||||||
|
error ? reject(error) : resolve(children),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
async function until(predicate, message) {
|
||||||
|
const deadline = Date.now() + 5000;
|
||||||
|
while (!(await predicate())) {
|
||||||
|
assert.ok(Date.now() < deadline, message);
|
||||||
|
await delay(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function alive(pid) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
try {
|
||||||
|
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
||||||
|
if (
|
||||||
|
['Z', 'X', 'x'].includes(
|
||||||
|
stat.slice(stat.lastIndexOf(')') + 2).split(' ')[0],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ESRCH') return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mode of ['timeout', 'manual']) {
|
||||||
|
test(
|
||||||
|
`${mode} cleans up TERM-resistant children, grandchildren and the watchdog`,
|
||||||
|
{ timeout: 12000, skip: process.platform === 'win32' },
|
||||||
|
async (t) => {
|
||||||
|
const root = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'ql-timeout-processes-'),
|
||||||
|
);
|
||||||
|
const known = new Set();
|
||||||
|
let child;
|
||||||
|
t.after(async () => {
|
||||||
|
if (child) {
|
||||||
|
for (const p of await tree(child.pid).catch(() => []))
|
||||||
|
known.add(Number(p.PID));
|
||||||
|
}
|
||||||
|
for (const pid of known) {
|
||||||
|
if (pid > 1) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGKILL');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'child.sh'),
|
||||||
|
`
|
||||||
|
trap '' TERM
|
||||||
|
printf '%s' "$$" > "$QA_ROOT/child"
|
||||||
|
sleep 30 &
|
||||||
|
printf '%s' "$!" > "$QA_ROOT/grandchild"
|
||||||
|
wait
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
child = spawn(
|
||||||
|
bash,
|
||||||
|
[
|
||||||
|
'-c',
|
||||||
|
`
|
||||||
|
. "$QA_HELPER"
|
||||||
|
task() {
|
||||||
|
trap '' TERM
|
||||||
|
/bin/sh -c 'printf "%s" "$PPID"' > "$QA_ROOT/worker"
|
||||||
|
"$QA_BASH" "$QA_ROOT/child.sh" &
|
||||||
|
wait
|
||||||
|
}
|
||||||
|
run_task_command task
|
||||||
|
exit $?
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
detached: true,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
QA_ROOT: root,
|
||||||
|
QA_HELPER: helper,
|
||||||
|
QA_BASH: bash,
|
||||||
|
command_timeout_time: mode === 'timeout' ? '1s' : '1h',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
known.add(child.pid);
|
||||||
|
let result,
|
||||||
|
stderr = '';
|
||||||
|
child.stdout.resume();
|
||||||
|
child.stderr.on('data', (chunk) => (stderr += chunk));
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
result = { code, signal };
|
||||||
|
});
|
||||||
|
await until(
|
||||||
|
() =>
|
||||||
|
['worker', 'child', 'grandchild'].every((name) => {
|
||||||
|
const file = path.join(root, name);
|
||||||
|
return (
|
||||||
|
fs.existsSync(file) && Number(fs.readFileSync(file, 'utf8')) > 1
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
'task process tree did not initialize',
|
||||||
|
);
|
||||||
|
const descendants = await tree(child.pid);
|
||||||
|
for (const p of descendants) known.add(Number(p.PID));
|
||||||
|
for (const name of ['worker', 'child', 'grandchild'])
|
||||||
|
known.add(Number(fs.readFileSync(path.join(root, name), 'utf8')));
|
||||||
|
assert.ok(
|
||||||
|
known.size >= 6,
|
||||||
|
'must observe the wrapper, watchdog and task descendants',
|
||||||
|
);
|
||||||
|
if (mode === 'manual') {
|
||||||
|
const worker = Number(
|
||||||
|
fs.readFileSync(path.join(root, 'worker'), 'utf8'),
|
||||||
|
);
|
||||||
|
const wrapper = descendants.find((p) => Number(p.PID) === worker);
|
||||||
|
assert.ok(wrapper, 'worker must belong to this test process tree');
|
||||||
|
process.kill(Number(wrapper.PPID), 'SIGTERM');
|
||||||
|
}
|
||||||
|
await until(() => result, 'task wrapper did not exit');
|
||||||
|
assert.equal(result.code, mode === 'timeout' ? 124 : 143, stderr);
|
||||||
|
await until(
|
||||||
|
() => [...known].every((pid) => !alive(pid)),
|
||||||
|
'task or watchdog process survived cleanup',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ test('decoded SystemNotify requests reach WPUSH with explicit or saved configura
|
|||||||
'../config/const': constants,
|
'../config/const': constants,
|
||||||
'../config/util': {},
|
'../config/util': {},
|
||||||
'../data/dependence': {},
|
'../data/dependence': {},
|
||||||
'../data/system': {},
|
'../data/system': { AuthDataType: { notifyLog: 'notifyLog' }, NotifyStatus: { success: 0, fail: 1 }, SystemModel: { create: async () => {}, findAll: async () => [] } },
|
||||||
'../shared/pLimit': {},
|
'../shared/pLimit': {},
|
||||||
'../schedule/client': {},
|
'../schedule/client': {},
|
||||||
'../shared/i18n': { t: (x) => x },
|
'../shared/i18n': { t: (x) => x },
|
||||||
@@ -79,7 +79,7 @@ test('decoded SystemNotify requests reach WPUSH with explicit or saved configura
|
|||||||
wpushApiKey: 'saved-key',
|
wpushApiKey: 'saved-key',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const system = new SystemService();
|
const system = new SystemService({ error() {} });
|
||||||
system.notificationService = notification;
|
system.notificationService = notification;
|
||||||
|
|
||||||
for (const info of [
|
for (const info of [
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { createRequire } = require('node:module');
|
||||||
|
const test = require('node:test');
|
||||||
|
const { EditorState } = require('@codemirror/state');
|
||||||
|
const load = require('../helpers/load-security-module.cjs');
|
||||||
|
const { getCodeMirrorExtensions } = load(
|
||||||
|
path.resolve(__dirname, '../../src/utils/codemirror/language.ts'),
|
||||||
|
);
|
||||||
|
const { LANG_MAP } = load(path.resolve(__dirname, '../../src/utils/const.ts'));
|
||||||
|
const languageRequire = createRequire(
|
||||||
|
require.resolve('@uiw/codemirror-extensions-langs'),
|
||||||
|
);
|
||||||
|
const { syntaxTree } = languageRequire('@codemirror/language');
|
||||||
|
|
||||||
|
test('INI documents load and remain editable with CodeMirror syntax support', () => {
|
||||||
|
const doc = '; comment\n[section]\nkey=value\n';
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc,
|
||||||
|
extensions: getCodeMirrorExtensions(LANG_MAP['.ini']),
|
||||||
|
});
|
||||||
|
assert.ok(syntaxTree(state).length > 0);
|
||||||
|
assert.ok(syntaxTree(state).topNode.firstChild, 'INI tokens were parsed');
|
||||||
|
const updated = state.update({
|
||||||
|
changes: {
|
||||||
|
from: doc.indexOf('value'),
|
||||||
|
to: doc.indexOf('value') + 5,
|
||||||
|
insert: 'edited',
|
||||||
|
},
|
||||||
|
}).state;
|
||||||
|
assert.equal(updated.doc.toString(), '; comment\n[section]\nkey=edited\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every configured script language supplies a valid CodeMirror extension', () => {
|
||||||
|
for (const mode of new Set(Object.values(LANG_MAP))) {
|
||||||
|
const extensions = getCodeMirrorExtensions(mode);
|
||||||
|
assert.equal(extensions.length, 1, mode);
|
||||||
|
assert.doesNotThrow(
|
||||||
|
() => EditorState.create({ doc: 'sample', extensions }),
|
||||||
|
mode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown or empty languages fall back to editable plain text', () => {
|
||||||
|
for (const mode of ['', undefined, 'unknown', 'constructor', 'toString']) {
|
||||||
|
const extensions = getCodeMirrorExtensions(mode);
|
||||||
|
assert.deepEqual(extensions, []);
|
||||||
|
const state = EditorState.create({ doc: 'text', extensions });
|
||||||
|
assert.equal(
|
||||||
|
state
|
||||||
|
.update({ changes: { from: 4, insert: ' edited' } })
|
||||||
|
.state.doc.toString(),
|
||||||
|
'text edited',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
test('notification log column titles resolve in every supported locale', () => {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../src/pages/setting/notifyLog.tsx'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const titles = [...source.matchAll(/title:\s*intl\.get\('([^']+)'\)/g)].map(
|
||||||
|
(m) => m[1],
|
||||||
|
);
|
||||||
|
assert.ok(titles.includes('标题') && titles.includes('内容'));
|
||||||
|
for (const locale of ['zh-CN', 'en-US']) {
|
||||||
|
const messages = require(`../../src/locales/${locale}.json`);
|
||||||
|
for (const title of titles)
|
||||||
|
assert.ok(messages[title]?.trim(), `${locale}: ${title}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user