394 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|