monitor/core/modules/FxMonitor/utils.ts
2025-04-16 22:30:27 +07:00

394 lines
11 KiB
TypeScript

import got from "@lib/got";
import { secsToShortestDuration } from "@lib/misc";
import bytes from "bytes";
import { RequestError, TimeoutError, type Response as GotResponse } from "got";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
import type { MonitorIssuesArray } from "./index";
/**
* Class to easily check elapsed time.
* Seconds precision, rounded down, consistent.
*/
export class Stopwatch {
private readonly autoStart: boolean = false;
private tsStart: number | null = null;
constructor(autoStart?: boolean) {
if (autoStart) {
this.autoStart = true;
this.restart();
}
}
/**
* Reset the stopwatch (stop and clear).
*/
reset() {
if (this.autoStart) {
this.restart();
} else {
this.tsStart = null;
}
}
/**
* Start or restart the stopwatch.
*/
restart() {
this.tsStart = Date.now();
}
/**
* Returns if the timer is over a certain amount of time.
* Always false if not started.
*/
isOver(secs: number) {
const elapsed = this.elapsed;
if (elapsed === Infinity) {
return false;
} else {
return elapsed >= secs;
}
}
/**
* Returns true if the stopwatch is running.
*/
get started() {
return this.tsStart !== null;
}
/**
* Returns the elapsed time in seconds or Infinity if not started.
*/
get elapsed() {
if (this.tsStart === null) {
return Infinity;
} else {
const elapsedMs = Date.now() - this.tsStart;
return Math.floor(elapsedMs / 1000);
}
}
}
/**
* Exported enum
*/
export enum MonitorState {
PENDING = 'PENDING',
HEALTHY = 'HEALTHY',
DELAYED = 'DELAYED',
FATAL = 'FATAL',
}
/**
* Class to easily check elapsed time.
* Seconds precision, rounded down, consistent.
*/
export class HealthEventMonitor {
private readonly swLastHealthyEvent = new Stopwatch();
private firstHealthyEvent: number | undefined;
constructor(
private readonly delayLimit: number,
private readonly fatalLimit: number,
) { }
/**
* Resets the state of the monitor.
*/
public reset() {
this.swLastHealthyEvent.reset();
this.firstHealthyEvent = undefined;
}
/**
* Register a successful event
*/
public markHealthy() {
this.swLastHealthyEvent.restart();
this.firstHealthyEvent ??= Date.now();
}
/**
* Returns the current status of the monitor.
*/
public get status() {
let state: MonitorState;
if (!this.swLastHealthyEvent.started) {
state = MonitorState.PENDING;
} else if (this.swLastHealthyEvent.isOver(this.fatalLimit)) {
state = MonitorState.FATAL;
} else if (this.swLastHealthyEvent.isOver(this.delayLimit)) {
state = MonitorState.DELAYED;
} else {
state = MonitorState.HEALTHY;
}
return {
state,
secsSinceLast: this.swLastHealthyEvent.elapsed,
secsSinceFirst: this.firstHealthyEvent
? Math.floor((Date.now() - this.firstHealthyEvent) / 1000)
: Infinity,
}
}
}
type HealthEventMonitorStatus = HealthEventMonitor['status'];
/**
* Helper to get the time tags for error messages
*/
export const getMonitorTimeTags = (
heartBeat: HealthEventMonitorStatus,
healthCheck: HealthEventMonitorStatus,
processUptime: number,
) => {
const secs = (s: number) => Number.isFinite(s) ? secsToShortestDuration(s, { round: false }) : '--';
const procTime = secsToShortestDuration(processUptime);
const hbTime = secs(heartBeat.secsSinceLast);
const hcTime = secs(healthCheck.secsSinceLast);
return {
simple: `(HB:${hbTime}|HC:${hcTime})`,
withProc: `(P:${procTime}|HB:${hbTime}|HC:${hcTime})`,
}
}
/**
* Processes a MonitorIssuesArray and returns a clean array of strings.
*/
export const cleanMonitorIssuesArray = (issues: MonitorIssuesArray | undefined) => {
if (!issues || !Array.isArray(issues)) return [];
let cleanIssues: string[] = [];
for (const issue of issues) {
if (!issue) continue;
if (typeof issue === 'string') {
cleanIssues.push(issue);
} else {
cleanIssues.push(...issue.all.filter(Boolean));
}
}
return cleanIssues;
}
/**
* Helper class to organize monitor issues.
*/
export class MonitorIssue {
private readonly infos: string[] = [];
private readonly details: string[] = [];
constructor(public title: string) { }
setTitle(title: string) {
this.title = title;
}
addInfo(info: string | undefined) {
if (!info) return;
this.infos.push(info);
}
addDetail(detail: string | undefined) {
if (!detail) return;
this.details.push(detail);
}
get all() {
return [this.title, ...this.infos, ...this.details];
}
}
/**
* Helper to get debug data from a Got error
*/
const getRespDebugData = (resp?: GotResponse<string>) => {
if (!resp) return { error: 'Response object is undefined.' };
try {
let truncatedBody = '[resp.body is not a string]';
if (typeof resp?.body === 'string') {
const bodyCutoff = 512;
truncatedBody = resp.body.length > bodyCutoff
? resp.body.slice(0, bodyCutoff) + '[...]'
: resp.body;
}
return {
URL: String(resp?.url),
Status: `${resp?.statusCode} ${resp?.statusMessage}`,
Server: String(resp?.headers?.['server']),
Location: String(resp?.headers?.['location']),
ContentType: String(resp?.headers?.['content-type']),
ContentLength: String(resp?.headers?.['content-length']),
BodyLength: bytes(resp?.body?.length),
Body: truncatedBody,
} as Record<string, string>;
} catch (error) {
return {
error: `Error getting debug data: ${(error as any).message}`,
};
}
}
/**
* Do a HTTP GET to the /dynamic.json endpoint and parse the JSON response.
*/
export const fetchDynamicJson = async (
netEndpoint: string,
timeout: number
): Promise<FetchDynamicJsonError | FetchDynamicJsonSuccess> => {
let resp: GotResponse<string> | undefined;
try {
resp = await got.get({
url: `http://${netEndpoint}/dynamic.json`,
maxRedirects: 0,
timeout: { request: timeout },
retry: { limit: 0 },
throwHttpErrors: false,
});
} catch (error) {
let msg, code;
if (error instanceof RequestError) {
msg = error.message;
code = error.code;
} else if (error instanceof TimeoutError) {
msg = error.message;
code = error.code;
} else {
const err = error as any;
msg = err?.message ?? '';
code = err?.code ?? '';
}
return {
success: false,
error: `HealthCheck Request error: ${msg}`,
debugData: {
message: msg,
code: code,
...getRespDebugData(resp),
},
};
}
//Precalculating error message
const debugData = getRespDebugData(resp);
//Checking response status
if (resp.statusCode !== 200) {
return {
success: false,
error: `HealthCheck HTTP status: ${debugData.Status}`,
debugData,
}
}
//Parsing response
if (typeof resp.body !== 'string') {
return {
success: false,
error: `HealthCheck response body is not a string.`,
debugData,
}
}
if (!resp.body.length) {
return {
success: false,
error: `HealthCheck response body is empty.`,
debugData,
}
}
if (resp.body.toLocaleLowerCase().includes('<html')) {
return {
success: false,
error: `HealthCheck response body is HTML instead of JSON.`,
debugData,
}
}
let jsonData: any;
try {
jsonData = JSON.parse(resp.body);
} catch (error) {
return {
success: false,
error: `HealthCheck response body is not valid JSON.`,
debugData,
}
}
const schemaRes = dynamicJsonSchema.safeParse(jsonData);
if (!schemaRes.success) {
return {
success: false,
error: fromZodError(schemaRes.error, { prefix: 'HealthCheck JSON invalid data' }).message,
debugData,
}
}
return {
success: true,
data: schemaRes.data,
};
}
const dynamicJsonSchema = z.object({
clients: z.number().int().nonnegative(),
// hostname: z.string().optional(),
// gametype: z.string().optional(),
// mapname: z.string().optional(),
// iv: z.string().optional(),
sv_maxclients: z.coerce.number().int().positive().optional(),
});
export type VerboseErrorData = {
error: string,
debugData: Record<string, string>,
}
type FetchDynamicJsonError = {
success: false;
} & VerboseErrorData;
type FetchDynamicJsonSuccess = {
success: true;
data: z.infer<typeof dynamicJsonSchema>;
};
/**
* Do a HTTP GET to the /info.json endpoint and parse the JSON response.
*/
export const fetchInfoJson = async (netEndpoint: string) => {
let info: any;
try {
info = await got.get({
url: `http://${netEndpoint}/info.json`,
maxRedirects: 0,
timeout: { request: 15_000 },
retry: { limit: 6 },
}).json();
} catch (error) {
return;
}
const schemas = {
icon: z.string().base64(),
locale: z.string().nonempty(),
projectName: z.string().nonempty(),
projectDesc: z.string().nonempty(),
bannerConnecting: z.string().url(),
bannerDetail: z.string().url(),
tags: z.string().nonempty(),
};
return {
icon: schemas.icon.safeParse(info.icon)?.data,
locale: schemas.locale.safeParse(info.vars?.locale)?.data,
projectName: schemas.projectName.safeParse(info.vars?.sv_projectName)?.data,
projectDesc: schemas.projectDesc.safeParse(info.vars?.sv_projectDesc)?.data,
bannerConnecting: schemas.bannerConnecting.safeParse(info.vars?.banner_connecting)?.data,
bannerDetail: schemas.bannerDetail.safeParse(info.vars?.banner_detail)?.data,
tags: schemas.tags.safeParse(info.vars?.tags)?.data,
};
}