mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
feat: move automations into a first-party plugin
This commit is contained in:
1 parent
a6f6a0439c
commit
e0f14f2ecf
47 files changed
+384
-125
No files matched your search
@@ -0,0 +1,203 @@
|
||||
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";
|
||||
}
|
||||
Reference in new issue
Block a user