feat: finish the automations plugin (B17)

This commit is contained in:
LukeGus committed 2026-09-24 13:05:44 -05:00
1 parent 993a91c899
commit 6b4d1432ab
96 files changed
+25106 -5077

No files matched your search

-286
View File
@@ -1,286 +0,0 @@
/**
* Shared automation model used by both the backend engine and the editor UI.
* An automation is one trigger plus an ordered list of steps. The whole
* definition is stored as JSON on the automations row, so this file is the
* only contract describing that blob.
*/
export const AUTOMATION_DEFINITION_VERSION = 1;
/** Which hosts a trigger watches or a step acts on. */
export type HostSelector =
| { kind: "host"; hostId: number }
| { kind: "hosts"; hostIds: number[] }
| { kind: "fleet"; fleetId: number }
| { kind: "all" }
/** The host that produced the event. Only valid for host-scoped triggers. */
| { kind: "trigger" };
/**
* A metric to compare against. Paths mirror the shape returned by
* collectMetrics(). mount/iface pick one entry out of a per-instance list so a
* rule can watch a single filesystem rather than the aggregate.
*/
export type MetricPath =
| { path: "cpu.percent" }
| { path: "cpu.load1" }
| { path: "cpu.load5" }
| { path: "cpu.load15" }
| { path: "memory.percent" }
| { path: "memory.usedGiB" }
| { path: "disk.percent"; mount?: string }
| { path: "disk.availableBytes"; mount?: string }
| { path: "temperature.highestCelsius" }
| { path: "uptime.seconds" }
| { path: "processes.total" }
| { path: "network.rxBytes"; iface?: string }
| { path: "network.txBytes"; iface?: string }
| { path: "network.rxRateBps"; iface?: string }
| { path: "network.txRateBps"; iface?: string };
export type Operator =
| ">"
| "<"
| ">="
| "<="
| "=="
| "!="
| "contains"
| "not_contains"
| "changed";
export type Severity = "info" | "warning" | "critical";
export type HostStatusState = "online" | "offline";
export type HealthCheckState = "failing" | "recovered";
export type DockerEventKind = "exited" | "started" | "unhealthy" | "restarting";
export type InternalEventKind =
| "user_login"
| "host_added"
| "host_deleted"
| "tunnel_disconnected"
| "automation_failed";
export type Trigger =
| {
kind: "metric_threshold";
hostSelector: HostSelector;
metric: MetricPath;
operator: Operator;
value: number;
/** Sustained breach window before firing. 0 fires immediately. */
forSeconds?: number;
cooldownMinutes: number;
severity?: Severity;
}
| {
kind: "host_status";
hostSelector: HostSelector;
to: HostStatusState;
cooldownMinutes: number;
}
| {
kind: "health_check";
hostSelector: HostSelector;
checkId?: string;
to: HealthCheckState;
cooldownMinutes: number;
}
| {
kind: "schedule";
/** Five field cron. Ignored when intervalSeconds is set. */
cron?: string;
intervalSeconds?: number;
timezone?: string;
}
| {
kind: "docker_event";
hostSelector: HostSelector;
container?: string;
event: DockerEventKind;
cooldownMinutes: number;
}
| {
kind: "internal_event";
event: InternalEventKind;
hostSelector?: HostSelector;
cooldownMinutes: number;
}
| {
kind: "webhook";
/** Only the hash is persisted. The raw token is shown once on creation. */
tokenHash: string;
};
export type TriggerKind = Trigger["kind"];
/** A comparison used by `if` steps, evaluated against the run context. */
export interface Condition {
/** Template string, e.g. "{{steps.check.stdout}}" or "{{trigger.value}}". */
left: string;
operator: Operator;
/** Template string. Compared numerically when both sides parse as numbers. */
right?: string;
}
export type StepErrorPolicy = "stop" | "continue" | "branch";
interface StepBase {
/** Stable across edits so run history survives reordering. */
id: string;
name?: string;
enabled?: boolean;
onError?: StepErrorPolicy;
timeoutMs?: number;
}
export type Step =
| (StepBase & {
type: "notify";
channelIds: number[];
title?: string;
body?: string;
severity?: Severity;
})
| (StepBase & {
type: "http";
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
url: string;
headers?: Record<string, string>;
body?: string;
/** Opt in to LAN/private addresses. Off by default, see safe-outbound-fetch. */
allowPrivateNetwork?: boolean;
})
| (StepBase & {
type: "run_snippet";
snippetId: number;
hostSelector: HostSelector;
inputValues?: Record<string, string>;
elevated?: boolean;
})
| (StepBase & {
type: "run_command";
command: string;
hostSelector: HostSelector;
elevated?: boolean;
})
| (StepBase & {
type: "docker";
action: "start" | "stop" | "restart";
container: string;
hostSelector: HostSelector;
})
| (StepBase & {
type: "tunnel";
action: "connect" | "disconnect";
tunnelName: string;
})
| (StepBase & { type: "wol"; hostId: number })
| (StepBase & { type: "wait"; seconds: number })
| (StepBase & { type: "set_var"; name: string; value: string })
| (StepBase & {
type: "if";
condition: Condition;
then: Step[];
else?: Step[];
})
| (StepBase & { type: "run_automation"; automationId: number })
| (StepBase & { type: "stop"; status?: "success" | "failed" });
export type StepType = Step["type"];
export interface AutomationDefinition {
version: number;
trigger: Trigger;
steps: Step[];
}
export type ConcurrencyPolicy = "skip" | "queue" | "allow";
export type RunStatus =
"running" | "success" | "failed" | "timeout" | "skipped" | "cancelled";
export type StepStatus =
"pending" | "running" | "success" | "failed" | "skipped";
export interface Automation {
id: number;
userId: string;
name: string;
description: string | null;
enabled: boolean;
definition: AutomationDefinition;
concurrencyPolicy: ConcurrencyPolicy;
maxRunSeconds: number;
dryRun: boolean;
lastRunAt: string | null;
lastRunStatus: RunStatus | null;
createdAt: string;
updatedAt: string;
}
export interface AutomationRun {
id: number;
automationId: number;
userId: string;
triggerType: TriggerKind | "manual";
triggerContext: Record<string, unknown> | null;
status: RunStatus;
startedAt: string;
finishedAt: string | null;
durationMs: number | null;
error: string | null;
dryRun: boolean;
parentRunId: number | null;
}
export interface AutomationRunStep {
id: number;
runId: number;
stepIndex: number;
stepId: string;
stepType: StepType;
status: StepStatus;
startedAt: string;
finishedAt: string | null;
output: string | null;
error: string | null;
truncated: boolean;
}
/** Defaults applied when an automation does not override them. */
export const DEFAULT_MAX_RUN_SECONDS = 300;
export const DEFAULT_STEP_TIMEOUT_MS = 60_000;
export const DEFAULT_COOLDOWN_MINUTES = 15;
/** Guards run_automation against direct and mutual recursion. */
export const MAX_AUTOMATION_DEPTH = 5;
/** Step output beyond this is truncated before it reaches the database. */
export const MAX_STEP_OUTPUT_BYTES = 32_768;
export const OPERATOR_LABELS: Record<Operator, string> = {
">": "greater than",
"<": "less than",
">=": "greater than or equal to",
"<=": "less than or equal to",
"==": "equals",
"!=": "does not equal",
contains: "contains",
not_contains: "does not contain",
changed: "changed",
};
/** Steps that reach outside Termix and are therefore stubbed in a dry run. */
export const SIDE_EFFECTING_STEP_TYPES: readonly StepType[] = [
"notify",
"http",
"run_snippet",
"run_command",
"docker",
"tunnel",
"wol",
];
export function isSideEffectingStep(type: StepType): boolean {
return SIDE_EFFECTING_STEP_TYPES.includes(type);
}