Files
Checkmate/server/src/service/statusService.ts
T

273 lines
9.7 KiB
TypeScript
Raw Normal View History

2026-06-09 12:34:15 -07:00
import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
2026-08-04 11:27:32 -07:00
import type { Check, CheckDiskInfo } from "@/domain/checks/check.type.js";
2026-09-24 11:46:04 -07:00
import {
HardwareMetricKeys,
MonitorStatuses,
type HardwareBreaches,
type HardwareCounters,
2026-09-24 11:46:04 -07:00
type Monitor,
type MonitorStatus,
} from "@/domain/monitors/monitor.type.js";
2026-09-23 13:38:02 -07:00
import type { StatusChangeResult } from "@/types/network.js";
2026-03-03 23:01:35 +00:00
import { AppError } from "@/utils/AppError.js";
2026-02-12 22:37:40 +00:00
import { ILogger } from "@/utils/logger.js";
import type { HardwareStatusMetrics } from "@/types/network.js";
import { MAX_RECENT_CHECKS } from "@/domain/monitors/monitor.type.js";
2026-08-04 11:27:32 -07:00
import { toCheckSnapshot } from "@/domain/checks/check.snapshot.js";
2026-09-23 10:54:35 -07:00
import { findContainerBreaches } from "@/domain/docker/docker-alert.js";
2025-04-20 11:29:53 -07:00
const SERVICE_NAME = "StatusService";
const HARDWARE_ALERT_COUNTER_START = 5;
2025-04-20 11:29:53 -07:00
2026-01-18 23:38:40 +00:00
export interface IStatusService {
2026-09-23 13:38:02 -07:00
updateRunningStats(check: Check, monitor: Monitor): Promise<boolean>;
updateMonitorStatus(check: Check, monitor: Monitor): Promise<StatusChangeResult>;
2026-01-18 23:38:40 +00:00
}
export class StatusService implements IStatusService {
2025-04-20 11:29:53 -07:00
static SERVICE_NAME = SERVICE_NAME;
2026-03-03 23:01:35 +00:00
private logger: ILogger;
2026-01-15 22:33:25 +00:00
private monitorsRepository: IMonitorsRepository;
2026-02-12 22:37:40 +00:00
private monitorStatsRepository: IMonitorStatsRepository;
2026-01-15 22:33:25 +00:00
constructor(logger: ILogger, monitorsRepository: IMonitorsRepository, monitorStatsRepository: IMonitorStatsRepository) {
2025-04-20 11:29:53 -07:00
this.logger = logger;
2026-01-13 20:20:00 +00:00
this.monitorsRepository = monitorsRepository;
2026-02-12 22:37:40 +00:00
this.monitorStatsRepository = monitorStatsRepository;
2025-07-25 15:15:14 -07:00
}
2026-09-23 13:38:02 -07:00
async updateRunningStats(check: Check, monitor: Monitor) {
2025-04-20 11:29:53 -07:00
try {
await this.monitorStatsRepository.updateByMonitorId(monitor.id, {
2026-09-23 13:38:02 -07:00
status: check.status === true,
responseTime: check.responseTime ?? 0,
now: Date.now(),
});
2025-04-20 11:29:53 -07:00
return true;
2026-03-03 23:01:35 +00:00
} catch (error: unknown) {
2025-04-20 11:29:53 -07:00
this.logger.error({
2026-01-15 22:33:25 +00:00
service: SERVICE_NAME,
2026-03-03 23:01:35 +00:00
message: error instanceof Error ? error.message : "Unknown error",
2025-04-20 11:29:53 -07:00
method: "updateRunningStats",
2026-03-03 23:01:35 +00:00
stack: error instanceof Error ? error.stack : undefined,
2025-04-20 11:29:53 -07:00
});
return false;
}
}
2026-09-23 13:38:02 -07:00
private tryUpdateRunningStats = async (check: Check, monitor: Monitor) => {
const statsOk = await this.updateRunningStats(check, monitor);
if (!statsOk) {
this.logger.warn({
service: SERVICE_NAME,
method: "updateMonitorStatus",
message: `Stats update failed for monitor ${monitor.id}`,
});
}
};
private computeReachability = (
currentStatus: MonitorStatus,
window: Array<boolean>,
threshold: number
): { nextStatus: "up" | "down"; transitioned: boolean } => {
const failures = window.filter((status) => status === false).length;
const failureRate = (failures / window.length) * 100;
if (failureRate >= threshold && currentStatus !== "down") {
return { nextStatus: "down", transitioned: true };
}
if (failureRate < threshold && currentStatus === "down") {
return { nextStatus: "up", transitioned: true };
}
return { nextStatus: "up", transitioned: false };
};
private computeHardwareStatus = (params: {
currentStatus: MonitorStatus;
reachabilityDown: boolean;
2026-04-14 10:41:55 -07:00
metrics: HardwareStatusMetrics;
thresholds: { cpu: number; memory: number; disk: number; temp: number };
counters: HardwareCounters;
}): {
nextStatus: MonitorStatus;
transitioned: boolean;
breaches: HardwareBreaches;
nextCounters: HardwareCounters;
2026-04-14 10:41:55 -07:00
} => {
const { metrics, thresholds, counters, currentStatus, reachabilityDown } = params;
const cpuUsage = metrics.cpu?.usage_percent ?? -1;
const memoryUsage = metrics.memory?.usage_percent ?? -1;
const temps = metrics.cpu?.temperature ?? [];
const breaches: HardwareBreaches = {
cpu: cpuUsage !== -1 && cpuUsage > thresholds.cpu / 100,
memory: memoryUsage !== -1 && memoryUsage > thresholds.memory / 100,
disk: metrics.disk
? metrics.disk.some((d: CheckDiskInfo) => d != null && typeof d.usage_percent === "number" && d.usage_percent > thresholds.disk / 100)
: false,
temp: temps.some((temp: number) => temp > thresholds.temp),
};
// Update counters: decrement (floored at 0) if breached, reset to start otherwise.
const nextCounters = { ...counters };
2026-09-24 11:46:04 -07:00
for (const key of HardwareMetricKeys) {
nextCounters[key] = breaches[key] ? Math.max(0, counters[key] - 1) : HARDWARE_ALERT_COUNTER_START;
}
2026-04-14 10:41:55 -07:00
// Status transition: reachability "down" takes precedence; hardware can only drive
// transitions into "breached" and back out to "up". Any other case leaves status untouched.
let nextStatus: MonitorStatus = currentStatus;
let transitioned = false;
if (!reachabilityDown) {
2026-04-14 10:41:55 -07:00
// A counter can only reach zero via the decrement path, which only runs when that
// metric is currently breaching — so anyCounterZero already implies anyBreached.
2026-09-24 11:46:04 -07:00
const anyCounterZero = HardwareMetricKeys.some((k) => nextCounters[k] === 0);
const allNormal = HardwareMetricKeys.every((k) => !breaches[k]);
2026-04-14 10:41:55 -07:00
if (anyCounterZero && currentStatus !== "breached") {
nextStatus = "breached";
transitioned = true;
} else if (allNormal && currentStatus === "breached") {
nextStatus = "up";
transitioned = true;
}
}
return { nextStatus, transitioned, breaches, nextCounters };
};
2026-09-23 10:54:35 -07:00
private computeDockerStatus = (params: { currentStatus: MonitorStatus; reachabilityDown: boolean; breaching: boolean }) => {
const { currentStatus, reachabilityDown, breaching } = params;
let nextStatus: MonitorStatus = currentStatus;
let transitioned = false;
if (!reachabilityDown) {
if (breaching && currentStatus !== "breached") {
nextStatus = "breached";
transitioned = true;
2026-09-23 11:06:25 -07:00
} else if (!breaching && currentStatus === "breached") {
nextStatus = "up";
transitioned = true;
}
2026-09-23 10:54:35 -07:00
}
return { nextStatus, transitioned };
};
2026-09-23 13:38:02 -07:00
updateMonitorStatus = async (check: Check, monitor: Monitor): Promise<StatusChangeResult> => {
2026-04-16 10:41:00 -07:00
try {
const { status } = check;
2026-04-16 10:41:00 -07:00
// Update running stats
2026-09-23 13:38:02 -07:00
await this.tryUpdateRunningStats(check, monitor);
2026-04-16 10:41:00 -07:00
const prevStatus = monitor.status;
2026-08-04 11:27:32 -07:00
const checkSnapshot = toCheckSnapshot(check);
// Project the window as it will look after updating DB
// This is done because we need the updated status window to compute new status, but we don't
// want an an extra DB write just to get the window.
const projectedWindow = [...(monitor.statusWindow || []), check.status].slice(-monitor.statusWindowSize);
// Build the status patch — computed against the projected window
const patch: Partial<Monitor> = {};
2026-06-19 09:37:24 -07:00
// Resolve "initializing" and "maintenance" up front, incidents should be created on initialization && down
// Unknown values (e.g. legacy boolean statuses from old versions) are resolved the same way,
// otherwise computeReachability can never transition them and they stay stuck forever.
const isUnknownStatus = !MonitorStatuses.includes(monitor.status);
2026-04-28 10:09:42 -07:00
let newStatus: MonitorStatus = monitor.status;
let statusChanged = false;
if (monitor.status === "initializing" || monitor.status === "maintenance" || isUnknownStatus) {
newStatus = status ? "up" : "down";
2026-04-28 10:09:42 -07:00
patch.status = newStatus;
statusChanged = newStatus === "down";
}
2026-04-16 10:41:00 -07:00
let thresholdBreaches: HardwareBreaches | undefined;
2026-09-24 13:49:54 -07:00
if (projectedWindow.length >= monitor.statusWindowSize) {
// First evaluate reachability status changes, which apply to all monitor types
// and take precedence over hardware breaches.
const reachabilityResult = this.computeReachability(newStatus, projectedWindow, monitor.statusWindowThreshold);
if (reachabilityResult.transitioned) {
newStatus = reachabilityResult.nextStatus;
2026-04-16 10:41:00 -07:00
statusChanged = true;
}
2026-09-24 13:49:54 -07:00
// Evaluate hardware threshold breaches
if (monitor.type === "hardware") {
const hardware = this.computeHardwareStatus({
currentStatus: newStatus,
reachabilityDown: newStatus === "down",
metrics: check,
thresholds: {
cpu: monitor.cpuAlertThreshold,
memory: monitor.memoryAlertThreshold,
disk: monitor.diskAlertThreshold,
temp: monitor.tempAlertThreshold,
},
counters: {
cpu: monitor.cpuAlertCounter,
memory: monitor.memoryAlertCounter,
disk: monitor.diskAlertCounter,
temp: monitor.tempAlertCounter,
},
});
patch.cpuAlertCounter = hardware.nextCounters.cpu;
patch.memoryAlertCounter = hardware.nextCounters.memory;
patch.diskAlertCounter = hardware.nextCounters.disk;
patch.tempAlertCounter = hardware.nextCounters.temp;
thresholdBreaches = hardware.breaches;
if (hardware.transitioned) {
newStatus = hardware.nextStatus;
statusChanged = true;
}
2026-09-23 10:54:35 -07:00
}
2026-09-24 13:49:54 -07:00
if (monitor.type === "docker" && check.containers) {
const breaches = findContainerBreaches(monitor, check.containers);
const docker = this.computeDockerStatus({
currentStatus: newStatus,
reachabilityDown: newStatus === "down",
breaching: breaches.length > 0,
});
if (docker.transitioned) {
newStatus = docker.nextStatus;
statusChanged = true;
}
}
patch.status = newStatus;
}
2026-02-14 22:14:51 +00:00
// Single atomic write: push arrays + set status/counters
const updated = await this.monitorsRepository.updateStatusWindowAndChecks(
monitor.id,
monitor.teamId,
check.status,
checkSnapshot,
monitor.statusWindowSize,
MAX_RECENT_CHECKS,
patch
);
2025-05-19 12:28:18 -07:00
2025-04-20 11:29:53 -07:00
return {
2026-01-13 20:20:00 +00:00
monitor: updated,
statusChanged,
2025-04-20 11:29:53 -07:00
prevStatus,
2026-02-14 22:14:51 +00:00
thresholdBreaches,
2025-04-20 11:29:53 -07:00
};
2026-03-03 23:01:35 +00:00
} catch (error: unknown) {
throw new AppError({
2026-03-12 11:12:46 -07:00
message: `Failed to update monitor with id ${check.metadata.monitorId} with status: ${error instanceof Error ? error.message : "Unknown error"}`,
2026-03-03 23:01:35 +00:00
service: SERVICE_NAME,
method: "updateMonitorStatus",
});
2025-04-20 11:29:53 -07:00
}
};
}