mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
204 lines
6.7 KiB
TypeScript
204 lines
6.7 KiB
TypeScript
import type { MetricPath, Operator } from "../../../src/types/automations.js";
|
|
|
|
/**
|
|
* Operator evaluation and metric extraction.
|
|
*
|
|
* Pure: no database, no SSH, no clock. Everything here is driven by values the
|
|
* caller already has, which keeps the comparison rules testable on their own.
|
|
*/
|
|
|
|
/** Shape of the metrics snapshot this module reads. Mirrors collectMetrics(). */
|
|
export interface MetricsSnapshot {
|
|
cpu?: {
|
|
percent?: number | null;
|
|
load?: [number, number, number] | null;
|
|
} | null;
|
|
memory?: { percent?: number | null; usedGiB?: number | null } | null;
|
|
disk?: {
|
|
percent?: number | null;
|
|
filesystems?: Array<{
|
|
mount?: string;
|
|
percent?: number | null;
|
|
availableBytes?: number | null;
|
|
}> | null;
|
|
} | null;
|
|
network?: {
|
|
interfaces?: Array<{
|
|
name?: string;
|
|
rxBytes?: string | number | null;
|
|
txBytes?: string | number | null;
|
|
rxRateBps?: number | null;
|
|
txRateBps?: number | null;
|
|
}> | null;
|
|
} | null;
|
|
temperature?: { highestCelsius?: number | null } | null;
|
|
uptime?: { seconds?: number | null } | null;
|
|
processes?: { total?: number | null } | null;
|
|
}
|
|
|
|
function toNumber(value: unknown): number | null {
|
|
if (value === null || value === undefined || value === "") return null;
|
|
const parsed = typeof value === "number" ? value : Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
/**
|
|
* Pulls the value a trigger watches out of a metrics snapshot.
|
|
*
|
|
* The mount and iface selectors are what let a rule watch one filesystem or
|
|
* one interface. Without a selector the aggregate is used, which for disk is
|
|
* the primary (root) mount, matching what the metrics UI shows.
|
|
*/
|
|
export function extractMetricValue(
|
|
metrics: MetricsSnapshot | null | undefined,
|
|
metric: MetricPath,
|
|
): number | null {
|
|
if (!metrics) return null;
|
|
|
|
switch (metric.path) {
|
|
case "cpu.percent":
|
|
return toNumber(metrics.cpu?.percent);
|
|
case "cpu.load1":
|
|
return toNumber(metrics.cpu?.load?.[0]);
|
|
case "cpu.load5":
|
|
return toNumber(metrics.cpu?.load?.[1]);
|
|
case "cpu.load15":
|
|
return toNumber(metrics.cpu?.load?.[2]);
|
|
case "memory.percent":
|
|
return toNumber(metrics.memory?.percent);
|
|
case "memory.usedGiB":
|
|
return toNumber(metrics.memory?.usedGiB);
|
|
case "disk.percent": {
|
|
if (!metric.mount) return toNumber(metrics.disk?.percent);
|
|
const fs = findMount(metrics, metric.mount);
|
|
return fs ? toNumber(fs.percent) : null;
|
|
}
|
|
case "disk.availableBytes": {
|
|
const fs = metric.mount ? findMount(metrics, metric.mount) : null;
|
|
if (metric.mount) return fs ? toNumber(fs.availableBytes) : null;
|
|
const first = metrics.disk?.filesystems?.[0];
|
|
return first ? toNumber(first.availableBytes) : null;
|
|
}
|
|
case "temperature.highestCelsius":
|
|
return toNumber(metrics.temperature?.highestCelsius);
|
|
case "uptime.seconds":
|
|
return toNumber(metrics.uptime?.seconds);
|
|
case "processes.total":
|
|
return toNumber(metrics.processes?.total);
|
|
case "network.rxBytes":
|
|
return toNumber(findInterface(metrics, metric.iface)?.rxBytes);
|
|
case "network.txBytes":
|
|
return toNumber(findInterface(metrics, metric.iface)?.txBytes);
|
|
case "network.rxRateBps":
|
|
return toNumber(findInterface(metrics, metric.iface)?.rxRateBps);
|
|
case "network.txRateBps":
|
|
return toNumber(findInterface(metrics, metric.iface)?.txRateBps);
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function findMount(metrics: MetricsSnapshot, mount: string) {
|
|
return (
|
|
metrics.disk?.filesystems?.find((entry) => entry.mount === mount) ?? null
|
|
);
|
|
}
|
|
|
|
function findInterface(metrics: MetricsSnapshot, iface?: string) {
|
|
const interfaces = metrics.network?.interfaces;
|
|
if (!interfaces || interfaces.length === 0) return null;
|
|
if (!iface) return interfaces[0];
|
|
return interfaces.find((entry) => entry.name === iface) ?? null;
|
|
}
|
|
|
|
/**
|
|
* The key a trigger's durable state is stored under. Including the mount or
|
|
* container is what allows a sustained-breach window per filesystem rather
|
|
* than per host.
|
|
*/
|
|
export function metricStateKey(hostId: number, metric: MetricPath): string {
|
|
if ("mount" in metric && metric.mount) return `${hostId}:${metric.mount}`;
|
|
if ("iface" in metric && metric.iface) return `${hostId}:${metric.iface}`;
|
|
return String(hostId);
|
|
}
|
|
|
|
/**
|
|
* Compares two values. Numeric when both sides look numeric, so "90" and 90
|
|
* behave the same; string comparison otherwise. `changed` is handled by the
|
|
* caller, which is the only place that knows the previous value.
|
|
*/
|
|
export function compare(
|
|
left: unknown,
|
|
operator: Operator,
|
|
right: unknown,
|
|
): boolean {
|
|
if (operator === "contains" || operator === "not_contains") {
|
|
const haystack = String(left ?? "");
|
|
const needle = String(right ?? "");
|
|
const found = haystack.includes(needle);
|
|
return operator === "contains" ? found : !found;
|
|
}
|
|
|
|
const leftNumber = toNumber(left);
|
|
const rightNumber = toNumber(right);
|
|
const numeric = leftNumber !== null && rightNumber !== null;
|
|
|
|
switch (operator) {
|
|
case ">":
|
|
return numeric && leftNumber > rightNumber;
|
|
case "<":
|
|
return numeric && leftNumber < rightNumber;
|
|
case ">=":
|
|
return numeric && leftNumber >= rightNumber;
|
|
case "<=":
|
|
return numeric && leftNumber <= rightNumber;
|
|
case "==":
|
|
return numeric
|
|
? leftNumber === rightNumber
|
|
: String(left ?? "") === String(right ?? "");
|
|
case "!=":
|
|
return numeric
|
|
? leftNumber !== rightNumber
|
|
: String(left ?? "") !== String(right ?? "");
|
|
case "changed":
|
|
return String(left ?? "") !== String(right ?? "");
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Whether a cooldown window is still open. */
|
|
export function isCoolingDown(
|
|
lastFiredAt: string | null | undefined,
|
|
cooldownMinutes: number,
|
|
now: number = Date.now(),
|
|
): boolean {
|
|
if (!lastFiredAt) return false;
|
|
const last = Date.parse(lastFiredAt);
|
|
if (Number.isNaN(last)) return false;
|
|
return now - last < Math.max(cooldownMinutes, 0) * 60_000;
|
|
}
|
|
|
|
/** Whether a sustained breach has been held long enough to fire. */
|
|
export function hasDwelled(
|
|
breachStartedAt: string | null | undefined,
|
|
forSeconds: number | undefined,
|
|
now: number = Date.now(),
|
|
): boolean {
|
|
if (!forSeconds) return true;
|
|
if (!breachStartedAt) return false;
|
|
const started = Date.parse(breachStartedAt);
|
|
if (Number.isNaN(started)) return false;
|
|
return now - started >= forSeconds * 1000;
|
|
}
|
|
|
|
/** Severity for a threshold breach, matching the old engine's behaviour. */
|
|
export function severityForValue(
|
|
value: number | null,
|
|
explicit?: "info" | "warning" | "critical",
|
|
): "info" | "warning" | "critical" {
|
|
if (explicit) return explicit;
|
|
if (value !== null && value >= 95) return "critical";
|
|
return "warning";
|
|
}
|