Files
Termix/plugins/automations/backend/conditions.ts
T

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";
}