mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +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
@@ -1,90 +0,0 @@
|
||||
import type { HostSelector } from "../../../types/automations.js";
|
||||
import { resolveHostById } from "../../hosts/host-resolver.js";
|
||||
import { createCurrentFleetRepository } from "../../database/repositories/factory.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import type { StepExecutionContext } from "./types.js";
|
||||
|
||||
/** A host the caller is allowed to act on. `host` is always resolved. */
|
||||
export interface ResolvedTarget {
|
||||
id: number;
|
||||
name: string;
|
||||
host: NonNullable<Awaited<ReturnType<typeof resolveHostById>>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a selector into the hosts a step may actually act on.
|
||||
*
|
||||
* Access is checked here, at execution time rather than when the automation
|
||||
* was saved, so a permission revoked after the fact takes effect on the next
|
||||
* run. resolveHostById performs its own connect-level check and returns null
|
||||
* when the owner can no longer reach the host.
|
||||
*/
|
||||
export async function resolveTargets(
|
||||
selector: HostSelector,
|
||||
context: StepExecutionContext,
|
||||
): Promise<{ targets: ResolvedTarget[]; skipped: number[] }> {
|
||||
const ids = await selectorHostIds(selector, context);
|
||||
const targets: ResolvedTarget[] = [];
|
||||
const skipped: number[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
const host = await resolveHostById(id, context.userId);
|
||||
if (!host) {
|
||||
skipped.push(id);
|
||||
continue;
|
||||
}
|
||||
targets.push({ id, name: host.name || host.ip, host });
|
||||
}
|
||||
|
||||
return { targets, skipped };
|
||||
}
|
||||
|
||||
async function selectorHostIds(
|
||||
selector: HostSelector,
|
||||
context: StepExecutionContext,
|
||||
): Promise<number[]> {
|
||||
switch (selector.kind) {
|
||||
case "host":
|
||||
return [selector.hostId];
|
||||
case "hosts":
|
||||
return selector.hostIds;
|
||||
case "trigger":
|
||||
return context.triggerHostId ? [context.triggerHostId] : [];
|
||||
case "fleet":
|
||||
return fleetHostIds(selector.fleetId, context.userId);
|
||||
case "all":
|
||||
return allAccessibleHostIds(context.userId);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fleetHostIds(
|
||||
fleetId: number,
|
||||
userId: string,
|
||||
): Promise<number[]> {
|
||||
try {
|
||||
const repository = createCurrentFleetRepository();
|
||||
const members = await repository.listEffectiveMembers(userId, fleetId);
|
||||
return members.map((member) => member.id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function allAccessibleHostIds(userId: string): Promise<number[]> {
|
||||
try {
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../../database/repositories/factory.js");
|
||||
const hosts = await createCurrentHostRepository().listByUserId(userId);
|
||||
const ids = hosts.map((host) => host.id);
|
||||
const allowed =
|
||||
await PermissionManager.getInstance().filterAccessibleHostIds(
|
||||
userId,
|
||||
ids,
|
||||
);
|
||||
return ids.filter((id) => allowed.has(id));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
import {
|
||||
DEFAULT_STEP_TIMEOUT_MS,
|
||||
type Step,
|
||||
} from "../../../types/automations.js";
|
||||
import { execCommand } from "../../hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
execElevated,
|
||||
shellSingleQuote,
|
||||
} from "../../hosts/metrics-shared/exec-elevated.js";
|
||||
import {
|
||||
createFleetSshFactory,
|
||||
getFleetPoolKey,
|
||||
} from "../../hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../hosts/ssh-connection-pool.js";
|
||||
import { resolveSnippetCommand } from "../../database/routes/snippets-execution.js";
|
||||
import {
|
||||
createCurrentAlertRepository,
|
||||
createCurrentSnippetRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { sendAutomationNotification } from "../notify.js";
|
||||
import { automationFetch } from "../http.js";
|
||||
import { renderRecord, renderTemplate } from "../template.js";
|
||||
import { resolveTargets, type ResolvedTarget } from "./host-targets.js";
|
||||
import {
|
||||
fail,
|
||||
ok,
|
||||
stepTimeout,
|
||||
type StepExecutionContext,
|
||||
type StepResult,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* One executor per step type.
|
||||
*
|
||||
* Anything that leaves Termix checks context.dryRun first and reports what it
|
||||
* would have done instead of doing it, so an automation can be exercised
|
||||
* safely while it is being built.
|
||||
*/
|
||||
export async function executeStep(
|
||||
step: Step,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
switch (step.type) {
|
||||
case "notify":
|
||||
return runNotify(step, context);
|
||||
case "http":
|
||||
return runHttp(step, context);
|
||||
case "run_command":
|
||||
return runCommand(step, context);
|
||||
case "run_snippet":
|
||||
return runSnippet(step, context);
|
||||
case "docker":
|
||||
return runDocker(step, context);
|
||||
case "tunnel":
|
||||
return runTunnel(step, context);
|
||||
case "wol":
|
||||
return runWol(step, context);
|
||||
case "wait":
|
||||
return runWait(step, context);
|
||||
case "set_var":
|
||||
return runSetVar(step, context);
|
||||
case "stop":
|
||||
return {
|
||||
success: true,
|
||||
halt: { status: step.status ?? "success" },
|
||||
output: `Stopped with status ${step.status ?? "success"}`,
|
||||
};
|
||||
default:
|
||||
return fail(`Unsupported step type: ${(step as Step).type}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runNotify(
|
||||
step: Extract<Step, { type: "notify" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const title = renderTemplate(step.title ?? "", context.template);
|
||||
const body = renderTemplate(step.body ?? "", context.template);
|
||||
|
||||
if (step.channelIds.length === 0) {
|
||||
return fail("No notification channels selected");
|
||||
}
|
||||
if (context.dryRun) {
|
||||
return ok(
|
||||
`Would notify ${step.channelIds.length} channel(s): ${title || body}`,
|
||||
);
|
||||
}
|
||||
|
||||
const repository = createCurrentAlertRepository();
|
||||
const channels = await repository.listNotificationChannels(context.userId);
|
||||
const selected = channels.filter((channel) =>
|
||||
step.channelIds.includes(channel.id),
|
||||
);
|
||||
|
||||
if (selected.length === 0) {
|
||||
return fail("Selected notification channels no longer exist");
|
||||
}
|
||||
|
||||
let delivered = 0;
|
||||
const errors: string[] = [];
|
||||
for (const channel of selected) {
|
||||
if (!channel.enabled) continue;
|
||||
try {
|
||||
await sendAutomationNotification(
|
||||
{ id: channel.id, type: channel.type, config: channel.config },
|
||||
{
|
||||
title,
|
||||
body,
|
||||
severity: step.severity ?? "warning",
|
||||
context: context.template,
|
||||
},
|
||||
);
|
||||
delivered++;
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`${channel.name}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (delivered === 0) {
|
||||
return fail(errors.join("; ") || "No enabled channels to notify");
|
||||
}
|
||||
return ok(
|
||||
`Notified ${delivered} channel(s)${errors.length ? `; ${errors.join("; ")}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runHttp(
|
||||
step: Extract<Step, { type: "http" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const url = renderTemplate(step.url, context.template);
|
||||
const headers = renderRecord(step.headers, context.template);
|
||||
const body = step.body
|
||||
? renderTemplate(step.body, context.template)
|
||||
: undefined;
|
||||
|
||||
if (context.dryRun) {
|
||||
return ok(`Would ${step.method} ${url}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await automationFetch(url, {
|
||||
method: step.method,
|
||||
headers,
|
||||
body,
|
||||
allowPrivateNetwork: step.allowPrivateNetwork,
|
||||
timeoutMs: stepTimeout(context, step.timeoutMs, DEFAULT_STEP_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const summary = `HTTP ${response.status} ${response.statusText}\n${text}`;
|
||||
return response.ok ? ok(summary) : fail(`HTTP ${response.status}`, summary);
|
||||
} catch (error) {
|
||||
return fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommand(
|
||||
step: Extract<Step, { type: "run_command" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const command = renderTemplate(step.command, context.template);
|
||||
return runOnTargets(step.hostSelector, context, async (target) => {
|
||||
if (context.dryRun) {
|
||||
return { output: `Would run on ${target.name}: ${command}` };
|
||||
}
|
||||
return execOnHost(
|
||||
target.host,
|
||||
command,
|
||||
step.elevated,
|
||||
context,
|
||||
step.timeoutMs,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function runSnippet(
|
||||
step: Extract<Step, { type: "run_snippet" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const snippet = await createCurrentSnippetRepository()
|
||||
.findOwnedById(context.userId, step.snippetId)
|
||||
.catch(() => null);
|
||||
|
||||
if (!snippet) return fail("Snippet not found");
|
||||
if (snippet.isNote) return fail("Notes cannot be executed on a host");
|
||||
|
||||
// Template values only ever reach the snippet through inputValues, never by
|
||||
// rewriting the snippet body, so automation variables cannot inject snippet
|
||||
// syntax of their own.
|
||||
const inputValues = renderRecord(step.inputValues, context.template) ?? {};
|
||||
|
||||
return runOnTargets(step.hostSelector, context, async (target) => {
|
||||
const command = resolveSnippetCommand(
|
||||
snippet.content,
|
||||
{
|
||||
ip: target.host.ip,
|
||||
username: target.host.username,
|
||||
port: target.host.port,
|
||||
name: target.host.name,
|
||||
},
|
||||
inputValues,
|
||||
);
|
||||
|
||||
if (context.dryRun) {
|
||||
return { output: `Would run snippet on ${target.name}: ${command}` };
|
||||
}
|
||||
return execOnHost(
|
||||
target.host,
|
||||
command,
|
||||
step.elevated,
|
||||
context,
|
||||
step.timeoutMs,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function runDocker(
|
||||
step: Extract<Step, { type: "docker" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const container = renderTemplate(step.container, context.template);
|
||||
if (!container) return fail("Container name is required");
|
||||
|
||||
return runOnTargets(step.hostSelector, context, async (target) => {
|
||||
if (context.dryRun) {
|
||||
return {
|
||||
output: `Would ${step.action} container ${container} on ${target.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
// The Docker HTTP routes are tied to an interactive session, so run the
|
||||
// equivalent command over the same pooled SSH connection everything else
|
||||
// uses. The container name is quoted because it comes from a template.
|
||||
const command = `docker ${step.action} ${shellSingleQuote(container)}`;
|
||||
return execOnHost(target.host, command, false, context, step.timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function runTunnel(
|
||||
step: Extract<Step, { type: "tunnel" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const name = renderTemplate(step.tunnelName, context.template);
|
||||
if (context.dryRun) return ok(`Would ${step.action} tunnel ${name}`);
|
||||
|
||||
try {
|
||||
const manager = await import("../../hosts/tunnel/manager.js");
|
||||
const config = manager.tunnelConfigs?.get(name);
|
||||
if (!config) return fail(`Tunnel "${name}" is not configured`);
|
||||
|
||||
if (step.action === "connect") {
|
||||
await manager.connectSSHTunnel(config);
|
||||
return ok(`Tunnel ${name} connected`);
|
||||
}
|
||||
|
||||
// shouldRetry false, otherwise the manager immediately reconnects the
|
||||
// tunnel the automation just asked it to drop.
|
||||
manager.manualDisconnects.add(name);
|
||||
await manager.handleDisconnect(name, config, false);
|
||||
return ok(`Tunnel ${name} disconnected`);
|
||||
} catch (error) {
|
||||
return fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function runWol(
|
||||
step: Extract<Step, { type: "wol" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const host = await resolveTargets(
|
||||
{ kind: "host", hostId: step.hostId },
|
||||
context,
|
||||
);
|
||||
const target = host.targets[0];
|
||||
if (!target?.host) return fail("Host not found or not accessible");
|
||||
|
||||
const mac = (target.host as { macAddress?: string }).macAddress;
|
||||
if (!mac) return fail("Host has no MAC address configured");
|
||||
if (context.dryRun) return ok(`Would wake ${target.name} (${mac})`);
|
||||
|
||||
try {
|
||||
const { sendWakeOnLan, isValidMac } =
|
||||
await import("../../utils/wake-on-lan.js");
|
||||
if (!isValidMac(mac)) return fail(`Invalid MAC address: ${mac}`);
|
||||
await sendWakeOnLan(mac);
|
||||
return ok(`Sent magic packet to ${target.name}`);
|
||||
} catch (error) {
|
||||
return fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function runWait(
|
||||
step: Extract<Step, { type: "wait" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const requested = Math.max(step.seconds, 0) * 1000;
|
||||
const waitMs = Math.min(
|
||||
requested,
|
||||
stepTimeout(context, undefined, requested),
|
||||
);
|
||||
if (context.dryRun) return ok(`Would wait ${step.seconds}s`);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
return ok(`Waited ${Math.round(waitMs / 1000)}s`);
|
||||
}
|
||||
|
||||
async function runSetVar(
|
||||
step: Extract<Step, { type: "set_var" }>,
|
||||
context: StepExecutionContext,
|
||||
): Promise<StepResult> {
|
||||
const value = renderTemplate(step.value, context.template);
|
||||
return ok(`${step.name} = ${value}`, { [step.name]: value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a per-host action across a selector's targets. One host failing does
|
||||
* not stop the others, matching how fleet execution behaves.
|
||||
*/
|
||||
async function runOnTargets(
|
||||
selector: Extract<Step, { type: "run_command" }>["hostSelector"],
|
||||
context: StepExecutionContext,
|
||||
run: (target: ResolvedTarget) => Promise<{ output?: string; error?: string }>,
|
||||
): Promise<StepResult> {
|
||||
const { targets, skipped } = await resolveTargets(selector, context);
|
||||
|
||||
if (targets.length === 0) {
|
||||
return fail(
|
||||
skipped.length > 0
|
||||
? `No accessible hosts (${skipped.length} skipped)`
|
||||
: "No hosts matched the selector",
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
targets.map(async (target) => ({
|
||||
target,
|
||||
result: await run(target),
|
||||
})),
|
||||
);
|
||||
|
||||
const lines: string[] = [];
|
||||
let failures = 0;
|
||||
|
||||
for (const [index, settled] of results.entries()) {
|
||||
const name = targets[index].name;
|
||||
if (settled.status === "rejected") {
|
||||
failures++;
|
||||
lines.push(`${name}: ${String(settled.reason)}`);
|
||||
continue;
|
||||
}
|
||||
const { result } = settled.value;
|
||||
if (result.error) {
|
||||
failures++;
|
||||
lines.push(`${name}: ${result.error}`);
|
||||
} else {
|
||||
lines.push(`${name}: ${result.output ?? "ok"}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped.length > 0) {
|
||||
lines.push(`${skipped.length} host(s) skipped: no access`);
|
||||
}
|
||||
|
||||
const output = lines.join("\n");
|
||||
return failures > 0 && failures === targets.length
|
||||
? fail(`All ${failures} host(s) failed`, output)
|
||||
: ok(output);
|
||||
}
|
||||
|
||||
async function execOnHost(
|
||||
host: ResolvedTarget["host"],
|
||||
command: string,
|
||||
elevated: boolean | undefined,
|
||||
context: StepExecutionContext,
|
||||
timeoutMs: number | undefined,
|
||||
): Promise<{ output?: string; error?: string }> {
|
||||
const sshHost = host as ResolvedTarget["host"] & { sudoPassword?: string };
|
||||
const timeout = stepTimeout(context, timeoutMs, DEFAULT_STEP_TIMEOUT_MS);
|
||||
if (timeout <= 0) return { error: "Run deadline exceeded" };
|
||||
|
||||
try {
|
||||
const result = await withConnection(
|
||||
getFleetPoolKey(sshHost),
|
||||
createFleetSshFactory(sshHost),
|
||||
async (client) => {
|
||||
if (elevated) {
|
||||
return execElevated(client, command, sshHost.sudoPassword, {
|
||||
timeoutMs: timeout,
|
||||
});
|
||||
}
|
||||
return execCommand(client, command, timeout);
|
||||
},
|
||||
);
|
||||
|
||||
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
||||
if (result.code === 0 || result.code === null) {
|
||||
return { output: output || "(no output)" };
|
||||
}
|
||||
return { error: `Exited with code ${result.code}`, output };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { Step } from "../../../types/automations.js";
|
||||
import type { TemplateContext } from "../template.js";
|
||||
|
||||
/** What every executor returns. `output` is what later steps can read. */
|
||||
export interface StepResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
/** Merged into the run's variables, for set_var and friends. */
|
||||
vars?: Record<string, string>;
|
||||
/** Set by the stop step to end the run early. */
|
||||
halt?: { status: "success" | "failed" };
|
||||
}
|
||||
|
||||
export interface StepExecutionContext {
|
||||
userId: string;
|
||||
automationId: number;
|
||||
runId: number;
|
||||
/** Nothing that leaves Termix may actually happen when this is set. */
|
||||
dryRun: boolean;
|
||||
template: TemplateContext;
|
||||
/** Host the trigger fired for, when there was one. */
|
||||
triggerHostId?: number;
|
||||
/** Automations already on the stack, to refuse recursion. */
|
||||
ancestry: number[];
|
||||
depth: number;
|
||||
/** Wall-clock deadline for the whole run. */
|
||||
deadlineAt: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type StepExecutor<T extends Step = Step> = (
|
||||
step: T,
|
||||
context: StepExecutionContext,
|
||||
) => Promise<StepResult>;
|
||||
|
||||
export function ok(output?: string, vars?: Record<string, string>): StepResult {
|
||||
return { success: true, output, vars };
|
||||
}
|
||||
|
||||
export function fail(error: string, output?: string): StepResult {
|
||||
return { success: false, error, output };
|
||||
}
|
||||
|
||||
/** Milliseconds left before the run's overall deadline. */
|
||||
export function remainingMs(context: StepExecutionContext): number {
|
||||
return Math.max(context.deadlineAt - Date.now(), 0);
|
||||
}
|
||||
|
||||
/** A step's timeout, clamped so it can never outlive the run. */
|
||||
export function stepTimeout(
|
||||
context: StepExecutionContext,
|
||||
requested: number | undefined,
|
||||
fallback: number,
|
||||
): number {
|
||||
const wanted = requested && requested > 0 ? requested : fallback;
|
||||
return Math.max(Math.min(wanted, remainingMs(context)), 0);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import type { MetricPath, Operator } from "../../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";
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
/**
|
||||
* A small five field cron parser, used only to work out when a schedule is
|
||||
* next due.
|
||||
*
|
||||
* Deliberately not a dependency: the engine needs "when is this next due?" and
|
||||
* nothing else, and a pure function is far easier to test than a scheduler
|
||||
* library. Fields are the standard minute, hour, day-of-month, month and
|
||||
* day-of-week, supporting *, lists (1,2), ranges (1-5) and steps (a slash).
|
||||
*
|
||||
* Day-of-month and day-of-week follow cron's union rule: when both are
|
||||
* restricted, a date matching either one matches.
|
||||
*/
|
||||
|
||||
interface CronFields {
|
||||
minutes: Set<number>;
|
||||
hours: Set<number>;
|
||||
daysOfMonth: Set<number>;
|
||||
months: Set<number>;
|
||||
daysOfWeek: Set<number>;
|
||||
domRestricted: boolean;
|
||||
dowRestricted: boolean;
|
||||
}
|
||||
|
||||
const RANGES: Record<string, [number, number]> = {
|
||||
minute: [0, 59],
|
||||
hour: [0, 23],
|
||||
dayOfMonth: [1, 31],
|
||||
month: [1, 12],
|
||||
// 7 is accepted as an alias for Sunday and folded to 0 once parsed.
|
||||
dayOfWeek: [0, 7],
|
||||
};
|
||||
|
||||
const NAMED_MONTHS: Record<string, number> = {
|
||||
jan: 1,
|
||||
feb: 2,
|
||||
mar: 3,
|
||||
apr: 4,
|
||||
may: 5,
|
||||
jun: 6,
|
||||
jul: 7,
|
||||
aug: 8,
|
||||
sep: 9,
|
||||
oct: 10,
|
||||
nov: 11,
|
||||
dec: 12,
|
||||
};
|
||||
|
||||
const NAMED_DAYS: Record<string, number> = {
|
||||
sun: 0,
|
||||
mon: 1,
|
||||
tue: 2,
|
||||
wed: 3,
|
||||
thu: 4,
|
||||
fri: 5,
|
||||
sat: 6,
|
||||
};
|
||||
|
||||
function normalize(token: string, kind: string): string {
|
||||
const lower = token.toLowerCase();
|
||||
if (kind === "month" && lower in NAMED_MONTHS) {
|
||||
return String(NAMED_MONTHS[lower]);
|
||||
}
|
||||
if (kind === "dayOfWeek" && lower in NAMED_DAYS) {
|
||||
return String(NAMED_DAYS[lower]);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function parseField(field: string, kind: keyof typeof RANGES): Set<number> {
|
||||
const [min, max] = RANGES[kind];
|
||||
const values = new Set<number>();
|
||||
|
||||
for (const part of field.split(",")) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) throw new Error(`Empty value in ${kind} field`);
|
||||
|
||||
const [rangePart, stepPart] = trimmed.split("/");
|
||||
const step = stepPart === undefined ? 1 : Number(stepPart);
|
||||
if (!Number.isInteger(step) || step < 1) {
|
||||
throw new Error(`Invalid step in ${kind} field`);
|
||||
}
|
||||
|
||||
let start: number;
|
||||
let end: number;
|
||||
|
||||
if (rangePart === "*" || rangePart === "") {
|
||||
start = min;
|
||||
end = max;
|
||||
} else if (rangePart.includes("-")) {
|
||||
const [from, to] = rangePart.split("-");
|
||||
start = Number(normalize(from, kind));
|
||||
end = Number(normalize(to, kind));
|
||||
} else {
|
||||
start = Number(normalize(rangePart, kind));
|
||||
end = stepPart === undefined ? start : max;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end)) {
|
||||
throw new Error(`Invalid value in ${kind} field`);
|
||||
}
|
||||
if (start < min || end > max || start > end) {
|
||||
throw new Error(`Value out of range in ${kind} field`);
|
||||
}
|
||||
|
||||
for (let value = start; value <= end; value += step) {
|
||||
// Sunday can be written as 7; cron treats it as 0.
|
||||
values.add(kind === "dayOfWeek" && value === 7 ? 0 : value);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
export function parseCron(expression: string): CronFields {
|
||||
const fields = expression.trim().split(/\s+/);
|
||||
if (fields.length !== 5) {
|
||||
throw new Error("A cron expression needs five fields");
|
||||
}
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = fields;
|
||||
return {
|
||||
minutes: parseField(minute, "minute"),
|
||||
hours: parseField(hour, "hour"),
|
||||
daysOfMonth: parseField(dayOfMonth, "dayOfMonth"),
|
||||
months: parseField(month, "month"),
|
||||
daysOfWeek: parseField(dayOfWeek, "dayOfWeek"),
|
||||
domRestricted: dayOfMonth.trim() !== "*",
|
||||
dowRestricted: dayOfWeek.trim() !== "*",
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidCron(expression: string): boolean {
|
||||
try {
|
||||
parseCron(expression);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Wall-clock fields for a moment, in a given zone or server local time. */
|
||||
interface WallClock {
|
||||
minute: number;
|
||||
hour: number;
|
||||
dayOfMonth: number;
|
||||
month: number;
|
||||
dayOfWeek: number;
|
||||
}
|
||||
|
||||
const WEEKDAYS: Record<string, number> = {
|
||||
Sun: 0,
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
};
|
||||
|
||||
const formatterCache = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
/**
|
||||
* Whether a zone name is one this runtime actually knows. An unknown zone
|
||||
* falls back to server local time rather than throwing, so a bad value saved
|
||||
* against a schedule cannot stop it from ever running.
|
||||
*/
|
||||
export function isValidTimezone(timezone: string): boolean {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: timezone });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatterFor(timezone: string): Intl.DateTimeFormat | null {
|
||||
const cached = formatterCache.get(timezone);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
hour12: false,
|
||||
weekday: "short",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
});
|
||||
formatterCache.set(timezone, formatter);
|
||||
return formatter;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function wallClock(date: Date, timezone?: string | null): WallClock {
|
||||
const formatter = timezone ? formatterFor(timezone) : null;
|
||||
if (!formatter) {
|
||||
return {
|
||||
minute: date.getMinutes(),
|
||||
hour: date.getHours(),
|
||||
dayOfMonth: date.getDate(),
|
||||
month: date.getMonth() + 1,
|
||||
dayOfWeek: date.getDay(),
|
||||
};
|
||||
}
|
||||
|
||||
const parts: Record<string, string> = {};
|
||||
for (const part of formatter.formatToParts(date)) {
|
||||
parts[part.type] = part.value;
|
||||
}
|
||||
|
||||
return {
|
||||
// Midnight formats as 24 in some locales' hour-cycle handling.
|
||||
minute: Number(parts.minute),
|
||||
hour: Number(parts.hour) % 24,
|
||||
dayOfMonth: Number(parts.day),
|
||||
month: Number(parts.month),
|
||||
dayOfWeek: WEEKDAYS[parts.weekday] ?? date.getDay(),
|
||||
};
|
||||
}
|
||||
|
||||
function matches(
|
||||
fields: CronFields,
|
||||
date: Date,
|
||||
timezone?: string | null,
|
||||
): boolean {
|
||||
const clock = wallClock(date, timezone);
|
||||
if (!fields.months.has(clock.month)) return false;
|
||||
if (!fields.minutes.has(clock.minute)) return false;
|
||||
if (!fields.hours.has(clock.hour)) return false;
|
||||
|
||||
const domMatch = fields.daysOfMonth.has(clock.dayOfMonth);
|
||||
const dowMatch = fields.daysOfWeek.has(clock.dayOfWeek);
|
||||
|
||||
// Both restricted means either may match, which is how cron behaves.
|
||||
if (fields.domRestricted && fields.dowRestricted) return domMatch || dowMatch;
|
||||
if (fields.domRestricted) return domMatch;
|
||||
if (fields.dowRestricted) return dowMatch;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The next time on or after `from` that the expression matches, or null when
|
||||
* nothing matches within a four year window (e.g. Feb 30).
|
||||
*/
|
||||
export function nextCronRun(
|
||||
expression: string,
|
||||
from: Date = new Date(),
|
||||
timezone?: string | null,
|
||||
): Date | null {
|
||||
const fields = parseCron(expression);
|
||||
|
||||
const candidate = new Date(from.getTime());
|
||||
candidate.setSeconds(0, 0);
|
||||
candidate.setMinutes(candidate.getMinutes() + 1);
|
||||
|
||||
// Four years covers every leap year cycle, so a date that never matches
|
||||
// gives up rather than looping.
|
||||
const limit = 366 * 4 * 24 * 60;
|
||||
for (let i = 0; i < limit; i++) {
|
||||
if (matches(fields, candidate, timezone)) return candidate;
|
||||
candidate.setMinutes(candidate.getMinutes() + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Next due time for a schedule trigger, as an ISO string. Interval wins over
|
||||
* cron when both are set, matching the editor which offers one or the other.
|
||||
*/
|
||||
export function computeNextDueAt(
|
||||
schedule: {
|
||||
cron?: string | null;
|
||||
intervalSeconds?: number | null;
|
||||
timezone?: string | null;
|
||||
},
|
||||
from: Date = new Date(),
|
||||
): string | null {
|
||||
if (schedule.intervalSeconds && schedule.intervalSeconds > 0) {
|
||||
return new Date(
|
||||
from.getTime() + schedule.intervalSeconds * 1000,
|
||||
).toISOString();
|
||||
}
|
||||
if (schedule.cron) {
|
||||
const next = nextCronRun(schedule.cron, from, schedule.timezone);
|
||||
return next ? next.toISOString() : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import type { AutomationDefinition } from "../../types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
|
||||
import { resolveHostById } from "../hosts/host-resolver.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { execCommand } from "../hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
createFleetSshFactory,
|
||||
getFleetPoolKey,
|
||||
} from "../hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../hosts/ssh-connection-pool.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import { onDockerEvent } from "./triggers.js";
|
||||
|
||||
/**
|
||||
* Container state polling for docker_event triggers.
|
||||
*
|
||||
* Everything else Docker-related in the backend hangs off an interactive
|
||||
* session that only exists while somebody has the UI open, so a trigger built
|
||||
* on it would only ever fire while being watched. This polls over the same
|
||||
* pooled SSH connection the other automation steps use, and only for hosts a
|
||||
* docker_event trigger actually names, so an install with no such automation
|
||||
* does no extra work at all.
|
||||
*
|
||||
* Events are derived by diffing successive snapshots: the poll interval is the
|
||||
* resolution, so a container that stops and starts between two polls is not
|
||||
* reported. That is the tradeoff for not holding a `docker events` stream open
|
||||
* against every host.
|
||||
*/
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
const EXEC_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface ContainerState {
|
||||
/** Docker's own state word: running, exited, restarting, ... */
|
||||
state: string;
|
||||
/** Health from the status text, when the image declares a healthcheck. */
|
||||
unhealthy: boolean;
|
||||
}
|
||||
|
||||
/** Last snapshot per host, so transitions can be spotted. */
|
||||
const snapshots = new Map<number, Map<string, ContainerState>>();
|
||||
const lastPolledAt = new Map<number, number>();
|
||||
|
||||
/** Hosts named by an enabled docker_event trigger, with the owning user. */
|
||||
export async function listDockerWatchedHosts(): Promise<Map<number, string>> {
|
||||
const watched = new Map<number, string>();
|
||||
|
||||
try {
|
||||
const rows = await createCurrentAutomationRepository().listAllEnabled();
|
||||
for (const row of rows) {
|
||||
let definition: AutomationDefinition;
|
||||
try {
|
||||
definition = JSON.parse(row.definition) as AutomationDefinition;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "docker_event") continue;
|
||||
|
||||
const selector = trigger.hostSelector;
|
||||
if (selector?.kind === "host") {
|
||||
watched.set(selector.hostId, row.userId);
|
||||
} else if (selector?.kind === "hosts") {
|
||||
for (const hostId of selector.hostIds) watched.set(hostId, row.userId);
|
||||
}
|
||||
// Fleet and "all" selectors are deliberately not expanded: polling every
|
||||
// host a user owns for container state is far too costly to do blindly.
|
||||
}
|
||||
} catch {
|
||||
return watched;
|
||||
}
|
||||
|
||||
return watched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `docker ps -a` output. One JSON object per line, matching the format
|
||||
* string the container routes use.
|
||||
*/
|
||||
export function parseContainerStates(
|
||||
output: string,
|
||||
): Map<string, ContainerState> {
|
||||
const states = new Map<string, ContainerState>();
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as {
|
||||
name?: string;
|
||||
state?: string;
|
||||
status?: string;
|
||||
};
|
||||
if (!parsed.name) continue;
|
||||
|
||||
states.set(parsed.name, {
|
||||
state: (parsed.state ?? "").toLowerCase(),
|
||||
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
|
||||
});
|
||||
} catch {
|
||||
// A partial line is not worth failing the whole poll over.
|
||||
}
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out which events a pair of snapshots implies.
|
||||
*
|
||||
* A container missing from the previous snapshot is treated as newly seen
|
||||
* rather than started, so the first poll after a restart does not replay every
|
||||
* running container as a fresh start event.
|
||||
*/
|
||||
export function diffContainerStates(
|
||||
previous: Map<string, ContainerState>,
|
||||
current: Map<string, ContainerState>,
|
||||
): Array<{ container: string; event: DockerEventName }> {
|
||||
const events: Array<{ container: string; event: DockerEventName }> = [];
|
||||
|
||||
for (const [name, now] of current) {
|
||||
const before = previous.get(name);
|
||||
if (!before) continue;
|
||||
|
||||
if (before.state !== now.state) {
|
||||
if (now.state === "exited")
|
||||
events.push({ container: name, event: "exited" });
|
||||
else if (now.state === "running")
|
||||
events.push({ container: name, event: "started" });
|
||||
else if (now.state === "restarting")
|
||||
events.push({ container: name, event: "restarting" });
|
||||
}
|
||||
|
||||
// Health is independent of state: a container can go unhealthy while it
|
||||
// stays up, which is exactly the case worth alerting on.
|
||||
if (!before.unhealthy && now.unhealthy) {
|
||||
events.push({ container: name, event: "unhealthy" });
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
|
||||
|
||||
const PS_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
|
||||
|
||||
async function pollHost(hostId: number, userId: string): Promise<void> {
|
||||
const host = await resolveHostById(hostId, userId);
|
||||
if (!host) {
|
||||
snapshots.delete(hostId);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await withConnection(
|
||||
getFleetPoolKey(host as never),
|
||||
createFleetSshFactory(host as never),
|
||||
(client) =>
|
||||
execCommand(
|
||||
client,
|
||||
`docker ps -a --format ${PS_FORMAT}`,
|
||||
EXEC_TIMEOUT_MS,
|
||||
),
|
||||
);
|
||||
|
||||
if (result.code !== 0 && result.code !== null) {
|
||||
// Docker missing or not permitted on this host. Drop the snapshot so a
|
||||
// later success is treated as a first observation rather than a diff.
|
||||
snapshots.delete(hostId);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = parseContainerStates(result.stdout);
|
||||
const previous = snapshots.get(hostId);
|
||||
snapshots.set(hostId, current);
|
||||
|
||||
if (!previous) return;
|
||||
|
||||
for (const { container, event } of diffContainerStates(previous, current)) {
|
||||
await onDockerEvent({
|
||||
hostId,
|
||||
ownerUserId: userId,
|
||||
container,
|
||||
event,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls every watched host whose interval has elapsed. Called from the
|
||||
* automation scheduler tick rather than owning a timer of its own.
|
||||
*/
|
||||
export async function pollDockerEvents(
|
||||
now: number = Date.now(),
|
||||
): Promise<void> {
|
||||
const watched = await listDockerWatchedHosts();
|
||||
|
||||
for (const hostId of [...snapshots.keys()]) {
|
||||
if (!watched.has(hostId)) {
|
||||
snapshots.delete(hostId);
|
||||
lastPolledAt.delete(hostId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [hostId, userId] of watched) {
|
||||
const last = lastPolledAt.get(hostId) ?? 0;
|
||||
if (now - last < POLL_INTERVAL_MS) continue;
|
||||
// Host credentials cannot be decrypted while the owner's key is locked.
|
||||
if (!canAccess(userId)) continue;
|
||||
lastPolledAt.set(hostId, now);
|
||||
|
||||
try {
|
||||
await pollHost(hostId, userId);
|
||||
} catch (error) {
|
||||
statsLogger.warn("Docker event poll failed", {
|
||||
operation: "automation_docker_poll_error",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canAccess(userId: string): boolean {
|
||||
try {
|
||||
return DataCrypto.canUserAccessData(userId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears cached state, for shutdown and tests. */
|
||||
export function resetDockerWatcher(): void {
|
||||
snapshots.clear();
|
||||
lastPolledAt.clear();
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
RunStatus,
|
||||
Step,
|
||||
} from "../../types/automations.js";
|
||||
import {
|
||||
DEFAULT_MAX_RUN_SECONDS,
|
||||
MAX_AUTOMATION_DEPTH,
|
||||
MAX_STEP_OUTPUT_BYTES,
|
||||
} from "../../types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import { resolveHostById } from "../hosts/host-resolver.js";
|
||||
import { notifyAutomationInternalEvent } from "../hosts/automation-events.js";
|
||||
import { executeStep } from "./actions/index.js";
|
||||
import type { StepExecutionContext, StepResult } from "./actions/types.js";
|
||||
import { compare } from "./conditions.js";
|
||||
import { renderTemplate, type TemplateContext } from "./template.js";
|
||||
|
||||
export interface RunRequest {
|
||||
automationId: number;
|
||||
triggerType: string;
|
||||
triggerContext?: Record<string, unknown>;
|
||||
triggerHostId?: number;
|
||||
/** Overrides the automation's own dry-run flag, for "test run". */
|
||||
dryRun?: boolean;
|
||||
parentRunId?: number;
|
||||
ancestry?: number[];
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export interface RunOutcome {
|
||||
runId: number | null;
|
||||
status: RunStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes automations.
|
||||
*
|
||||
* Both HTTP handlers and the metrics hooks live in this same process, so this
|
||||
* is a plain singleton rather than anything cross-process. State that has to
|
||||
* survive a restart (cooldowns, dwell windows) lives in the database; the only
|
||||
* thing held in memory is the set of runs currently in flight, which is
|
||||
* meaningless after a restart anyway.
|
||||
*/
|
||||
export class AutomationEngine {
|
||||
private static instance: AutomationEngine;
|
||||
|
||||
private readonly running = new Set<number>();
|
||||
private readonly queued = new Map<number, number>();
|
||||
|
||||
static getInstance(): AutomationEngine {
|
||||
if (!AutomationEngine.instance) {
|
||||
AutomationEngine.instance = new AutomationEngine();
|
||||
}
|
||||
return AutomationEngine.instance;
|
||||
}
|
||||
|
||||
isRunning(automationId: number): boolean {
|
||||
return this.running.has(automationId);
|
||||
}
|
||||
|
||||
async run(request: RunRequest): Promise<RunOutcome> {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const automation = await repository.findById(request.automationId);
|
||||
|
||||
if (!automation) {
|
||||
return { runId: null, status: "failed", error: "Automation not found" };
|
||||
}
|
||||
|
||||
const depth = request.depth ?? 0;
|
||||
const ancestry = request.ancestry ?? [];
|
||||
|
||||
// Refuse recursion before anything is recorded, so a cycle cannot spin.
|
||||
if (depth > MAX_AUTOMATION_DEPTH) {
|
||||
return {
|
||||
runId: null,
|
||||
status: "failed",
|
||||
error: `Maximum automation depth of ${MAX_AUTOMATION_DEPTH} exceeded`,
|
||||
};
|
||||
}
|
||||
if (ancestry.includes(automation.id)) {
|
||||
return {
|
||||
runId: null,
|
||||
status: "failed",
|
||||
error: `Automation ${automation.id} is already running in this chain`,
|
||||
};
|
||||
}
|
||||
|
||||
let definition: AutomationDefinition;
|
||||
try {
|
||||
definition = JSON.parse(automation.definition) as AutomationDefinition;
|
||||
} catch {
|
||||
return {
|
||||
runId: null,
|
||||
status: "failed",
|
||||
error: "Automation definition is not valid JSON",
|
||||
};
|
||||
}
|
||||
|
||||
// A second trigger while a run is in flight is recorded as skipped rather
|
||||
// than dropped silently, so the history explains what happened.
|
||||
//
|
||||
// The slot has to be claimed in the same tick as the check. It used to be
|
||||
// claimed several awaits later, so two triggers arriving together both
|
||||
// passed this test and both ran.
|
||||
let claimed = false;
|
||||
if (this.running.has(automation.id)) {
|
||||
const policy = automation.concurrencyPolicy;
|
||||
if (policy === "skip") {
|
||||
const run = await repository.createRun({
|
||||
automationId: automation.id,
|
||||
userId: automation.userId,
|
||||
triggerType: request.triggerType,
|
||||
triggerContext: JSON.stringify(request.triggerContext ?? {}),
|
||||
status: "skipped",
|
||||
});
|
||||
await repository.finishRun(run.id, {
|
||||
status: "skipped",
|
||||
error: "A previous run was still in progress",
|
||||
durationMs: 0,
|
||||
});
|
||||
return { runId: run.id, status: "skipped" };
|
||||
}
|
||||
if (policy === "queue") {
|
||||
const depthNow = this.queued.get(automation.id) ?? 0;
|
||||
if (depthNow >= 5) {
|
||||
return { runId: null, status: "skipped", error: "Queue is full" };
|
||||
}
|
||||
this.queued.set(automation.id, depthNow + 1);
|
||||
try {
|
||||
await this.waitUntilFree(automation.id);
|
||||
} finally {
|
||||
this.queued.set(
|
||||
automation.id,
|
||||
(this.queued.get(automation.id) ?? 1) - 1,
|
||||
);
|
||||
}
|
||||
// waitUntilFree gives up on its own deadline, so the slot may still be
|
||||
// taken. Only claim it when it is genuinely free.
|
||||
if (!this.running.has(automation.id)) {
|
||||
this.running.add(automation.id);
|
||||
claimed = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.running.add(automation.id);
|
||||
claimed = true;
|
||||
}
|
||||
|
||||
const dryRun = request.dryRun ?? automation.dryRun;
|
||||
const maxRunSeconds = automation.maxRunSeconds || DEFAULT_MAX_RUN_SECONDS;
|
||||
const startedAt = Date.now();
|
||||
|
||||
let run: { id: number };
|
||||
try {
|
||||
run = await repository.createRun({
|
||||
automationId: automation.id,
|
||||
userId: automation.userId,
|
||||
triggerType: request.triggerType,
|
||||
triggerContext: JSON.stringify(request.triggerContext ?? {}),
|
||||
status: "running",
|
||||
dryRun,
|
||||
parentRunId: request.parentRunId ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
// The slot is already claimed at this point, so it has to be given back
|
||||
// here; the finally below is only reached once a run row exists.
|
||||
if (claimed) this.running.delete(automation.id);
|
||||
return {
|
||||
runId: null,
|
||||
status: "failed",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
|
||||
if (!claimed) this.running.add(automation.id);
|
||||
|
||||
const trigger = { ...(request.triggerContext ?? {}) };
|
||||
trigger.type ??= request.triggerType;
|
||||
let host: TemplateContext["host"];
|
||||
if (request.triggerHostId) {
|
||||
try {
|
||||
const resolved = await resolveHostById(
|
||||
request.triggerHostId,
|
||||
automation.userId,
|
||||
);
|
||||
if (resolved) {
|
||||
host = {
|
||||
id: request.triggerHostId,
|
||||
name: resolved.name || resolved.ip,
|
||||
ip: resolved.ip,
|
||||
username: resolved.username,
|
||||
port: resolved.port,
|
||||
};
|
||||
trigger.hostName ??= host.name;
|
||||
}
|
||||
} catch {
|
||||
// A notification should still run with its numeric host id.
|
||||
}
|
||||
}
|
||||
|
||||
const template: TemplateContext = {
|
||||
host,
|
||||
trigger,
|
||||
steps: {},
|
||||
vars: {},
|
||||
run: {
|
||||
id: run.id,
|
||||
automationId: automation.id,
|
||||
startedAt: new Date(startedAt).toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const context: StepExecutionContext = {
|
||||
userId: automation.userId,
|
||||
automationId: automation.id,
|
||||
runId: run.id,
|
||||
dryRun,
|
||||
template,
|
||||
triggerHostId: request.triggerHostId,
|
||||
ancestry: [...ancestry, automation.id],
|
||||
depth,
|
||||
deadlineAt: startedAt + maxRunSeconds * 1000,
|
||||
};
|
||||
|
||||
let status: RunStatus = "success";
|
||||
let error: string | undefined;
|
||||
|
||||
try {
|
||||
const result = await this.runSteps(definition.steps ?? [], context, {
|
||||
index: 0,
|
||||
});
|
||||
if (result.halted?.status === "failed") {
|
||||
status = "failed";
|
||||
error = "Stopped by a stop step";
|
||||
} else if (result.failed) {
|
||||
status = "failed";
|
||||
error = result.error;
|
||||
}
|
||||
if (Date.now() >= context.deadlineAt) {
|
||||
status = "timeout";
|
||||
error = `Run exceeded ${maxRunSeconds}s`;
|
||||
}
|
||||
} catch (err) {
|
||||
status = "failed";
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
this.running.delete(automation.id);
|
||||
}
|
||||
|
||||
await repository.finishRun(run.id, {
|
||||
status,
|
||||
error: error ?? null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
|
||||
if (status === "failed") {
|
||||
statsLogger.warn(`Automation "${automation.name}" failed`, {
|
||||
operation: "automation_run_failed",
|
||||
automationId: automation.id,
|
||||
runId: run.id,
|
||||
error,
|
||||
});
|
||||
|
||||
// An automation_failed handler that itself fails must not re-announce
|
||||
// its own failure, so the event is not emitted for runs that this event
|
||||
// already started.
|
||||
if (request.triggerType !== "internal_event") {
|
||||
// Emitted onto the bus rather than imported from hosts/: the bus is
|
||||
// fire-and-forget, so this no longer needs the lazy-import dance that
|
||||
// was working around the repositories cycle.
|
||||
notifyAutomationInternalEvent(
|
||||
"automation_failed",
|
||||
automation.userId,
|
||||
undefined,
|
||||
{
|
||||
automationId: automation.id,
|
||||
automationName: automation.name,
|
||||
runId: run.id,
|
||||
error: error ?? null,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { runId: run.id, status, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a list of steps in order, descending into if/else. Returns as soon as
|
||||
* a stop step halts the run or a failing step's policy says to stop.
|
||||
*/
|
||||
private async runSteps(
|
||||
steps: Step[],
|
||||
context: StepExecutionContext,
|
||||
cursor: { index: number },
|
||||
): Promise<{
|
||||
failed: boolean;
|
||||
error?: string;
|
||||
halted?: { status: "success" | "failed" };
|
||||
}> {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
|
||||
for (const step of steps) {
|
||||
if (step.enabled === false) continue;
|
||||
|
||||
if (Date.now() >= context.deadlineAt) {
|
||||
return { failed: true, error: "Run deadline exceeded" };
|
||||
}
|
||||
|
||||
const stepIndex = cursor.index++;
|
||||
|
||||
if (step.type === "if") {
|
||||
const left = renderTemplate(step.condition.left, context.template);
|
||||
const right = renderTemplate(
|
||||
step.condition.right ?? "",
|
||||
context.template,
|
||||
);
|
||||
const matched = compare(left, step.condition.operator, right);
|
||||
|
||||
const rowId = await repository.createRunStep({
|
||||
runId: context.runId,
|
||||
stepIndex,
|
||||
stepId: step.id,
|
||||
stepType: "if",
|
||||
status: "running",
|
||||
});
|
||||
await repository.finishRunStep(rowId, {
|
||||
status: "success",
|
||||
output: `Condition ${matched ? "matched" : "did not match"}: ${left} ${step.condition.operator} ${right}`,
|
||||
});
|
||||
|
||||
const branch = matched ? step.then : (step.else ?? []);
|
||||
const result = await this.runSteps(branch, context, cursor);
|
||||
if (result.halted) return result;
|
||||
if (result.failed) return result;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.type === "run_automation") {
|
||||
const rowId = await repository.createRunStep({
|
||||
runId: context.runId,
|
||||
stepIndex,
|
||||
stepId: step.id,
|
||||
stepType: step.type,
|
||||
status: "running",
|
||||
});
|
||||
|
||||
const nested = await this.run({
|
||||
automationId: step.automationId,
|
||||
triggerType: "run_automation",
|
||||
triggerContext: { parentAutomationId: context.automationId },
|
||||
triggerHostId: context.triggerHostId,
|
||||
dryRun: context.dryRun,
|
||||
parentRunId: context.runId,
|
||||
ancestry: context.ancestry,
|
||||
depth: context.depth + 1,
|
||||
});
|
||||
|
||||
const nestedOk = nested.status === "success";
|
||||
await repository.finishRunStep(rowId, {
|
||||
status: nestedOk ? "success" : "failed",
|
||||
output: `Nested run ${nested.runId ?? "not started"}: ${nested.status}`,
|
||||
error: nested.error ?? null,
|
||||
});
|
||||
|
||||
if (!nestedOk && (step.onError ?? "stop") === "stop") {
|
||||
return { failed: true, error: nested.error ?? "Nested run failed" };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const rowId = await repository.createRunStep({
|
||||
runId: context.runId,
|
||||
stepIndex,
|
||||
stepId: step.id,
|
||||
stepType: step.type,
|
||||
status: "running",
|
||||
});
|
||||
|
||||
let result: StepResult;
|
||||
try {
|
||||
result = await executeStep(step, context);
|
||||
} catch (err) {
|
||||
result = {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
|
||||
const { text, truncated } = truncate(result.output);
|
||||
await repository.finishRunStep(rowId, {
|
||||
status: result.success ? "success" : "failed",
|
||||
output: text,
|
||||
error: result.error ?? null,
|
||||
truncated,
|
||||
});
|
||||
|
||||
// Later steps read earlier output through {{steps.<id>.stdout}}.
|
||||
context.template.steps = {
|
||||
...context.template.steps,
|
||||
[step.id]: {
|
||||
stdout: result.output ?? "",
|
||||
code: result.success ? 0 : 1,
|
||||
},
|
||||
};
|
||||
if (result.vars) {
|
||||
context.template.vars = { ...context.template.vars, ...result.vars };
|
||||
}
|
||||
|
||||
if (result.halt) return { failed: false, halted: result.halt };
|
||||
|
||||
if (!result.success) {
|
||||
const policy = step.onError ?? "stop";
|
||||
if (policy === "stop") {
|
||||
return { failed: true, error: result.error };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { failed: false };
|
||||
}
|
||||
|
||||
private async waitUntilFree(automationId: number): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (this.running.has(automationId)) {
|
||||
if (Date.now() - started > 60_000) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps a single step's output from bloating the database. */
|
||||
function truncate(output: string | undefined): {
|
||||
text: string | null;
|
||||
truncated: boolean;
|
||||
} {
|
||||
if (!output) return { text: null, truncated: false };
|
||||
if (Buffer.byteLength(output, "utf8") <= MAX_STEP_OUTPUT_BYTES) {
|
||||
return { text: output, truncated: false };
|
||||
}
|
||||
return {
|
||||
text: output.slice(0, MAX_STEP_OUTPUT_BYTES) + "\n... (truncated)",
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import { listAutomationWatchedHosts } from "./triggers.js";
|
||||
|
||||
/**
|
||||
* Keeps metric collection running for hosts an automation watches.
|
||||
*
|
||||
* Heavy metric collection is normally started by a UI viewer and stops when
|
||||
* the last one leaves, which means threshold rules only ever evaluated while
|
||||
* somebody had the host open. Automations register a synthetic viewer instead
|
||||
* of bypassing that mechanism, so a real viewer arriving or leaving still
|
||||
* behaves exactly as before.
|
||||
*
|
||||
* The catch is `cleanupInactiveViewers`, which drops any viewer whose
|
||||
* heartbeat is older than 120s. Without the heartbeat below, headless polling
|
||||
* would quietly stop two minutes after it started.
|
||||
*/
|
||||
|
||||
export interface ViewerRegistry {
|
||||
registerViewer(hostId: number, sessionId: string, userId: string): void;
|
||||
unregisterViewer(hostId: number, sessionId: string): void;
|
||||
updateHeartbeat(sessionId: string): boolean;
|
||||
}
|
||||
|
||||
const SESSION_PREFIX = "automation:";
|
||||
|
||||
let registry: ViewerRegistry | null = null;
|
||||
const registered = new Map<number, string>();
|
||||
|
||||
export function setViewerRegistry(next: ViewerRegistry | null): void {
|
||||
registry = next;
|
||||
}
|
||||
|
||||
export function automationSessionId(hostId: number): string {
|
||||
return `${SESSION_PREFIX}${hostId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the set of synthetic viewers in line with what the enabled
|
||||
* automations currently watch, and heartbeats the ones that stay.
|
||||
*/
|
||||
export async function reconcileHeadlessViewers(): Promise<{
|
||||
added: number;
|
||||
removed: number;
|
||||
active: number;
|
||||
}> {
|
||||
if (!registry) return { added: 0, removed: 0, active: 0 };
|
||||
|
||||
let watched: Map<number, string>;
|
||||
try {
|
||||
watched = await listAutomationWatchedHosts();
|
||||
} catch {
|
||||
return { added: 0, removed: 0, active: registered.size };
|
||||
}
|
||||
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
|
||||
for (const [hostId, userId] of watched) {
|
||||
const sessionId = automationSessionId(hostId);
|
||||
if (registered.has(hostId)) {
|
||||
// Refresh before the 120s reaper would take it.
|
||||
registry.updateHeartbeat(sessionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
registry.registerViewer(hostId, sessionId, userId);
|
||||
registered.set(hostId, userId);
|
||||
added++;
|
||||
} catch (error) {
|
||||
statsLogger.warn("Could not start headless metrics for a host", {
|
||||
operation: "automation_headless_register_error",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const hostId of [...registered.keys()]) {
|
||||
if (watched.has(hostId)) continue;
|
||||
try {
|
||||
registry.unregisterViewer(hostId, automationSessionId(hostId));
|
||||
} catch {
|
||||
// Already gone; drop it either way.
|
||||
}
|
||||
registered.delete(hostId);
|
||||
removed++;
|
||||
}
|
||||
|
||||
return { added, removed, active: registered.size };
|
||||
}
|
||||
|
||||
/** Drops every synthetic viewer, for shutdown and tests. */
|
||||
export function releaseHeadlessViewers(): void {
|
||||
if (registry) {
|
||||
for (const hostId of registered.keys()) {
|
||||
try {
|
||||
registry.unregisterViewer(hostId, automationSessionId(hostId));
|
||||
} catch {
|
||||
// Nothing useful to do during teardown.
|
||||
}
|
||||
}
|
||||
}
|
||||
registered.clear();
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
||||
import { readNotificationPrivateAllowlist } from "../utils/notification-egress.js";
|
||||
|
||||
/**
|
||||
* Outbound HTTP for automation steps and notification channels.
|
||||
*
|
||||
* safeOutboundFetch refuses private and loopback addresses, which is the right
|
||||
* default against SSRF but also blocks the self-hosted ntfy or Gotify sitting
|
||||
* on a LAN that many installs actually use. Private delivery therefore needs
|
||||
* both a channel opt-in and an exact host in the administrator allowlist;
|
||||
* scheme validation, DNS pinning and redirect refusal remain in force.
|
||||
*/
|
||||
export interface AutomationFetchOptions {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function automationFetch(
|
||||
url: string,
|
||||
options: AutomationFetchOptions = {},
|
||||
): Promise<Response> {
|
||||
const {
|
||||
method = "GET",
|
||||
headers,
|
||||
body,
|
||||
allowPrivateNetwork,
|
||||
timeoutMs = 30_000,
|
||||
} = options;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), Math.max(timeoutMs, 1));
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: body
|
||||
? { "Content-Type": "application/json", ...(headers ?? {}) }
|
||||
: headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
try {
|
||||
if (allowPrivateNetwork) {
|
||||
return await privateNetworkFetch(url, init);
|
||||
}
|
||||
return await safeOutboundFetch(url, init);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The opt-in path still goes through the guarded resolver. Only an exact host
|
||||
* authorized by an administrator may resolve to a private address.
|
||||
*/
|
||||
async function privateNetworkFetch(
|
||||
rawUrl: string,
|
||||
init: RequestInit,
|
||||
): Promise<Response> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error("Invalid URL");
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("Only http and https URLs are allowed");
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error("URLs with embedded credentials are not allowed");
|
||||
}
|
||||
|
||||
const allowlist = await readNotificationPrivateAllowlist();
|
||||
return safeOutboundFetch(rawUrl, init, allowlist);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import { automationFetch } from "./http.js";
|
||||
import type { TemplateContext } from "./template.js";
|
||||
|
||||
/**
|
||||
* Notification delivery for automations.
|
||||
*
|
||||
* The alert engine special-cased Discord because its dispatcher only knew
|
||||
* webhook and ntfy; here every transport goes through one switch, so adding a
|
||||
* channel type is a single edit.
|
||||
*/
|
||||
export interface AutomationChannel {
|
||||
id: number;
|
||||
type: string;
|
||||
config: string;
|
||||
}
|
||||
|
||||
export interface AutomationNotification {
|
||||
title: string;
|
||||
body: string;
|
||||
severity: "info" | "warning" | "critical";
|
||||
context?: TemplateContext;
|
||||
}
|
||||
|
||||
const NTFY_PRIORITY: Record<string, number> = {
|
||||
info: 2,
|
||||
warning: 3,
|
||||
critical: 5,
|
||||
};
|
||||
|
||||
const NTFY_TAGS: Record<string, string> = {
|
||||
info: "information_source",
|
||||
warning: "warning",
|
||||
critical: "rotating_light",
|
||||
};
|
||||
|
||||
const DISCORD_COLORS: Record<string, number> = {
|
||||
info: 3066993,
|
||||
warning: 16753920,
|
||||
critical: 15158332,
|
||||
};
|
||||
|
||||
export async function sendAutomationNotification(
|
||||
channel: AutomationChannel,
|
||||
notification: AutomationNotification,
|
||||
): Promise<void> {
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(channel.config) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error("Channel configuration is not valid JSON");
|
||||
}
|
||||
|
||||
const allowPrivateNetwork = config.allowPrivateNetwork === true;
|
||||
|
||||
switch (channel.type) {
|
||||
case "webhook":
|
||||
return sendWebhook(config, notification, allowPrivateNetwork);
|
||||
case "ntfy":
|
||||
return sendNtfy(config, notification, allowPrivateNetwork);
|
||||
case "discord":
|
||||
return sendDiscord(config, notification, allowPrivateNetwork);
|
||||
default:
|
||||
throw new Error(`Unsupported channel type: ${channel.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireUrl(config: Record<string, unknown>): string {
|
||||
const url = typeof config.url === "string" ? config.url.trim() : "";
|
||||
if (!url) throw new Error("Channel is missing a URL");
|
||||
return url;
|
||||
}
|
||||
|
||||
async function sendWebhook(
|
||||
config: Record<string, unknown>,
|
||||
notification: AutomationNotification,
|
||||
allowPrivateNetwork: boolean,
|
||||
): Promise<void> {
|
||||
const url = requireUrl(config);
|
||||
const method = config.method === "PUT" ? "PUT" : "POST";
|
||||
const headers =
|
||||
config.headers && typeof config.headers === "object"
|
||||
? (config.headers as Record<string, string>)
|
||||
: {};
|
||||
|
||||
const response = await automationFetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
title: notification.title,
|
||||
hostName:
|
||||
notification.context?.host?.name ??
|
||||
notification.context?.trigger?.hostName,
|
||||
hostId:
|
||||
notification.context?.host?.id ?? notification.context?.trigger?.hostId,
|
||||
ruleName: notification.title,
|
||||
ruleId: notification.context?.run?.automationId,
|
||||
triggerType: notification.context?.trigger?.type,
|
||||
value: notification.context?.trigger?.value,
|
||||
threshold: notification.context?.trigger?.threshold,
|
||||
message: notification.body,
|
||||
severity: notification.severity,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
allowPrivateNetwork,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendNtfy(
|
||||
config: Record<string, unknown>,
|
||||
notification: AutomationNotification,
|
||||
allowPrivateNetwork: boolean,
|
||||
): Promise<void> {
|
||||
const base = requireUrl(config).replace(/\/$/, "");
|
||||
const topic = typeof config.topic === "string" ? config.topic.trim() : "";
|
||||
if (!topic) throw new Error("ntfy channel is missing a topic");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Title: notification.title || "Termix automation",
|
||||
Priority: String(NTFY_PRIORITY[notification.severity] ?? 3),
|
||||
Tags: NTFY_TAGS[notification.severity] ?? "information_source",
|
||||
};
|
||||
if (typeof config.token === "string" && config.token) {
|
||||
headers.Authorization = `Bearer ${config.token}`;
|
||||
}
|
||||
|
||||
const response = await automationFetch(`${base}/${topic}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: notification.body || notification.title,
|
||||
allowPrivateNetwork,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendDiscord(
|
||||
config: Record<string, unknown>,
|
||||
notification: AutomationNotification,
|
||||
allowPrivateNetwork: boolean,
|
||||
): Promise<void> {
|
||||
const url = requireUrl(config);
|
||||
const payload: Record<string, unknown> = {
|
||||
embeds: [
|
||||
{
|
||||
title: notification.title || "Termix automation",
|
||||
description: notification.body || undefined,
|
||||
color: DISCORD_COLORS[notification.severity] ?? 3447003,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
if (typeof config.username === "string" && config.username) {
|
||||
payload.username = config.username;
|
||||
}
|
||||
if (typeof config.avatar_url === "string" && config.avatar_url) {
|
||||
payload.avatar_url = config.avatar_url;
|
||||
}
|
||||
|
||||
const response = await automationFetch(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
allowPrivateNetwork,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`HTTP ${response.status} ${response.statusText}${detail ? `: ${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire-and-forget wrapper for callers that must not block on delivery. */
|
||||
export function sendAutomationNotificationSafely(
|
||||
channel: AutomationChannel,
|
||||
notification: AutomationNotification,
|
||||
): void {
|
||||
sendAutomationNotification(channel, notification).catch((error) => {
|
||||
statsLogger.warn("Automation notification failed", {
|
||||
operation: "automation_notification_error",
|
||||
channelId: channel.id,
|
||||
type: channel.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import type { AutomationDefinition } from "../../types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import { computeNextDueAt } from "./cron.js";
|
||||
import { hasDwelled, isCoolingDown } from "./conditions.js";
|
||||
import { pollDockerEvents } from "./docker-watcher.js";
|
||||
import { AutomationEngine } from "./engine.js";
|
||||
import { reconcileHeadlessViewers } from "./headless-viewer.js";
|
||||
import {
|
||||
subscribeAutomationTriggers,
|
||||
unsubscribeAutomationTriggers,
|
||||
} from "./triggers.js";
|
||||
|
||||
/**
|
||||
* The one timer the automations feature owns.
|
||||
*
|
||||
* Every other periodic job in the backend is its own module-level setInterval;
|
||||
* this deliberately is not one per automation. A single tick handles due
|
||||
* schedules, dwell windows that need re-checking without a fresh sample, the
|
||||
* synthetic viewer heartbeat, and history pruning.
|
||||
*/
|
||||
|
||||
const TICK_MS = 15_000;
|
||||
const STARTUP_DELAY_MS = 30_000;
|
||||
const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
const RUN_RETENTION_DAYS = 30;
|
||||
/** A "running" row older than this belongs to a process that is gone. */
|
||||
const STALE_RUN_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
let tickTimer: NodeJS.Timeout | null = null;
|
||||
let startupTimer: NodeJS.Timeout | null = null;
|
||||
let lastPruneAt = 0;
|
||||
let ticking = false;
|
||||
|
||||
export function startAutomationScheduler(): void {
|
||||
if (tickTimer) return;
|
||||
|
||||
// Event-driven triggers come through the bus; the interval below only drives
|
||||
// the schedule-based ones.
|
||||
subscribeAutomationTriggers();
|
||||
|
||||
startupTimer = setTimeout(() => {
|
||||
void tick();
|
||||
}, STARTUP_DELAY_MS);
|
||||
startupTimer.unref?.();
|
||||
|
||||
tickTimer = setInterval(() => {
|
||||
void tick();
|
||||
}, TICK_MS);
|
||||
tickTimer.unref?.();
|
||||
}
|
||||
|
||||
export function stopAutomationScheduler(): void {
|
||||
if (tickTimer) clearInterval(tickTimer);
|
||||
if (startupTimer) clearTimeout(startupTimer);
|
||||
tickTimer = null;
|
||||
startupTimer = null;
|
||||
unsubscribeAutomationTriggers();
|
||||
}
|
||||
|
||||
/** Exposed for tests; the interval calls this. */
|
||||
export async function tick(now: Date = new Date()): Promise<void> {
|
||||
// A slow tick must not overlap the next one.
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
|
||||
try {
|
||||
await reconcileHeadlessViewers().catch(() => undefined);
|
||||
await runDueSchedules(now);
|
||||
await recheckOpenBreaches(now);
|
||||
await pollDockerEvents(now.getTime()).catch(() => undefined);
|
||||
await pruneIfDue(now);
|
||||
} catch (error) {
|
||||
statsLogger.warn("Automation scheduler tick failed", {
|
||||
operation: "automation_scheduler_tick_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
ticking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runDueSchedules(now: Date): Promise<void> {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const due = await repository.listDueSchedules(now.toISOString());
|
||||
|
||||
for (const schedule of due) {
|
||||
const automation = await repository.findById(schedule.automationId);
|
||||
if (!automation) continue;
|
||||
|
||||
// Background work can only touch a user's data while their key resolves.
|
||||
if (!canAccess(automation.userId)) {
|
||||
statsLogger.warn("Skipping scheduled automation: data key unavailable", {
|
||||
operation: "automation_schedule_locked",
|
||||
automationId: automation.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDueAt = computeNextDueAt(
|
||||
{
|
||||
cron: schedule.cron,
|
||||
intervalSeconds: schedule.intervalSeconds,
|
||||
timezone: schedule.timezone,
|
||||
},
|
||||
now,
|
||||
);
|
||||
await repository.markScheduleTicked(
|
||||
schedule.automationId,
|
||||
nextDueAt,
|
||||
now.toISOString(),
|
||||
);
|
||||
|
||||
AutomationEngine.getInstance()
|
||||
.run({
|
||||
automationId: schedule.automationId,
|
||||
triggerType: "schedule",
|
||||
triggerContext: { scheduledFor: now.toISOString() },
|
||||
})
|
||||
.catch((error) => {
|
||||
statsLogger.warn("Scheduled automation failed to start", {
|
||||
operation: "automation_schedule_error",
|
||||
automationId: schedule.automationId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires sustained breaches whose window has elapsed.
|
||||
*
|
||||
* Without this a dwell window only completes when another sample happens to
|
||||
* arrive, so a breach that starts just before polling stops would never fire.
|
||||
*/
|
||||
async function recheckOpenBreaches(now: Date): Promise<void> {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const open = await repository.listOpenBreaches();
|
||||
const nowMs = now.getTime();
|
||||
|
||||
for (const state of open) {
|
||||
const automation = await repository.findById(state.automationId);
|
||||
if (!automation || !automation.enabled) continue;
|
||||
if (!canAccess(automation.userId)) continue;
|
||||
|
||||
let definition: AutomationDefinition;
|
||||
try {
|
||||
definition = JSON.parse(automation.definition) as AutomationDefinition;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "metric_threshold") continue;
|
||||
if (!trigger.forSeconds) continue;
|
||||
if (!hasDwelled(state.breachStartedAt, trigger.forSeconds, nowMs)) continue;
|
||||
if (isCoolingDown(state.lastFiredAt, trigger.cooldownMinutes, nowMs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hostId = Number(state.stateKey.split(":")[0]);
|
||||
await repository.upsertTriggerState({
|
||||
automationId: automation.id,
|
||||
stateKey: state.stateKey,
|
||||
lastFiredAt: now.toISOString(),
|
||||
});
|
||||
|
||||
AutomationEngine.getInstance()
|
||||
.run({
|
||||
automationId: automation.id,
|
||||
triggerType: "metric_threshold",
|
||||
triggerContext: {
|
||||
hostId,
|
||||
value: state.lastValue,
|
||||
threshold: trigger.value,
|
||||
metric: trigger.metric.path,
|
||||
sustained: true,
|
||||
},
|
||||
triggerHostId: Number.isFinite(hostId) ? hostId : undefined,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneIfDue(now: Date): Promise<void> {
|
||||
if (now.getTime() - lastPruneAt < PRUNE_INTERVAL_MS) return;
|
||||
lastPruneAt = now.getTime();
|
||||
|
||||
const repository = createCurrentAutomationRepository();
|
||||
try {
|
||||
await repository.failStaleRunningRuns(
|
||||
new Date(now.getTime() - STALE_RUN_MS).toISOString(),
|
||||
);
|
||||
const deleted = await repository.pruneRunsOlderThan(RUN_RETENTION_DAYS);
|
||||
if (deleted > 0) {
|
||||
statsLogger.info(`Pruned ${deleted} old automation run(s)`, {
|
||||
operation: "automation_run_prune",
|
||||
deleted,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
statsLogger.warn("Automation run pruning failed", {
|
||||
operation: "automation_run_prune_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function canAccess(userId: string): boolean {
|
||||
try {
|
||||
return DataCrypto.canUserAccessData(userId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* Variable substitution for automation steps.
|
||||
*
|
||||
* Templates read from the run context: {{host.name}}, {{trigger.value}},
|
||||
* {{steps.<stepId>.stdout}}, {{vars.myVar}}. Resolution always produces a
|
||||
* plain string and never shell syntax; callers that build a command are
|
||||
* responsible for quoting the result (see shellSingleQuote in
|
||||
* hosts/metrics/managers/exec-elevated.ts). Nothing here escapes anything,
|
||||
* precisely so there is one obvious place where quoting happens.
|
||||
*/
|
||||
|
||||
export interface TemplateContext {
|
||||
host?: {
|
||||
id?: number;
|
||||
name?: string;
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
};
|
||||
trigger?: Record<string, unknown>;
|
||||
steps?: Record<string, { stdout?: string; stderr?: string; code?: number }>;
|
||||
vars?: Record<string, string>;
|
||||
run?: { id?: number; automationId?: number; startedAt?: string };
|
||||
}
|
||||
|
||||
const TOKEN = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
|
||||
|
||||
function readPath(context: TemplateContext, path: string): unknown {
|
||||
const parts = path.split(".");
|
||||
let current: unknown = context;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
if (typeof current !== "object") return undefined;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function stringify(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces every {{token}} it can resolve. An unresolvable token is left
|
||||
* as-is so a typo shows up in the run output rather than silently becoming an
|
||||
* empty string, which is the difference between a visible mistake and a
|
||||
* command that quietly does the wrong thing.
|
||||
*/
|
||||
export function renderTemplate(
|
||||
input: string,
|
||||
context: TemplateContext,
|
||||
): string {
|
||||
if (!input || !input.includes("{{")) return input;
|
||||
|
||||
return input.replace(TOKEN, (match, path: string) => {
|
||||
const value = readPath(context, path);
|
||||
return value === undefined ? match : stringify(value);
|
||||
});
|
||||
}
|
||||
|
||||
/** Renders every string in a flat record, leaving keys untouched. */
|
||||
export function renderRecord(
|
||||
input: Record<string, string> | undefined,
|
||||
context: TemplateContext,
|
||||
): Record<string, string> | undefined {
|
||||
if (!input) return undefined;
|
||||
const output: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
output[key] = renderTemplate(value, context);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** True when a template still has unresolved tokens after rendering. */
|
||||
export function hasUnresolvedTokens(rendered: string): boolean {
|
||||
TOKEN.lastIndex = 0;
|
||||
return TOKEN.test(rendered);
|
||||
}
|
||||
|
||||
const SECRET_KEY = /(authorization|token|password|secret|api[-_]?key|cookie)/i;
|
||||
|
||||
/**
|
||||
* Masks values whose key looks like a credential, for anything written to run
|
||||
* history or returned by the API.
|
||||
*/
|
||||
export function redactSecrets(
|
||||
input: Record<string, string> | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
if (!input) return undefined;
|
||||
const output: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
output[key] = SECRET_KEY.test(key) ? "***" : value;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
HostSelector,
|
||||
Trigger,
|
||||
} from "../../types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
|
||||
import type { AutomationEngineRow } from "../database/repositories/automation-repository.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import {
|
||||
compare,
|
||||
extractMetricValue,
|
||||
hasDwelled,
|
||||
isCoolingDown,
|
||||
metricStateKey,
|
||||
severityForValue,
|
||||
type MetricsSnapshot,
|
||||
} from "./conditions.js";
|
||||
import { AutomationEngine } from "./engine.js";
|
||||
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||
|
||||
/**
|
||||
* Matches events against automation triggers and decides what fires.
|
||||
*
|
||||
* Dwell windows and cooldowns live in automation_trigger_state rather than in
|
||||
* memory, so a restart mid-breach neither loses the window nor re-fires an
|
||||
* alert that already went out.
|
||||
*/
|
||||
|
||||
export interface MetricEvent {
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
metrics: MetricsSnapshot;
|
||||
}
|
||||
|
||||
export interface StatusEvent {
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export interface HealthEvent {
|
||||
hostId: number;
|
||||
userId: string;
|
||||
checkId: string;
|
||||
ok: boolean;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface DockerEvent {
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
container: string;
|
||||
event: "exited" | "started" | "unhealthy" | "restarting";
|
||||
}
|
||||
|
||||
export interface InternalEvent {
|
||||
event: string;
|
||||
userId: string;
|
||||
hostId?: number;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface LoadedAutomation {
|
||||
row: AutomationEngineRow;
|
||||
definition: AutomationDefinition;
|
||||
}
|
||||
|
||||
async function loadEnabledFor(userId: string): Promise<LoadedAutomation[]> {
|
||||
try {
|
||||
const rows =
|
||||
await createCurrentAutomationRepository().listEnabledForUser(userId);
|
||||
const loaded: LoadedAutomation[] = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
loaded.push({
|
||||
row,
|
||||
definition: JSON.parse(row.definition) as AutomationDefinition,
|
||||
});
|
||||
} catch {
|
||||
// A malformed definition should not stop the others from evaluating.
|
||||
}
|
||||
}
|
||||
return loaded;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a selector covers a host. Ownership is checked by the caller. */
|
||||
function selectorCoversHost(
|
||||
selector: HostSelector | undefined,
|
||||
hostId: number,
|
||||
): boolean {
|
||||
if (!selector) return true;
|
||||
switch (selector.kind) {
|
||||
case "all":
|
||||
case "trigger":
|
||||
return true;
|
||||
case "host":
|
||||
return selector.hostId === hostId;
|
||||
case "hosts":
|
||||
return selector.hostIds.includes(hostId);
|
||||
case "fleet":
|
||||
// Fleet membership is resolved at execution time; evaluate optimistically
|
||||
// so a fleet-scoped trigger still reaches the engine.
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fire(
|
||||
automation: AutomationEngineRow,
|
||||
stateKey: string,
|
||||
triggerType: string,
|
||||
triggerContext: Record<string, unknown>,
|
||||
hostId?: number,
|
||||
): Promise<void> {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
await repository.upsertTriggerState({
|
||||
automationId: automation.id,
|
||||
stateKey,
|
||||
lastFiredAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
AutomationEngine.getInstance()
|
||||
.run({
|
||||
automationId: automation.id,
|
||||
triggerType,
|
||||
triggerContext,
|
||||
triggerHostId: hostId,
|
||||
})
|
||||
.catch((error) => {
|
||||
statsLogger.warn("Automation run failed to start", {
|
||||
operation: "automation_trigger_error",
|
||||
automationId: automation.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Metric samples. Called for every polled host, including hosts polled only
|
||||
* because an automation asked for them.
|
||||
*/
|
||||
export async function onMetrics(event: MetricEvent): Promise<void> {
|
||||
const automations = await loadEnabledFor(event.ownerUserId);
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const now = Date.now();
|
||||
|
||||
for (const { row, definition } of automations) {
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "metric_threshold") continue;
|
||||
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
|
||||
|
||||
const value = extractMetricValue(event.metrics, trigger.metric);
|
||||
if (value === null) continue;
|
||||
|
||||
const stateKey = metricStateKey(event.hostId, trigger.metric);
|
||||
const state = await repository.getTriggerState(row.id, stateKey);
|
||||
const breaching = compare(value, trigger.operator, trigger.value);
|
||||
|
||||
if (!breaching) {
|
||||
if (state?.breachStartedAt) {
|
||||
await repository.clearBreach(row.id, stateKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Open the dwell window on the first breaching sample.
|
||||
if (!state?.breachStartedAt) {
|
||||
await repository.upsertTriggerState({
|
||||
automationId: row.id,
|
||||
stateKey,
|
||||
breachStartedAt: new Date(now).toISOString(),
|
||||
lastValue: value,
|
||||
});
|
||||
if (trigger.forSeconds) continue;
|
||||
}
|
||||
|
||||
const breachStartedAt =
|
||||
state?.breachStartedAt ?? new Date(now).toISOString();
|
||||
if (!hasDwelled(breachStartedAt, trigger.forSeconds, now)) continue;
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
continue;
|
||||
|
||||
await fire(
|
||||
row,
|
||||
stateKey,
|
||||
"metric_threshold",
|
||||
{
|
||||
hostId: event.hostId,
|
||||
value,
|
||||
threshold: trigger.value,
|
||||
operator: trigger.operator,
|
||||
metric: trigger.metric.path,
|
||||
mount: "mount" in trigger.metric ? trigger.metric.mount : undefined,
|
||||
severity: severityForValue(value, trigger.severity),
|
||||
},
|
||||
event.hostId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Host reachability transitions. Only edges fire, never steady state. */
|
||||
export async function onStatus(event: StatusEvent): Promise<void> {
|
||||
const automations = await loadEnabledFor(event.ownerUserId);
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const now = Date.now();
|
||||
const observed = event.online ? "online" : "offline";
|
||||
|
||||
for (const { row, definition } of automations) {
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "host_status") continue;
|
||||
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
|
||||
|
||||
const stateKey = String(event.hostId);
|
||||
const state = await repository.getTriggerState(row.id, stateKey);
|
||||
|
||||
if (state?.lastObservedState === observed) continue;
|
||||
|
||||
await repository.upsertTriggerState({
|
||||
automationId: row.id,
|
||||
stateKey,
|
||||
lastObservedState: observed,
|
||||
});
|
||||
|
||||
// The first observation establishes a baseline rather than firing, so a
|
||||
// restart does not announce every host as though it just changed.
|
||||
if (!state?.lastObservedState) continue;
|
||||
if (trigger.to !== observed) continue;
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
continue;
|
||||
|
||||
await fire(
|
||||
row,
|
||||
stateKey,
|
||||
"host_status",
|
||||
{ hostId: event.hostId, status: observed },
|
||||
event.hostId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function onHealthCheck(event: HealthEvent): Promise<void> {
|
||||
const automations = await loadEnabledFor(event.userId);
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const now = Date.now();
|
||||
const observed = event.ok ? "recovered" : "failing";
|
||||
|
||||
for (const { row, definition } of automations) {
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "health_check") continue;
|
||||
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
|
||||
if (trigger.checkId && trigger.checkId !== event.checkId) continue;
|
||||
|
||||
const stateKey = `${event.hostId}:${event.checkId}`;
|
||||
const state = await repository.getTriggerState(row.id, stateKey);
|
||||
|
||||
if (state?.lastObservedState === observed) continue;
|
||||
|
||||
await repository.upsertTriggerState({
|
||||
automationId: row.id,
|
||||
stateKey,
|
||||
lastObservedState: observed,
|
||||
});
|
||||
|
||||
if (!state?.lastObservedState) continue;
|
||||
if (trigger.to !== observed) continue;
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
continue;
|
||||
|
||||
await fire(
|
||||
row,
|
||||
stateKey,
|
||||
"health_check",
|
||||
{
|
||||
hostId: event.hostId,
|
||||
checkId: event.checkId,
|
||||
state: observed,
|
||||
detail: event.detail,
|
||||
},
|
||||
event.hostId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function onDockerEvent(event: DockerEvent): Promise<void> {
|
||||
const automations = await loadEnabledFor(event.ownerUserId);
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const now = Date.now();
|
||||
|
||||
for (const { row, definition } of automations) {
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "docker_event") continue;
|
||||
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
|
||||
if (trigger.container && trigger.container !== event.container) continue;
|
||||
if (trigger.event !== event.event) continue;
|
||||
|
||||
const stateKey = `${event.hostId}:${event.container}`;
|
||||
const state = await repository.getTriggerState(row.id, stateKey);
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
continue;
|
||||
|
||||
await fire(
|
||||
row,
|
||||
stateKey,
|
||||
"docker_event",
|
||||
{
|
||||
hostId: event.hostId,
|
||||
container: event.container,
|
||||
event: event.event,
|
||||
},
|
||||
event.hostId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function onInternalEvent(event: InternalEvent): Promise<void> {
|
||||
const automations = await loadEnabledFor(event.userId);
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const now = Date.now();
|
||||
|
||||
for (const { row, definition } of automations) {
|
||||
const trigger = definition.trigger;
|
||||
if (trigger?.kind !== "internal_event") continue;
|
||||
if (trigger.event !== event.event) continue;
|
||||
if (
|
||||
event.hostId !== undefined &&
|
||||
!selectorCoversHost(trigger.hostSelector, event.hostId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stateKey = event.hostId ? String(event.hostId) : "global";
|
||||
const state = await repository.getTriggerState(row.id, stateKey);
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
continue;
|
||||
|
||||
await fire(
|
||||
row,
|
||||
stateKey,
|
||||
"internal_event",
|
||||
{ event: event.event, hostId: event.hostId, ...(event.details ?? {}) },
|
||||
event.hostId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let unsubscribers: Array<() => void> = [];
|
||||
|
||||
/**
|
||||
* Subscribes the engine to the event bus.
|
||||
*
|
||||
* The hosts modules publish onto the bus and know nothing about automations;
|
||||
* this is the other half of that arrangement, and it is what keeps the edge
|
||||
* one-directional (automations may import repositories, hosts modules must not
|
||||
* import automations).
|
||||
*
|
||||
* Handlers are async and the bus is fire-and-forget, so a rejection here is
|
||||
* logged by the bus rather than surfacing to whichever host feature emitted.
|
||||
*/
|
||||
export function subscribeAutomationTriggers(): void {
|
||||
if (unsubscribers.length > 0) return;
|
||||
|
||||
unsubscribers = [
|
||||
pluginEvents.on(TOPICS.hostMetrics, (payload) =>
|
||||
onMetrics(payload as MetricEvent),
|
||||
),
|
||||
pluginEvents.on(TOPICS.hostStatus, (payload) =>
|
||||
onStatus(payload as StatusEvent),
|
||||
),
|
||||
pluginEvents.on(TOPICS.hostHealthCheck, (payload) =>
|
||||
onHealthCheck(payload as HealthEvent),
|
||||
),
|
||||
pluginEvents.on(TOPICS.internalEvent, (payload) =>
|
||||
onInternalEvent(payload as InternalEvent),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function unsubscribeAutomationTriggers(): void {
|
||||
for (const unsubscribe of unsubscribers) unsubscribe();
|
||||
unsubscribers = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts that an enabled automation watches, so the poller knows to collect
|
||||
* metrics for them even when nobody is looking.
|
||||
*/
|
||||
export async function listAutomationWatchedHosts(): Promise<
|
||||
Map<number, string>
|
||||
> {
|
||||
const watched = new Map<number, string>();
|
||||
|
||||
try {
|
||||
const rows = await createCurrentAutomationRepository().listAllEnabled();
|
||||
for (const row of rows) {
|
||||
let definition: AutomationDefinition;
|
||||
try {
|
||||
definition = JSON.parse(row.definition) as AutomationDefinition;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trigger: Trigger | undefined = definition.trigger;
|
||||
// Only metric thresholds need heavy collection; status triggers are
|
||||
// already served by the cheap reachability probe.
|
||||
if (trigger?.kind !== "metric_threshold") continue;
|
||||
|
||||
const selector = trigger.hostSelector;
|
||||
if (selector?.kind === "host") {
|
||||
watched.set(selector.hostId, row.userId);
|
||||
} else if (selector?.kind === "hosts") {
|
||||
for (const hostId of selector.hostIds) watched.set(hostId, row.userId);
|
||||
}
|
||||
// Fleet and "all" selectors are resolved by the scheduler, which can
|
||||
// expand them without blocking this call.
|
||||
}
|
||||
} catch {
|
||||
return watched;
|
||||
}
|
||||
|
||||
return watched;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ import vaultRoutes from "./routes/vault.js";
|
||||
import secretSourceRoutes from "./routes/secret-sources.js";
|
||||
import alertRulesRoutes from "./routes/alert-rules-routes.js";
|
||||
import aiDispatch from "./routes/ai-dispatch.js";
|
||||
import automationsRoutes from "./routes/automations.js";
|
||||
import automationsDispatch from "./routes/automation-dispatch.js";
|
||||
import syncRoutes from "./routes/sync.js";
|
||||
import pluginApiRoutes from "./routes/plugin-api-routes.js";
|
||||
import pluginRoutes from "./routes/plugins.js";
|
||||
@@ -1776,7 +1776,7 @@ app.use("/vault", vaultRoutes);
|
||||
app.use("/secret-sources", secretSourceRoutes);
|
||||
// Before the alert routes, which are mounted at the root and would otherwise
|
||||
// have first claim on the path.
|
||||
app.use("/automations", automationsRoutes);
|
||||
app.use("/automations", automationsDispatch);
|
||||
app.use("/ai", aiDispatch);
|
||||
app.use("/", alertRulesRoutes);
|
||||
app.use("/sync", syncRoutes);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Mounted at /automations. Automations ships as a first-party in-process
|
||||
// plugin (plugins/automations) rather than a route file imported directly
|
||||
// here, so this module exists to keep /automations/* working at its original
|
||||
// path without handing the plugin a reference to the shared app instance. The
|
||||
// plugin runtime registers its router via registerAutomationsRouter when it
|
||||
// activates, and clears it on deactivate, so a disabled plugin falls back to
|
||||
// the 404 below.
|
||||
//
|
||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
||||
// to exactly one first-party router (the Automations plugin's own), and every
|
||||
// route in that router already applies authenticateJWT/requireDataAccess
|
||||
// itself (including the intentionally unauthenticated webhook route, which
|
||||
// gates on its own token instead), the same as every other router mounted
|
||||
// alongside it here (/users, /host, /fleets, ...). /plugin-api gates at the
|
||||
// mount point because it forwards to arbitrary third-party plugin code that
|
||||
// cannot be assumed to gate itself; that reasoning does not apply to a single
|
||||
// first-party router that already does.
|
||||
|
||||
import type { Request, Response, NextFunction, Router } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
let activeRouter: Router | null = null;
|
||||
|
||||
export function registerAutomationsRouter(router: Router): void {
|
||||
activeRouter = router;
|
||||
databaseLogger.info("Registered Automations plugin router", {
|
||||
operation: "automations_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterAutomationsRouter(): void {
|
||||
if (!activeRouter) return;
|
||||
activeRouter = null;
|
||||
databaseLogger.info("Unregistered Automations plugin router", {
|
||||
operation: "automations_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export default function automationsDispatch(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (!activeRouter) {
|
||||
res.status(404).json({ error: "Automations is not available" });
|
||||
return;
|
||||
}
|
||||
activeRouter(req, res, next);
|
||||
}
|
||||
@@ -1,822 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
Step,
|
||||
Trigger,
|
||||
} from "../../../types/automations.js";
|
||||
import { AUTOMATION_DEFINITION_VERSION } from "../../../types/automations.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
logAudit,
|
||||
} from "../../utils/audit-logger.js";
|
||||
import { createCurrentAutomationRepository } from "../repositories/factory.js";
|
||||
import type { AutomationRow } from "../repositories/automation-repository.js";
|
||||
import { AutomationEngine } from "../../automations/engine.js";
|
||||
import {
|
||||
computeNextDueAt,
|
||||
isValidCron,
|
||||
isValidTimezone,
|
||||
} from "../../automations/cron.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
const TRIGGER_KINDS = new Set([
|
||||
"metric_threshold",
|
||||
"host_status",
|
||||
"health_check",
|
||||
"schedule",
|
||||
"docker_event",
|
||||
"internal_event",
|
||||
"webhook",
|
||||
]);
|
||||
|
||||
const STEP_TYPES = new Set([
|
||||
"notify",
|
||||
"http",
|
||||
"run_snippet",
|
||||
"run_command",
|
||||
"docker",
|
||||
"tunnel",
|
||||
"wol",
|
||||
"wait",
|
||||
"set_var",
|
||||
"if",
|
||||
"run_automation",
|
||||
"stop",
|
||||
]);
|
||||
|
||||
const OPERATORS = new Set([
|
||||
">",
|
||||
"<",
|
||||
">=",
|
||||
"<=",
|
||||
"==",
|
||||
"!=",
|
||||
"contains",
|
||||
"not_contains",
|
||||
"changed",
|
||||
]);
|
||||
|
||||
function parseId(raw: unknown): number | null {
|
||||
const id = typeof raw === "string" ? parseInt(raw, 10) : NaN;
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a definition before it is stored. The engine treats the stored
|
||||
* blob as trusted, so everything it relies on is checked once here.
|
||||
*/
|
||||
export function validateDefinition(value: unknown): {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
definition?: AutomationDefinition;
|
||||
} {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return { ok: false, error: "Definition must be an object" };
|
||||
}
|
||||
|
||||
const candidate = value as Partial<AutomationDefinition>;
|
||||
const trigger = candidate.trigger as Trigger | undefined;
|
||||
|
||||
if (!trigger || !TRIGGER_KINDS.has(trigger.kind)) {
|
||||
return { ok: false, error: "Unknown or missing trigger kind" };
|
||||
}
|
||||
|
||||
if (trigger.kind === "metric_threshold") {
|
||||
if (!OPERATORS.has(trigger.operator)) {
|
||||
return { ok: false, error: "Unknown comparison operator" };
|
||||
}
|
||||
if (typeof trigger.value !== "number" || !Number.isFinite(trigger.value)) {
|
||||
return { ok: false, error: "Threshold value must be a number" };
|
||||
}
|
||||
if (!trigger.metric?.path) {
|
||||
return { ok: false, error: "Trigger is missing a metric" };
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.kind === "schedule") {
|
||||
const hasInterval =
|
||||
typeof trigger.intervalSeconds === "number" &&
|
||||
trigger.intervalSeconds > 0;
|
||||
const hasCron = isNonEmptyString(trigger.cron);
|
||||
if (!hasInterval && !hasCron) {
|
||||
return { ok: false, error: "Schedule needs an interval or a cron" };
|
||||
}
|
||||
if (hasCron && !isValidCron(trigger.cron as string)) {
|
||||
return { ok: false, error: "Cron expression is not valid" };
|
||||
}
|
||||
if (hasInterval && (trigger.intervalSeconds as number) < 60) {
|
||||
return { ok: false, error: "Interval must be at least 60 seconds" };
|
||||
}
|
||||
if (
|
||||
isNonEmptyString(trigger.timezone) &&
|
||||
!isValidTimezone(trigger.timezone)
|
||||
) {
|
||||
return { ok: false, error: "Time zone is not valid" };
|
||||
}
|
||||
}
|
||||
|
||||
const steps = candidate.steps;
|
||||
if (!Array.isArray(steps)) {
|
||||
return { ok: false, error: "Definition must include a steps array" };
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const stepError = validateSteps(steps as Step[], seen);
|
||||
if (stepError) return { ok: false, error: stepError };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
definition: {
|
||||
version: candidate.version ?? AUTOMATION_DEFINITION_VERSION,
|
||||
trigger,
|
||||
steps: steps as Step[],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateSteps(steps: Step[], seen: Set<string>): string | null {
|
||||
for (const step of steps) {
|
||||
if (!step || typeof step !== "object") return "Step must be an object";
|
||||
if (!isNonEmptyString(step.id)) return "Every step needs an id";
|
||||
if (seen.has(step.id)) return `Duplicate step id: ${step.id}`;
|
||||
seen.add(step.id);
|
||||
if (!STEP_TYPES.has(step.type)) {
|
||||
return `Unknown step type: ${step.type}`;
|
||||
}
|
||||
|
||||
if (step.type === "if") {
|
||||
if (!step.condition || !OPERATORS.has(step.condition.operator)) {
|
||||
return "Condition needs a valid operator";
|
||||
}
|
||||
const thenError = validateSteps(step.then ?? [], seen);
|
||||
if (thenError) return thenError;
|
||||
const elseError = validateSteps(step.else ?? [], seen);
|
||||
if (elseError) return elseError;
|
||||
}
|
||||
|
||||
if (step.type === "http" && !isNonEmptyString(step.url)) {
|
||||
return "HTTP steps need a URL";
|
||||
}
|
||||
if (step.type === "run_command" && !isNonEmptyString(step.command)) {
|
||||
return "Command steps need a command";
|
||||
}
|
||||
if (step.type === "wait" && typeof step.seconds !== "number") {
|
||||
return "Wait steps need a number of seconds";
|
||||
}
|
||||
if (step.type === "set_var" && !isNonEmptyString(step.name)) {
|
||||
return "Variable steps need a name";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Never leak a webhook token hash to the client. */
|
||||
function serialize(row: AutomationRow) {
|
||||
let definition: AutomationDefinition | null = null;
|
||||
try {
|
||||
definition = JSON.parse(row.definition) as AutomationDefinition;
|
||||
} catch {
|
||||
definition = null;
|
||||
}
|
||||
|
||||
if (definition?.trigger?.kind === "webhook") {
|
||||
definition = {
|
||||
...definition,
|
||||
trigger: { ...definition.trigger, tokenHash: "" },
|
||||
};
|
||||
}
|
||||
|
||||
return { ...row, definition };
|
||||
}
|
||||
|
||||
async function syncSchedule(
|
||||
automationId: number,
|
||||
definition: AutomationDefinition,
|
||||
): Promise<void> {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
if (definition.trigger?.kind !== "schedule") {
|
||||
await repository.deleteSchedule(automationId);
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger = definition.trigger;
|
||||
await repository.upsertSchedule({
|
||||
automationId,
|
||||
cron: trigger.cron ?? null,
|
||||
intervalSeconds: trigger.intervalSeconds ?? null,
|
||||
timezone: trigger.timezone ?? null,
|
||||
nextDueAt: computeNextDueAt({
|
||||
cron: trigger.cron,
|
||||
intervalSeconds: trigger.intervalSeconds,
|
||||
timezone: trigger.timezone,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations:
|
||||
* get:
|
||||
* summary: List the current user's automations
|
||||
* description: Returns every automation the caller owns, with its parsed definition and linked notification channels.
|
||||
* tags:
|
||||
* - Automations
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of automations.
|
||||
* 403:
|
||||
* description: Missing the automations.view permission.
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.view"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows = await createCurrentAutomationRepository().list(userId);
|
||||
res.json(rows.map(serialize));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to list automations", error, {
|
||||
operation: "automation_list_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list automations" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/{id}:
|
||||
* get:
|
||||
* summary: Fetch a single automation
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The automation.
|
||||
* 404:
|
||||
* description: Automation not found.
|
||||
*/
|
||||
router.get(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.view"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const row = await createCurrentAutomationRepository().findForUser(
|
||||
id,
|
||||
userId,
|
||||
);
|
||||
if (!row) return res.status(404).json({ error: "Automation not found" });
|
||||
res.json(serialize(row));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to fetch automation", error, {
|
||||
operation: "automation_get_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch automation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations:
|
||||
* post:
|
||||
* summary: Create an automation
|
||||
* description: Validates the trigger and every step before storing the definition. A schedule trigger also registers its next due time.
|
||||
* tags:
|
||||
* - Automations
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* definition:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: The created automation.
|
||||
* 400:
|
||||
* description: Validation failed.
|
||||
*/
|
||||
router.post(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.create"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, description, enabled, definition, concurrencyPolicy } =
|
||||
req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Name is required" });
|
||||
}
|
||||
|
||||
const validated = validateDefinition(definition);
|
||||
if (!validated.ok || !validated.definition) {
|
||||
return res.status(400).json({ error: validated.error });
|
||||
}
|
||||
|
||||
// A webhook trigger's token is shown once here and only stored hashed.
|
||||
let webhookToken: string | undefined;
|
||||
if (validated.definition.trigger.kind === "webhook") {
|
||||
webhookToken = crypto.randomBytes(32).toString("hex");
|
||||
validated.definition = {
|
||||
...validated.definition,
|
||||
trigger: {
|
||||
kind: "webhook",
|
||||
tokenHash: hashToken(webhookToken),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const created = await repository.create({
|
||||
userId,
|
||||
name: name.trim(),
|
||||
description: description ?? null,
|
||||
enabled: enabled !== false,
|
||||
definition: JSON.stringify(validated.definition),
|
||||
concurrencyPolicy,
|
||||
channels: Array.isArray(req.body?.channels) ? req.body.channels : [],
|
||||
});
|
||||
|
||||
await syncSchedule(created.id, validated.definition);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "create_automation",
|
||||
resourceType: "automation",
|
||||
resourceId: String(created.id),
|
||||
resourceName: created.name,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.status(201).json({ ...serialize(created), webhookToken });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to create automation", error, {
|
||||
operation: "automation_create_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create automation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/{id}:
|
||||
* put:
|
||||
* summary: Update an automation
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The updated automation.
|
||||
* 400:
|
||||
* description: Validation failed.
|
||||
* 404:
|
||||
* description: Automation not found.
|
||||
*/
|
||||
router.put(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.edit"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const existing = await repository.findForUser(id, userId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Automation not found" });
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = {};
|
||||
if (req.body?.name !== undefined) {
|
||||
if (!isNonEmptyString(req.body.name)) {
|
||||
return res.status(400).json({ error: "Name cannot be empty" });
|
||||
}
|
||||
update.name = req.body.name.trim();
|
||||
}
|
||||
if (req.body?.description !== undefined) {
|
||||
update.description = req.body.description;
|
||||
}
|
||||
if (req.body?.enabled !== undefined) update.enabled = !!req.body.enabled;
|
||||
if (req.body?.concurrencyPolicy !== undefined) {
|
||||
update.concurrencyPolicy = req.body.concurrencyPolicy;
|
||||
}
|
||||
if (Array.isArray(req.body?.channels)) update.channels = req.body.channels;
|
||||
|
||||
let parsedDefinition: AutomationDefinition | null = null;
|
||||
if (req.body?.definition !== undefined) {
|
||||
const validated = validateDefinition(req.body.definition);
|
||||
if (!validated.ok || !validated.definition) {
|
||||
return res.status(400).json({ error: validated.error });
|
||||
}
|
||||
|
||||
// Keep the stored token hash: the raw token is only ever shown once.
|
||||
if (validated.definition.trigger.kind === "webhook") {
|
||||
const previous = JSON.parse(
|
||||
existing.definition,
|
||||
) as AutomationDefinition;
|
||||
const previousHash =
|
||||
previous.trigger?.kind === "webhook"
|
||||
? previous.trigger.tokenHash
|
||||
: "";
|
||||
validated.definition = {
|
||||
...validated.definition,
|
||||
trigger: { kind: "webhook", tokenHash: previousHash },
|
||||
};
|
||||
}
|
||||
|
||||
parsedDefinition = validated.definition;
|
||||
update.definition = JSON.stringify(validated.definition);
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await repository.update(id, userId, update);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "Automation not found" });
|
||||
}
|
||||
|
||||
if (parsedDefinition) await syncSchedule(id, parsedDefinition);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "update_automation",
|
||||
resourceType: "automation",
|
||||
resourceId: String(id),
|
||||
resourceName: updated.name,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json(serialize(updated));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to update automation", error, {
|
||||
operation: "automation_update_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update automation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/{id}:
|
||||
* delete:
|
||||
* summary: Delete an automation
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Deleted.
|
||||
* 404:
|
||||
* description: Automation not found.
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.delete"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const deleted = await createCurrentAutomationRepository().delete(
|
||||
id,
|
||||
userId,
|
||||
);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: "Automation not found" });
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "delete_automation",
|
||||
resourceType: "automation",
|
||||
resourceId: String(id),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to delete automation", error, {
|
||||
operation: "automation_delete_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete automation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/{id}/run:
|
||||
* post:
|
||||
* summary: Run an automation now
|
||||
* description: Runs immediately, as the automation's owner. Pass dryRun to record what each step would do without touching anything outside Termix.
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* dryRun:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The run outcome.
|
||||
* 404:
|
||||
* description: Automation not found.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/run",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.run"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
const existing = await createCurrentAutomationRepository().findForUser(
|
||||
id,
|
||||
userId,
|
||||
);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Automation not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: id,
|
||||
triggerType: "manual",
|
||||
triggerContext: { manual: true, requestedBy: userId },
|
||||
dryRun: req.body?.dryRun === true,
|
||||
});
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "run_automation",
|
||||
resourceType: "automation",
|
||||
resourceId: String(id),
|
||||
resourceName: existing.name,
|
||||
details: JSON.stringify({
|
||||
status: outcome.status,
|
||||
dryRun: req.body?.dryRun === true,
|
||||
}),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: outcome.status === "success",
|
||||
});
|
||||
|
||||
res.json(outcome);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to run automation", error, {
|
||||
operation: "automation_run_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to run automation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/runs:
|
||||
* get:
|
||||
* summary: List automation runs
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: automationId
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recent runs, newest first.
|
||||
*/
|
||||
router.get(
|
||||
"/runs/history",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.view"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const runs = await createCurrentAutomationRepository().listRuns(userId, {
|
||||
automationId: parseId(req.query.automationId) ?? undefined,
|
||||
limit: Number(req.query.limit) || 50,
|
||||
offset: Number(req.query.offset) || 0,
|
||||
});
|
||||
res.json(runs);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to list automation runs", error, {
|
||||
operation: "automation_runs_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list runs" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/runs/{runId}/steps:
|
||||
* get:
|
||||
* summary: Step-by-step results for a run
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: runId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The run's steps in order.
|
||||
* 404:
|
||||
* description: Run not found.
|
||||
*/
|
||||
router.get(
|
||||
"/runs/:runId/steps",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.view"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const runId = parseId(req.params.runId);
|
||||
if (runId === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const run = await repository.findRunForUser(runId, userId);
|
||||
if (!run) return res.status(404).json({ error: "Run not found" });
|
||||
|
||||
res.json(await repository.listRunSteps(runId));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to list run steps", error, {
|
||||
operation: "automation_run_steps_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list run steps" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/webhook/{token}:
|
||||
* post:
|
||||
* summary: Trigger an automation from an external system
|
||||
* description: Unauthenticated by design; the 32-byte token in the path is the credential and is compared against a stored hash in constant time.
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: token
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 202:
|
||||
* description: The run was accepted.
|
||||
* 404:
|
||||
* description: No automation matches that token.
|
||||
*/
|
||||
router.post("/webhook/:token", async (req: Request, res: Response) => {
|
||||
const token = req.params.token;
|
||||
if (!isNonEmptyString(token) || token.length < 32) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const candidates = await repository.listAllEnabled();
|
||||
const wanted = hashToken(token);
|
||||
|
||||
const match = candidates.find((row) => {
|
||||
try {
|
||||
const definition = JSON.parse(row.definition) as AutomationDefinition;
|
||||
if (definition.trigger?.kind !== "webhook") return false;
|
||||
return timingSafeEqual(definition.trigger.tokenHash, wanted);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!match) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: match.id,
|
||||
triggerType: "webhook",
|
||||
triggerContext: {
|
||||
body: req.body ?? {},
|
||||
receivedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
res.status(202).json({ runId: outcome.runId, status: outcome.status });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Webhook automation failed", error, {
|
||||
operation: "automation_webhook_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to run automation" });
|
||||
}
|
||||
});
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
const left = Buffer.from(a || "", "utf8");
|
||||
const right = Buffer.from(b || "", "utf8");
|
||||
if (left.length !== right.length) return false;
|
||||
return crypto.timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -75,6 +75,14 @@
|
||||
* reads/writes and a zip archive for its push/pull routes, and buffer
|
||||
* multipart file uploads. None of that fits the worker's single-exec
|
||||
* ctx.ssh or single-postMessage-reply ctx.http.route.
|
||||
*
|
||||
* automations is here for the same reason as ai, proxmox and fleets: it does
|
||||
* not own a transport, but its engine and scheduler write across hosts,
|
||||
* snippets, fleets and notification-channel repositories through the shared
|
||||
* SSH pool via resolveHostById and withConnection, and its scheduler needs
|
||||
* direct repository access plus subscription to the cross-plugin event bus
|
||||
* (host metrics, status and health-check events) that the worker's read-only
|
||||
* ctx.hosts and single-postMessage-reply ctx.http.route cannot support.
|
||||
*/
|
||||
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||
"ssh-terminal",
|
||||
@@ -84,6 +92,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||
"proxmox",
|
||||
"remote-desktop",
|
||||
"fleets",
|
||||
"automations",
|
||||
]);
|
||||
|
||||
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
|
||||
|
||||
+9
-11
@@ -299,12 +299,15 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
// ssh-terminal, docker and host-metrics plugins start their own servers,
|
||||
// so disabling any of them stops its WS/HTTP server. See
|
||||
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
|
||||
// AI, Proxmox, Remote Desktop and Fleets are also absent for the same
|
||||
// reason: each plugin registers its router with its own dispatcher in
|
||||
// database.ts on activate (/ai, /proxmox, /guacamole, /fleets), so
|
||||
// disabling any of them makes its routes 404 instead of leaving a dead
|
||||
// import here. See plugins/ai, plugins/proxmox, plugins/remote-desktop
|
||||
// and plugins/fleets.
|
||||
// AI, Proxmox, Remote Desktop, Fleets and Automations are also absent for
|
||||
// the same reason: each plugin registers its router with its own
|
||||
// dispatcher in database.ts on activate (/ai, /proxmox, /guacamole,
|
||||
// /fleets, /automations), so disabling any of them makes its routes 404
|
||||
// instead of leaving a dead import here. Automations' scheduler (due
|
||||
// schedules, dwell rechecks, docker-event polling, history pruning) also
|
||||
// starts from its own activate() rather than here. See plugins/ai,
|
||||
// plugins/proxmox, plugins/remote-desktop, plugins/fleets and
|
||||
// plugins/automations.
|
||||
await import("./hosts/tunnel/index.js");
|
||||
await import("./hosts/file-manager/index.js");
|
||||
await import("./hosts/tmux/index.js");
|
||||
@@ -323,11 +326,6 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// After metrics, which the automation triggers and headless polling hook into.
|
||||
const { startAutomationScheduler } =
|
||||
await import("./automations/scheduler.js");
|
||||
startAutomationScheduler();
|
||||
|
||||
// Last, so a plugin's activate() sees a fully wired server. A plugin that
|
||||
// fails to load must not stop the backend, so this never rejects.
|
||||
try {
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import {
|
||||
metricStateKey,
|
||||
severityForValue,
|
||||
type MetricsSnapshot,
|
||||
} from "../../automations/conditions.js";
|
||||
} from "../../../../../plugins/automations/backend/conditions.js";
|
||||
|
||||
const metrics: MetricsSnapshot = {
|
||||
cpu: { percent: 42.5, load: [1.5, 1.2, 0.9] },
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
isValidTimezone,
|
||||
nextCronRun,
|
||||
parseCron,
|
||||
} from "../../automations/cron.js";
|
||||
} from "../../../../../plugins/automations/backend/cron.js";
|
||||
|
||||
describe("parseCron", () => {
|
||||
it("rejects anything that is not five fields", () => {
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
diffContainerStates,
|
||||
parseContainerStates,
|
||||
} from "../../automations/docker-watcher.js";
|
||||
} from "../../../../../plugins/automations/backend/docker-watcher.js";
|
||||
|
||||
/**
|
||||
* The polling side needs SSH, so what is tested here is the pure part: turning
|
||||
+9
-5
@@ -1,5 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AutomationDefinition, Step } from "../../../types/automations.js";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
Step,
|
||||
} from "../../../../types/automations.js";
|
||||
|
||||
/**
|
||||
* The engine reaches the database through the repository factory and the
|
||||
@@ -49,20 +52,21 @@ const repository = {
|
||||
|
||||
const resolveHostById = vi.fn();
|
||||
|
||||
vi.mock("../../hosts/host-resolver.js", () => ({
|
||||
vi.mock("../../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => repository,
|
||||
}));
|
||||
|
||||
const executeStep = vi.fn();
|
||||
vi.mock("../../automations/actions/index.js", () => ({
|
||||
vi.mock("../../../../../plugins/automations/backend/actions/index.js", () => ({
|
||||
executeStep: (...args: unknown[]) => executeStep(...args),
|
||||
}));
|
||||
|
||||
const { AutomationEngine } = await import("../../automations/engine.js");
|
||||
const { AutomationEngine } =
|
||||
await import("../../../../../plugins/automations/backend/engine.js");
|
||||
|
||||
function defineAutomation(
|
||||
steps: Step[],
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listAutomationWatchedHosts = vi.fn();
|
||||
vi.mock("../../automations/triggers.js", () => ({
|
||||
vi.mock("../../../../../plugins/automations/backend/triggers.js", () => ({
|
||||
listAutomationWatchedHosts: () => listAutomationWatchedHosts(),
|
||||
}));
|
||||
|
||||
@@ -10,7 +10,8 @@ const {
|
||||
reconcileHeadlessViewers,
|
||||
releaseHeadlessViewers,
|
||||
setViewerRegistry,
|
||||
} = await import("../../automations/headless-viewer.js");
|
||||
} =
|
||||
await import("../../../../../plugins/automations/backend/headless-viewer.js");
|
||||
|
||||
const registry = {
|
||||
registerViewer: vi.fn(),
|
||||
+2
-2
@@ -2,12 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const automationFetch = vi.fn();
|
||||
|
||||
vi.mock("../../automations/http.js", () => ({
|
||||
vi.mock("../../../../../plugins/automations/backend/http.js", () => ({
|
||||
automationFetch: (...args: unknown[]) => automationFetch(...args),
|
||||
}));
|
||||
|
||||
const { sendAutomationNotification } =
|
||||
await import("../../automations/notify.js");
|
||||
await import("../../../../../plugins/automations/backend/notify.js");
|
||||
|
||||
beforeEach(() => {
|
||||
automationFetch.mockReset();
|
||||
+8
-18
@@ -44,10 +44,15 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
}));
|
||||
|
||||
const run = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../../automations/engine.js", () => ({
|
||||
vi.mock("../../../../../plugins/automations/backend/engine.js", () => ({
|
||||
AutomationEngine: { getInstance: () => ({ run }) },
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/automation-dispatch.js", () => ({
|
||||
registerAutomationsRouter: vi.fn(),
|
||||
unregisterAutomationsRouter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
@@ -80,23 +85,8 @@ vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/automations.js");
|
||||
|
||||
function findHandler(method: string, path: string) {
|
||||
const stack = (router as unknown as Router).stack as Array<{
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: Array<{ handle: (req: Request, res: Response) => unknown }>;
|
||||
};
|
||||
}>;
|
||||
const layer = stack.find(
|
||||
(l) => l.route?.path === path && l.route?.methods[method],
|
||||
);
|
||||
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
||||
return layer.route.stack[layer.route.stack.length - 1].handle;
|
||||
}
|
||||
const { router } =
|
||||
await import("../../../../../plugins/automations/backend/routes.js");
|
||||
|
||||
/** Runs the whole middleware chain for the route, not just its handler. */
|
||||
async function invoke(
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
||||
redactSecrets,
|
||||
renderRecord,
|
||||
renderTemplate,
|
||||
} from "../../automations/template.js";
|
||||
} from "../../../../../plugins/automations/backend/template.js";
|
||||
|
||||
const context = {
|
||||
host: { id: 7, name: "Zeus", ip: "10.0.0.5", username: "root", port: 22 },
|
||||
+5
-4
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
Trigger,
|
||||
} from "../../../types/automations.js";
|
||||
} from "../../../../types/automations.js";
|
||||
|
||||
/**
|
||||
* Trigger matching, dwell windows and cooldowns. The repository and the engine
|
||||
@@ -42,16 +42,17 @@ const repository = {
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => repository,
|
||||
}));
|
||||
|
||||
const run = vi.fn(async () => ({ runId: 1, status: "success" as const }));
|
||||
vi.mock("../../automations/engine.js", () => ({
|
||||
vi.mock("../../../../../plugins/automations/backend/engine.js", () => ({
|
||||
AutomationEngine: { getInstance: () => ({ run }) },
|
||||
}));
|
||||
|
||||
const triggers = await import("../../automations/triggers.js");
|
||||
const triggers =
|
||||
await import("../../../../../plugins/automations/backend/triggers.js");
|
||||
|
||||
function addAutomation(trigger: Trigger, overrides: Partial<FakeRow> = {}) {
|
||||
const definition: AutomationDefinition = { version: 1, trigger, steps: [] };
|
||||
@@ -283,3 +283,44 @@ describe("bundled fleets plugin", () => {
|
||||
expect(source).toMatch(/export async function deactivate/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundled automations plugin", () => {
|
||||
it("ships a directory with a manifest and both entry points", () => {
|
||||
const dir = path.join(getBundledPluginsDir(), "automations");
|
||||
|
||||
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
|
||||
});
|
||||
|
||||
it("has a manifest that passes the real validator", () => {
|
||||
const { manifest, errors } = parseManifest(
|
||||
readBundledManifest("automations"),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(manifest?.id).toBe("automations");
|
||||
expect(manifest?.category).toBe("Infrastructure");
|
||||
});
|
||||
|
||||
it("declares the transport-owner capability and qualifies for the tier", () => {
|
||||
const { manifest } = parseManifest(readBundledManifest("automations"));
|
||||
|
||||
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
|
||||
expect(isFirstParty("automations")).toBe(true);
|
||||
expect(runsInProcess("automations", manifest!.permissions)).toBe(true);
|
||||
});
|
||||
|
||||
it("exports activate and deactivate from its backend entry", () => {
|
||||
const entry = path.join(
|
||||
getBundledPluginsDir(),
|
||||
"automations",
|
||||
"backend",
|
||||
"index.mjs",
|
||||
);
|
||||
const source = fs.readFileSync(entry, "utf8");
|
||||
|
||||
expect(source).toMatch(/export async function activate/);
|
||||
expect(source).toMatch(/export async function deactivate/);
|
||||
});
|
||||
});
|
||||
@@ -39,9 +39,10 @@ function manifest(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe("first-party allowlist", () => {
|
||||
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop and fleets", () => {
|
||||
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets and automations", () => {
|
||||
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
|
||||
"ai",
|
||||
"automations",
|
||||
"docker",
|
||||
"fleets",
|
||||
"host-metrics",
|
||||
@@ -51,7 +52,7 @@ describe("first-party allowlist", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop and fleets and nothing else", () => {
|
||||
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets and automations and nothing else", () => {
|
||||
expect(isFirstParty("ssh-terminal")).toBe(true);
|
||||
expect(isFirstParty("docker")).toBe(true);
|
||||
expect(isFirstParty("host-metrics")).toBe(true);
|
||||
@@ -59,6 +60,7 @@ describe("first-party allowlist", () => {
|
||||
expect(isFirstParty("proxmox")).toBe(true);
|
||||
expect(isFirstParty("remote-desktop")).toBe(true);
|
||||
expect(isFirstParty("fleets")).toBe(true);
|
||||
expect(isFirstParty("automations")).toBe(true);
|
||||
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
||||
expect(isFirstParty("community-plugin")).toBe(false);
|
||||
expect(isFirstParty("")).toBe(false);
|
||||
@@ -79,6 +81,10 @@ describe("runsInProcess", () => {
|
||||
expect(runsInProcess("remote-desktop", [TRANSPORT_OWNER_CAPABILITY])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(runsInProcess("fleets", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
|
||||
expect(runsInProcess("automations", [TRANSPORT_OWNER_CAPABILITY])).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// On the list but not asking for it: stays in a worker, so the manifest
|
||||
// remains an honest description of what the plugin does.
|
||||
@@ -88,6 +94,8 @@ describe("runsInProcess", () => {
|
||||
expect(runsInProcess("ai", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("proxmox", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("remote-desktop", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("fleets", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("automations", ["hosts.read"])).toBe(false);
|
||||
|
||||
// Asking for it but not on the list.
|
||||
expect(
|
||||
@@ -179,6 +187,18 @@ describe("manifest gate on the reserved capability", () => {
|
||||
expect(parsed?.id).toBe("proxmox");
|
||||
});
|
||||
|
||||
it("allows it for automations", () => {
|
||||
const { manifest: parsed, errors } = parseManifest(
|
||||
manifest({
|
||||
id: "automations",
|
||||
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(parsed?.id).toBe("automations");
|
||||
});
|
||||
|
||||
it("still accepts an ordinary manifest that never mentions it", () => {
|
||||
const { manifest: parsed, errors } = parseManifest(
|
||||
manifest({ permissions: ["hosts.read"] }),
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ const WorkspacesPanel = lazy(() =>
|
||||
})),
|
||||
);
|
||||
const AutomationsPanel = lazy(() =>
|
||||
import("@/sidebar/AutomationsPanel").then((m) => ({
|
||||
import("../../plugins/automations/frontend/AutomationsPanel").then((m) => ({
|
||||
default: m.AutomationsPanel,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
ConcurrencyPolicy,
|
||||
RunStatus,
|
||||
StepStatus,
|
||||
StepType,
|
||||
TriggerKind,
|
||||
} from "@/types/automations";
|
||||
|
||||
export interface AutomationRow {
|
||||
id: number;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
enabled: number;
|
||||
definition: AutomationDefinition | null;
|
||||
definition_version: number;
|
||||
concurrency_policy: ConcurrencyPolicy;
|
||||
max_run_seconds: number;
|
||||
dry_run: number;
|
||||
last_run_at: string | null;
|
||||
last_run_status: RunStatus | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
channels: number[];
|
||||
/** Returned once, only when a webhook automation is created. */
|
||||
webhookToken?: string;
|
||||
}
|
||||
|
||||
export interface AutomationRunRow {
|
||||
id: number;
|
||||
automation_id: number;
|
||||
user_id: string;
|
||||
trigger_type: TriggerKind | "manual";
|
||||
trigger_context: string | null;
|
||||
status: RunStatus;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
error: string | null;
|
||||
dry_run: number;
|
||||
parent_run_id: number | null;
|
||||
automation_name?: string | null;
|
||||
}
|
||||
|
||||
export interface AutomationRunStepRow {
|
||||
id: number;
|
||||
run_id: number;
|
||||
step_index: number;
|
||||
step_id: string;
|
||||
step_type: StepType;
|
||||
status: StepStatus;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
output: string | null;
|
||||
error: string | null;
|
||||
truncated: number;
|
||||
}
|
||||
|
||||
export interface AutomationRunOutcome {
|
||||
runId: number | null;
|
||||
status: RunStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AutomationInput {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
definition: AutomationDefinition;
|
||||
concurrencyPolicy?: ConcurrencyPolicy;
|
||||
channels?: number[];
|
||||
}
|
||||
|
||||
export async function listAutomations(): Promise<AutomationRow[]> {
|
||||
try {
|
||||
return (await authApi.get("/automations")).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch automations");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAutomation(
|
||||
input: AutomationInput,
|
||||
): Promise<AutomationRow> {
|
||||
try {
|
||||
return (await authApi.post("/automations", input)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create automation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAutomation(
|
||||
id: number,
|
||||
input: Partial<AutomationInput>,
|
||||
): Promise<AutomationRow> {
|
||||
try {
|
||||
return (await authApi.put(`/automations/${id}`, input)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update automation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAutomation(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/automations/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete automation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAutomation(
|
||||
id: number,
|
||||
options: { dryRun?: boolean } = {},
|
||||
): Promise<AutomationRunOutcome> {
|
||||
try {
|
||||
return (await authApi.post(`/automations/${id}/run`, options)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "run automation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAutomationRuns(
|
||||
options: { automationId?: number; limit?: number; offset?: number } = {},
|
||||
): Promise<AutomationRunRow[]> {
|
||||
try {
|
||||
return (await authApi.get("/automations/runs/history", { params: options }))
|
||||
.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch automation runs");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAutomationRunSteps(
|
||||
runId: number,
|
||||
): Promise<AutomationRunStepRow[]> {
|
||||
try {
|
||||
return (await authApi.get(`/automations/runs/${runId}/steps`)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch run steps");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import { getSnippets } from "@/api/snippets-api";
|
||||
import { listAutomations } from "@/api/automations-api";
|
||||
import { listAutomations } from "../../../../plugins/automations/frontend/automations-api";
|
||||
|
||||
/**
|
||||
* Backs the @-mention picker in the composer.
|
||||
|
||||
@@ -175,9 +175,11 @@ const SshToolsPanel = lazy(() =>
|
||||
import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })),
|
||||
);
|
||||
const AutomationsPanel = lazy(() =>
|
||||
import("@/sidebar/AutomationsPanel").then((m) => ({
|
||||
default: m.AutomationsPanel,
|
||||
})),
|
||||
import("../../../plugins/automations/frontend/AutomationsPanel").then(
|
||||
(m) => ({
|
||||
default: m.AutomationsPanel,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const AiPanel = lazy(() =>
|
||||
|
||||
@@ -1,675 +0,0 @@
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Copy,
|
||||
FlaskConical,
|
||||
Loader2,
|
||||
Play,
|
||||
Plus,
|
||||
Trash2,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Badge } from "@/components/badge";
|
||||
import {
|
||||
createAutomation,
|
||||
deleteAutomation,
|
||||
listAutomationRunSteps,
|
||||
listAutomationRuns,
|
||||
listAutomations,
|
||||
runAutomation,
|
||||
updateAutomation,
|
||||
type AutomationRow,
|
||||
type AutomationRunRow,
|
||||
type AutomationRunStepRow,
|
||||
} from "@/api/automations-api";
|
||||
import {
|
||||
deleteNotificationChannel,
|
||||
getNotificationChannels,
|
||||
testNotificationChannel,
|
||||
type NotificationChannel,
|
||||
} from "@/api/alerts-api";
|
||||
import { NotificationChannelDialog } from "./NotificationChannelDialog";
|
||||
import { getSnippets } from "@/api/snippets-api";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import { listFleets } from "@/api/fleets-api";
|
||||
import {
|
||||
AutomationEditor,
|
||||
emptyDraft,
|
||||
type AutomationDraft,
|
||||
} from "./automations/AutomationEditor";
|
||||
import {
|
||||
EMPTY_EDITOR_OPTIONS,
|
||||
type AutomationEditorOptions,
|
||||
} from "./automations/editor-types";
|
||||
|
||||
type PanelTab = "automations" | "runs" | "channels";
|
||||
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
success: "text-green-500",
|
||||
failed: "text-destructive",
|
||||
timeout: "text-destructive",
|
||||
running: "text-foreground",
|
||||
skipped: "text-muted-foreground",
|
||||
cancelled: "text-muted-foreground",
|
||||
};
|
||||
|
||||
function timeAgo(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (!Number.isFinite(ms)) return "";
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
return `${Math.floor(hr / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function AutomationsPanel({
|
||||
active = true,
|
||||
onEditingChange,
|
||||
}: {
|
||||
active?: boolean;
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const base = "newUi.sidebar.automations";
|
||||
|
||||
const [tab, setTab] = useState<PanelTab>("automations");
|
||||
const [automations, setAutomations] = useState<AutomationRow[]>([]);
|
||||
const [runs, setRuns] = useState<AutomationRunRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [options, setOptions] =
|
||||
useState<AutomationEditorOptions>(EMPTY_EDITOR_OPTIONS);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [draft, setDraft] = useState<AutomationDraft>(emptyDraft());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [webhookToken, setWebhookToken] = useState<string | null>(null);
|
||||
|
||||
const [expandedRun, setExpandedRun] = useState<number | null>(null);
|
||||
const [runSteps, setRunSteps] = useState<AutomationRunStepRow[]>([]);
|
||||
|
||||
const [channels, setChannels] = useState<NotificationChannel[]>([]);
|
||||
const [channelDialogOpen, setChannelDialogOpen] = useState(false);
|
||||
const [editingChannel, setEditingChannel] =
|
||||
useState<NotificationChannel | null>(null);
|
||||
|
||||
const loadAutomations = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setAutomations(await listAutomations());
|
||||
} catch {
|
||||
// A failed load leaves the previous list on screen.
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRuns = useCallback(async () => {
|
||||
try {
|
||||
setRuns(await listAutomationRuns({ limit: 100 }));
|
||||
} catch {
|
||||
// Non-fatal.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadOptions = useCallback(async () => {
|
||||
const [hosts, snippets, channelsResult, fleets] = await Promise.allSettled([
|
||||
getSSHHosts({ includeStatus: false }),
|
||||
getSnippets(),
|
||||
getNotificationChannels(),
|
||||
listFleets(),
|
||||
]);
|
||||
|
||||
if (channelsResult.status === "fulfilled") {
|
||||
setChannels(channelsResult.value);
|
||||
}
|
||||
|
||||
setOptions({
|
||||
hosts:
|
||||
hosts.status === "fulfilled"
|
||||
? hosts.value.map((host) => ({
|
||||
id: host.id,
|
||||
name: host.name || host.ip,
|
||||
}))
|
||||
: [],
|
||||
snippets:
|
||||
snippets.status === "fulfilled"
|
||||
? snippets.value.map((s) => ({
|
||||
id: s.id,
|
||||
// SnippetRow only types its id; the rest is narrowed by callers.
|
||||
name: typeof s.name === "string" ? s.name : `#${s.id}`,
|
||||
}))
|
||||
: [],
|
||||
channels:
|
||||
channelsResult.status === "fulfilled"
|
||||
? channelsResult.value.map((c) => ({ id: c.id, name: c.name }))
|
||||
: [],
|
||||
fleets:
|
||||
fleets.status === "fulfilled"
|
||||
? fleets.value.map((f) => ({ id: f.id, name: f.name }))
|
||||
: [],
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
void loadAutomations();
|
||||
void loadOptions();
|
||||
}, [active, loadAutomations, loadOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && tab === "runs") void loadRuns();
|
||||
}, [active, tab, loadRuns]);
|
||||
|
||||
// Widens the sidebar while the editor is open, and releases it again on the
|
||||
// way out or when the user switches to another rail destination.
|
||||
useEffect(() => {
|
||||
if (active) onEditingChange?.(editorOpen);
|
||||
}, [active, editorOpen, onEditingChange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => onEditingChange?.(false);
|
||||
}, [onEditingChange]);
|
||||
|
||||
function closeEditor() {
|
||||
setWebhookToken(null);
|
||||
setEditorOpen(false);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditingId(null);
|
||||
setDraft(emptyDraft());
|
||||
setWebhookToken(null);
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: AutomationRow) {
|
||||
setEditingId(row.id);
|
||||
setDraft({
|
||||
name: row.name,
|
||||
description: row.description ?? "",
|
||||
enabled: !!row.enabled,
|
||||
concurrencyPolicy: row.concurrency_policy ?? "skip",
|
||||
definition: row.definition ?? emptyDraft().definition,
|
||||
});
|
||||
setWebhookToken(null);
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!draft.name.trim()) {
|
||||
toast.error(t(`${base}.nameRequired`));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingId === null) {
|
||||
const created = await createAutomation({
|
||||
name: draft.name.trim(),
|
||||
description: draft.description || null,
|
||||
enabled: draft.enabled,
|
||||
concurrencyPolicy: draft.concurrencyPolicy,
|
||||
definition: draft.definition,
|
||||
});
|
||||
toast.success(t(`${base}.created`));
|
||||
if (created.webhookToken) {
|
||||
setWebhookToken(created.webhookToken);
|
||||
} else {
|
||||
setEditorOpen(false);
|
||||
}
|
||||
} else {
|
||||
await updateAutomation(editingId, {
|
||||
name: draft.name.trim(),
|
||||
description: draft.description || null,
|
||||
enabled: draft.enabled,
|
||||
concurrencyPolicy: draft.concurrencyPolicy,
|
||||
definition: draft.definition,
|
||||
});
|
||||
toast.success(t(`${base}.updated`));
|
||||
setEditorOpen(false);
|
||||
}
|
||||
await loadAutomations();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, String(error)));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: AutomationRow) {
|
||||
if (!confirm(t(`${base}.deleteConfirm`))) return;
|
||||
try {
|
||||
await deleteAutomation(row.id);
|
||||
toast.success(t(`${base}.deleted`));
|
||||
await loadAutomations();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
async function run(row: AutomationRow, dryRun: boolean) {
|
||||
try {
|
||||
const outcome = await runAutomation(row.id, { dryRun });
|
||||
toast.success(t(`${base}.runFinished`, { status: outcome.status }));
|
||||
await loadAutomations();
|
||||
if (tab === "runs") await loadRuns();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
async function testChannel(channel: NotificationChannel) {
|
||||
try {
|
||||
// Throws with the reason when delivery fails.
|
||||
await testNotificationChannel(channel.id);
|
||||
toast.success(t(`${base}.channelTestSent`));
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeChannel(channel: NotificationChannel) {
|
||||
if (!confirm(t(`${base}.deleteChannelConfirm`))) return;
|
||||
try {
|
||||
await deleteNotificationChannel(channel.id);
|
||||
toast.success(t(`${base}.channelDeleted`));
|
||||
await loadOptions();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRun(runId: number) {
|
||||
if (expandedRun === runId) {
|
||||
setExpandedRun(null);
|
||||
return;
|
||||
}
|
||||
setExpandedRun(runId);
|
||||
try {
|
||||
setRunSteps(await listAutomationRunSteps(runId));
|
||||
} catch {
|
||||
setRunSteps([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Editing takes over the whole panel and widens the sidebar, the same way the
|
||||
// host manager does, rather than opening a dialog over the app.
|
||||
if (editorOpen) {
|
||||
const webhookUrl = webhookToken
|
||||
? `${window.location.origin}/automations/webhook/${webhookToken}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<button
|
||||
onClick={closeEditor}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50 shrink-0"
|
||||
>
|
||||
<ArrowLeft className="size-3.5 shrink-0" />
|
||||
<span>{t(`${base}.backToAutomations`)}</span>
|
||||
{editingId !== null && (
|
||||
<span
|
||||
className="ml-auto font-semibold text-foreground truncate max-w-[200px]"
|
||||
title={draft.name}
|
||||
>
|
||||
{draft.name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3">
|
||||
{webhookToken ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">{t(`${base}.webhookTokenTitle`)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`${base}.webhookTokenDescription`)}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<code className="flex-1 p-2 bg-muted text-xs break-all">
|
||||
{webhookUrl}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-none border-border shrink-0"
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(webhookUrl);
|
||||
toast.success(t(`${base}.copied`));
|
||||
}}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<AutomationEditor
|
||||
draft={draft}
|
||||
options={options}
|
||||
onChange={setDraft}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 border-t border-border shrink-0">
|
||||
{webhookToken ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-none ml-auto h-9 px-6 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={closeEditor}
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-none border-border h-9 px-5"
|
||||
onClick={closeEditor}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-none ml-auto h-9 px-6 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={saving}
|
||||
onClick={save}
|
||||
>
|
||||
{saving && (
|
||||
<Loader2 size={14} className="mr-1.5 animate-spin" />
|
||||
)}
|
||||
{t(`${base}.save`)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-1 p-2 border-b border-border">
|
||||
{(["automations", "runs", "channels"] as PanelTab[]).map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant={tab === key ? "outline" : "ghost"}
|
||||
size="sm"
|
||||
className={`min-w-0 flex-shrink rounded-none h-7 px-2 text-xs ${
|
||||
tab === key
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setTab(key)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{t(
|
||||
`${base}.${key === "automations" ? "tabAutomations" : key === "runs" ? "tabRuns" : "tabChannels"}`,
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
<a
|
||||
href="https://docs.termix.site/features/automations/overview"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-auto shrink-0 text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{tab === "automations" && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-none w-full mb-2 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus size={14} className="mr-1" />
|
||||
{t(`${base}.createAutomation`)}
|
||||
</Button>
|
||||
|
||||
{loading && automations.length === 0 && (
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && automations.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground p-2">
|
||||
{t(`${base}.empty`)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{automations.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="border border-border p-2.5 hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Workflow size={14} className="shrink-0" />
|
||||
<button
|
||||
className="flex-1 text-left text-sm truncate"
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
{row.name}
|
||||
</button>
|
||||
{!row.enabled && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
off
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="text-[11px] text-muted-foreground flex-1 truncate">
|
||||
{row.definition
|
||||
? t(
|
||||
`${base}.triggerKinds.${row.definition.trigger.kind}`,
|
||||
)
|
||||
: ""}
|
||||
{row.last_run_status
|
||||
? ` · ${timeAgo(row.last_run_at)}`
|
||||
: ""}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-none"
|
||||
title={t(`${base}.testRun`)}
|
||||
onClick={() => run(row, true)}
|
||||
>
|
||||
<FlaskConical size={12} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-none"
|
||||
title={t(`${base}.runNow`)}
|
||||
onClick={() => run(row, false)}
|
||||
>
|
||||
<Play size={12} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-none"
|
||||
onClick={() => remove(row)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "runs" && (
|
||||
<div className="space-y-1">
|
||||
{runs.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground p-2">
|
||||
{t(`${base}.emptyRuns`)}
|
||||
</p>
|
||||
)}
|
||||
{runs.map((entry) => (
|
||||
<div key={entry.id} className="border border-border">
|
||||
<button
|
||||
className="w-full text-left p-2.5 hover:bg-muted/40"
|
||||
onClick={() => toggleRun(entry.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm flex-1 truncate">
|
||||
{entry.automation_name ?? `#${entry.automation_id}`}
|
||||
</span>
|
||||
{entry.dry_run ? (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t(`${base}.dryRunBadge`)}
|
||||
</Badge>
|
||||
) : null}
|
||||
<span
|
||||
className={`text-[11px] ${STATUS_CLASS[entry.status] ?? ""}`}
|
||||
>
|
||||
{t(`${base}.statuses.${entry.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{timeAgo(entry.started_at)}
|
||||
{entry.duration_ms !== null
|
||||
? ` · ${Math.round(entry.duration_ms / 100) / 10}s`
|
||||
: ""}
|
||||
</div>
|
||||
{entry.error && (
|
||||
<div className="text-[11px] text-destructive truncate">
|
||||
{entry.error}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedRun === entry.id && (
|
||||
<div className="border-t border-border p-2 space-y-1">
|
||||
{runSteps.map((step) => (
|
||||
<div key={step.id} className="text-[11px]">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground">
|
||||
{step.step_index + 1}.
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t(`${base}.stepTypes.${step.step_type}`, {
|
||||
defaultValue: step.step_type,
|
||||
})}
|
||||
</span>
|
||||
<span className={STATUS_CLASS[step.status] ?? ""}>
|
||||
{step.status}
|
||||
</span>
|
||||
</div>
|
||||
{step.output && (
|
||||
<pre className="mt-1 p-1 bg-muted overflow-x-auto whitespace-pre-wrap break-all text-[10px]">
|
||||
{step.output}
|
||||
</pre>
|
||||
)}
|
||||
{step.error && (
|
||||
<div className="text-destructive">{step.error}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "channels" && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-none w-full mb-2 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => {
|
||||
setEditingChannel(null);
|
||||
setChannelDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={14} className="mr-1" />
|
||||
{t(`${base}.addChannel`)}
|
||||
</Button>
|
||||
|
||||
{channels.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground p-2">
|
||||
{t(`${base}.emptyChannels`)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{channels.map((channel) => (
|
||||
<div
|
||||
key={channel.id}
|
||||
className="border border-border p-2.5 hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="flex-1 text-left text-sm truncate"
|
||||
onClick={() => {
|
||||
setEditingChannel(channel);
|
||||
setChannelDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{channel.name}
|
||||
</button>
|
||||
<Badge variant="outline" className="text-[10px] uppercase">
|
||||
{channel.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 rounded-none text-[11px]"
|
||||
onClick={() => testChannel(channel)}
|
||||
>
|
||||
{t(`${base}.testChannel`)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-none"
|
||||
onClick={() => removeChannel(channel)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NotificationChannelDialog
|
||||
open={channelDialogOpen}
|
||||
onOpenChange={setChannelDialogOpen}
|
||||
channel={editingChannel}
|
||||
onSaved={() => {
|
||||
setChannelDialogOpen(false);
|
||||
void loadOptions();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AutomationsPanel;
|
||||
@@ -1,258 +0,0 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Label } from "@/components/label";
|
||||
import { Switch } from "@/components/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import {
|
||||
AUTOMATION_DEFINITION_VERSION,
|
||||
type AutomationDefinition,
|
||||
type ConcurrencyPolicy,
|
||||
type Step,
|
||||
} from "@/types/automations";
|
||||
import { StepBlock } from "./StepBlock";
|
||||
import { TriggerCard, defaultTrigger } from "./TriggerCard";
|
||||
import { newStepId, type AutomationEditorOptions } from "./editor-types";
|
||||
|
||||
export interface AutomationDraft {
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
concurrencyPolicy: ConcurrencyPolicy;
|
||||
definition: AutomationDefinition;
|
||||
}
|
||||
|
||||
export function emptyDraft(): AutomationDraft {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
enabled: true,
|
||||
concurrencyPolicy: "skip",
|
||||
definition: {
|
||||
version: AUTOMATION_DEFINITION_VERSION,
|
||||
trigger: defaultTrigger("metric_threshold"),
|
||||
steps: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The automation builder: one trigger, then an ordered list of steps that can
|
||||
* nest through if/otherwise. A JSON view is available for anything the form
|
||||
* does not cover yet.
|
||||
*/
|
||||
export function AutomationEditor({
|
||||
draft,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
draft: AutomationDraft;
|
||||
options: AutomationEditorOptions;
|
||||
onChange: (next: AutomationDraft) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const base = "newUi.sidebar.automations";
|
||||
const [view, setView] = useState<"builder" | "json">("builder");
|
||||
const [jsonText, setJsonText] = useState("");
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
|
||||
function setDefinition(definition: AutomationDefinition) {
|
||||
onChange({ ...draft, definition });
|
||||
}
|
||||
|
||||
function setSteps(steps: Step[]) {
|
||||
setDefinition({ ...draft.definition, steps });
|
||||
}
|
||||
|
||||
function renderSteps(
|
||||
steps: Step[],
|
||||
onStepsChange: (next: Step[]) => void,
|
||||
depth: number,
|
||||
): React.ReactNode {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, index) => (
|
||||
<StepBlock
|
||||
key={step.id}
|
||||
step={step}
|
||||
index={index}
|
||||
total={steps.length}
|
||||
depth={depth}
|
||||
options={options}
|
||||
onChange={(next) => {
|
||||
const copy = [...steps];
|
||||
copy[index] = next;
|
||||
onStepsChange(copy);
|
||||
}}
|
||||
onRemove={() => onStepsChange(steps.filter((_, i) => i !== index))}
|
||||
onMove={(direction) => {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= steps.length) return;
|
||||
const copy = [...steps];
|
||||
[copy[index], copy[target]] = [copy[target], copy[index]];
|
||||
onStepsChange(copy);
|
||||
}}
|
||||
renderNested={renderSteps}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-none w-full border-border"
|
||||
onClick={() =>
|
||||
onStepsChange([
|
||||
...steps,
|
||||
{ id: newStepId(), type: "notify", channelIds: [] } as Step,
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus size={14} className="mr-1" />
|
||||
{t(`${base}.addStep`)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t(`${base}.nameLabel`)}
|
||||
</Label>
|
||||
<Input
|
||||
className="h-8 rounded-none"
|
||||
placeholder={t(`${base}.namePlaceholder`)}
|
||||
value={draft.name}
|
||||
onChange={(e) => onChange({ ...draft, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(enabled) => onChange({ ...draft, enabled })}
|
||||
/>
|
||||
{t(`${base}.enabledLabel`)}
|
||||
</label>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t(`${base}.concurrencyLabel`)}
|
||||
</Label>
|
||||
<Select
|
||||
value={draft.concurrencyPolicy}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
...draft,
|
||||
concurrencyPolicy: value as ConcurrencyPolicy,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-full rounded-none border-border"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skip">
|
||||
{t(`${base}.concurrency.skip`)}
|
||||
</SelectItem>
|
||||
<SelectItem value="queue">
|
||||
{t(`${base}.concurrency.queue`)}
|
||||
</SelectItem>
|
||||
<SelectItem value="allow">
|
||||
{t(`${base}.concurrency.allow`)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`${base}.concurrencyHint`)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`rounded-none ${
|
||||
view === "builder"
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand"
|
||||
: "border-border"
|
||||
}`}
|
||||
onClick={() => setView("builder")}
|
||||
>
|
||||
{t(`${base}.viewBuilder`)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`rounded-none ${
|
||||
view === "json"
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand"
|
||||
: "border-border"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setJsonText(JSON.stringify(draft.definition, null, 2));
|
||||
setJsonError(null);
|
||||
setView("json");
|
||||
}}
|
||||
>
|
||||
{t(`${base}.viewJson`)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{view === "builder" ? (
|
||||
<>
|
||||
<TriggerCard
|
||||
trigger={draft.definition.trigger}
|
||||
options={options}
|
||||
onChange={(trigger) =>
|
||||
setDefinition({ ...draft.definition, trigger })
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t(`${base}.steps`)}
|
||||
</Label>
|
||||
{draft.definition.steps.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`${base}.noSteps`)}
|
||||
</p>
|
||||
)}
|
||||
{renderSteps(draft.definition.steps, setSteps, 0)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
className="w-full min-h-64 bg-background border border-border p-2 font-mono text-xs"
|
||||
value={jsonText}
|
||||
onChange={(e) => {
|
||||
setJsonText(e.target.value);
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
setDefinition(parsed as AutomationDefinition);
|
||||
setJsonError(null);
|
||||
} catch (error) {
|
||||
setJsonError(getErrorMessage(error, String(error)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{jsonError && <p className="text-xs text-destructive">{jsonError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "@/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import type { HostSelector } from "@/types/automations";
|
||||
import type { AutomationEditorOptions } from "./editor-types";
|
||||
|
||||
/**
|
||||
* Picks which hosts a step acts on. "The host that triggered this" is the
|
||||
* common case, so it is offered first and used as the default.
|
||||
*/
|
||||
export function HostSelectorField({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
allowTrigger = true,
|
||||
}: {
|
||||
value: HostSelector | undefined;
|
||||
options: AutomationEditorOptions;
|
||||
onChange: (next: HostSelector) => void;
|
||||
allowTrigger?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const base = "newUi.sidebar.automations";
|
||||
const kind = value?.kind ?? (allowTrigger ? "trigger" : "host");
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t(`${base}.fields.host`)}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={kind}
|
||||
onValueChange={(next) =>
|
||||
onChange(defaultFor(next as HostSelector["kind"]))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="min-w-0 flex-1 rounded-none border-border"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allowTrigger && (
|
||||
<SelectItem value="trigger">
|
||||
{t(`${base}.hostSelector.trigger`)}
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem value="host">
|
||||
{t(`${base}.hostSelector.host`)}
|
||||
</SelectItem>
|
||||
<SelectItem value="fleet">
|
||||
{t(`${base}.hostSelector.fleet`)}
|
||||
</SelectItem>
|
||||
<SelectItem value="all">{t(`${base}.hostSelector.all`)}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{kind === "host" && (
|
||||
<Select
|
||||
value={
|
||||
value?.kind === "host" && value.hostId ? String(value.hostId) : ""
|
||||
}
|
||||
onValueChange={(next) =>
|
||||
onChange({ kind: "host", hostId: Number(next) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="min-w-0 flex-1 rounded-none border-border"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.hosts.map((host) => (
|
||||
<SelectItem key={host.id} value={String(host.id)}>
|
||||
{host.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{kind === "fleet" && (
|
||||
<Select
|
||||
value={
|
||||
value?.kind === "fleet" && value.fleetId
|
||||
? String(value.fleetId)
|
||||
: ""
|
||||
}
|
||||
onValueChange={(next) =>
|
||||
onChange({ kind: "fleet", fleetId: Number(next) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="min-w-0 flex-1 rounded-none border-border"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.fleets.map((fleet) => (
|
||||
<SelectItem key={fleet.id} value={String(fleet.id)}>
|
||||
{fleet.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultFor(kind: HostSelector["kind"]): HostSelector {
|
||||
switch (kind) {
|
||||
case "host":
|
||||
return { kind: "host", hostId: 0 };
|
||||
case "fleet":
|
||||
return { kind: "fleet", fleetId: 0 };
|
||||
case "hosts":
|
||||
return { kind: "hosts", hostIds: [] };
|
||||
case "all":
|
||||
return { kind: "all" };
|
||||
default:
|
||||
return { kind: "trigger" };
|
||||
}
|
||||
}
|
||||
@@ -1,585 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Label } from "@/components/label";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import { Switch } from "@/components/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import type { Step, StepType } from "@/types/automations";
|
||||
import { HostSelectorField } from "./HostSelectorField";
|
||||
import type { AutomationEditorOptions } from "./editor-types";
|
||||
|
||||
const STEP_TYPES: StepType[] = [
|
||||
"notify",
|
||||
"http",
|
||||
"run_command",
|
||||
"run_snippet",
|
||||
"docker",
|
||||
"tunnel",
|
||||
"wol",
|
||||
"wait",
|
||||
"set_var",
|
||||
"if",
|
||||
"stop",
|
||||
];
|
||||
|
||||
interface StepBlockProps {
|
||||
step: Step;
|
||||
index: number;
|
||||
total: number;
|
||||
depth: number;
|
||||
options: AutomationEditorOptions;
|
||||
onChange: (next: Step) => void;
|
||||
onRemove: () => void;
|
||||
onMove: (direction: -1 | 1) => void;
|
||||
renderNested: (
|
||||
steps: Step[],
|
||||
onChange: (next: Step[]) => void,
|
||||
depth: number,
|
||||
) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* One block in the automation's step list. Each step type renders only the
|
||||
* fields it actually uses, so the form stays readable as more types are added.
|
||||
*/
|
||||
export function StepBlock({
|
||||
step,
|
||||
index,
|
||||
total,
|
||||
depth,
|
||||
options,
|
||||
onChange,
|
||||
onRemove,
|
||||
onMove,
|
||||
renderNested,
|
||||
}: StepBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const base = "newUi.sidebar.automations";
|
||||
|
||||
function patch(changes: Partial<Step>) {
|
||||
onChange({ ...step, ...changes } as Step);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border border-border p-3 ${depth > 0 ? "border-l-2 border-l-primary/40" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Select
|
||||
value={step.type}
|
||||
onValueChange={(value) =>
|
||||
onChange(changeStepType(step, value as StepType))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="flex-1 rounded-none border-border"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STEP_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{t(`${base}.stepTypes.${type}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-none"
|
||||
disabled={index === 0}
|
||||
title={t(`${base}.moveUp`)}
|
||||
onClick={() => onMove(-1)}
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-none"
|
||||
disabled={index === total - 1}
|
||||
title={t(`${base}.moveDown`)}
|
||||
onClick={() => onMove(1)}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-none"
|
||||
title={t(`${base}.removeStep`)}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{step.type === "notify" && (
|
||||
<>
|
||||
<Field label={t(`${base}.fields.title`)}>
|
||||
<Input
|
||||
className="h-8 rounded-none border-border"
|
||||
value={step.title ?? ""}
|
||||
onChange={(e) =>
|
||||
patch({ title: e.target.value } as Partial<Step>)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t(`${base}.fields.message`)}>
|
||||
<Textarea
|
||||
className="rounded-none border-border min-h-16"
|
||||
value={step.body ?? ""}
|
||||
onChange={(e) =>
|
||||
patch({ body: e.target.value } as Partial<Step>)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t(`${base}.fields.channels`)}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.channels.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(`${base}.noChannelsHint`)}
|
||||
</span>
|
||||
)}
|
||||
{options.channels.map((channel) => {
|
||||
const checked = step.channelIds?.includes(channel.id);
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
patch({
|
||||
channelIds: checked
|
||||
? step.channelIds.filter((id) => id !== channel.id)
|
||||
: [...(step.channelIds ?? []), channel.id],
|
||||
} as Partial<Step>)
|
||||
}
|
||||
className={`px-2 py-1 text-xs border ${
|
||||
checked
|
||||
? "border-primary text-foreground"
|
||||
: "border-border text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{channel.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step.type === "http" && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={step.method}
|
||||
onValueChange={(value) =>
|
||||
patch({ method: value } as Partial<Step>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-28 rounded-none border-border"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["GET", "POST", "PUT", "PATCH", "DELETE"].map((method) => (
|
||||
<SelectItem key={method} value={method}>
|
||||
{method}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
className="h-8 flex-1 rounded-none border-border"
|
||||
placeholder="https://"
|
||||
value={step.url ?? ""}
|
||||
onChange={(e) =>
|
||||
patch({ url: e.target.value } as Partial<Step>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Field label={t(`${base}.fields.body`)}>
|
||||
<Textarea
|
||||
className="rounded-none border-border min-h-16 font-mono text-xs"
|
||||
value={step.body ?? ""}
|
||||
onChange={(e) =>
|
||||
patch({ body: e.target.value } as Partial<Step>)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<label className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<Switch
|
||||
checked={!!step.allowPrivateNetwork}
|
||||
onCheckedChange={(checked) =>
|
||||
patch({ allowPrivateNetwork: checked } as Partial<Step>)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{t(`${base}.fields.allowPrivateNetwork`)}
|
||||
<span className="block">
|
||||
{t(`${base}.fields.allowPrivateNetworkHint`)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step.type === "run_command" && (
|
||||
<>
|
||||
<HostSelectorField
|
||||
value={step.hostSelector}
|
||||
options={options}
|
||||
onChange={(hostSelector) =>
|
||||
patch({ hostSelector } as Partial<Step>)
|
||||
}
|
||||
/>
|
||||
<Field label={t(`${base}.fields.command`)}>
|
||||
<Textarea
|
||||
className="rounded-none border-border min-h-16 font-mono text-xs"
|
||||
value={step.command ?? ""}
|
||||
onChange={(e) =>
|
||||
patch({ command: e.target.value } as Partial<Step>)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<ElevatedToggle
|
||||
checked={!!step.elevated}
|
||||
onChange={(elevated) => patch({ elevated } as Partial<Step>)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step.type === "run_snippet" && (
|
||||
<>
|
||||
<HostSelectorField
|
||||
value={step.hostSelector}
|
||||
options={options}
|
||||
onChange={(hostSelector) =>
|
||||
patch({ hostSelector } as Partial<Step>)
|
||||
}
|
||||
/>
|
||||
<Field label={t(`${base}.fields.snippet`)}>
|
||||
<Select
|
||||
value={step.snippetId ? String(step.snippetId) : ""}
|
||||
onValueChange={(value) =>
|
||||
patch({ snippetId: Number(value) } as Partial<Step>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger size="sm" className="rounded-none border-border">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.snippets.map((snippet) => (
|
||||
<SelectItem key={snippet.id} value={String(snippet.id)}>
|
||||
{snippet.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<ElevatedToggle
|
||||
checked={!!step.elevated}
|
||||
onChange={(elevated) => patch({ elevated } as Partial<Step>)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step.type === "docker" && (
|
||||
<>
|
||||
<HostSelectorField
|
||||
value={step.hostSelector}
|
||||
options={options}
|
||||
onChange={(hostSelector) =>
|
||||
patch({ hostSelector } as Partial<Step>)
|
||||
}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={step.action}
|
||||
onValueChange={(value) =>
|
||||
patch({ action: value } as Partial<Step>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="min-w-0 flex-1 rounded-none border-border"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["start", "stop", "restart"].map((action) => (
|
||||
<SelectItem key={action} value={action}>
|
||||
{action}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
className="h-8 flex-1 rounded-none border-border"
|
||||
placeholder={t(`${base}.fields.container`)}
|
||||
value={step.container ?? ""}
|
||||
onChange={(e) =>
|
||||
patch({ container: e.target.value } as Partial<Step>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step.type === "tunnel" && (
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={step.action}
|
||||
onValueChange={(value) =>
|
||||
patch({ action: value } as Partial<Step>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="min-w-0 flex-1 rounded-none border-border"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="connect">connect</SelectItem>
|
||||
<SelectItem value="disconnect">disconnect</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
className="h-8 flex-1 rounded-none border-border"
|
||||
value={step.tunnelName ?? ""}
|
||||
onChange={(e) =>
|
||||
patch({ tunnelName: e.target.value } as Partial<Step>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.type === "wol" && (
|
||||
<Field label={t(`${base}.fields.host`)}>
|
||||
<Select
|
||||
value={step.hostId ? String(step.hostId) : ""}
|
||||
onValueChange={(value) =>
|
||||
patch({ hostId: Number(value) } as Partial<Step>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger size="sm" className="rounded-none border-border">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.hosts.map((host) => (
|
||||
<SelectItem key={host.id} value={String(host.id)}>
|
||||
{host.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{step.type === "wait" && (
|
||||
<Field label={t(`${base}.fields.seconds`)}>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="h-8 w-32 rounded-none border-border"
|
||||
value={step.seconds ?? 0}
|
||||
onChange={(e) =>
|
||||
patch({ seconds: Number(e.target.value) } as Partial<Step>)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{step.type === "set_var" && (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="h-8 w-40 rounded-none border-border"
|
||||
placeholder={t(`${base}.fields.variableName`)}
|
||||
value={step.name ?? ""}
|
||||
onChange={(e) => patch({ name: e.target.value } as Partial<Step>)}
|
||||
/>
|
||||
<Input
|
||||
className="h-8 flex-1 rounded-none border-border"
|
||||
placeholder={t(`${base}.fields.variableValue`)}
|
||||
value={step.value ?? ""}
|
||||
onChange={(e) =>
|
||||
patch({ value: e.target.value } as Partial<Step>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.type === "if" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="h-8 flex-1 rounded-none border-border font-mono text-xs"
|
||||
placeholder={t(`${base}.fields.conditionLeftPlaceholder`)}
|
||||
value={step.condition?.left ?? ""}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
condition: { ...step.condition, left: e.target.value },
|
||||
} as Partial<Step>)
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
value={step.condition?.operator ?? "=="}
|
||||
onValueChange={(value) =>
|
||||
patch({
|
||||
condition: { ...step.condition, operator: value },
|
||||
} as Partial<Step>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-28 rounded-none border-border"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[">", "<", ">=", "<=", "==", "!=", "contains"].map((op) => (
|
||||
<SelectItem key={op} value={op}>
|
||||
{op}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
className="h-8 flex-1 rounded-none border-border font-mono text-xs"
|
||||
placeholder={t(`${base}.fields.conditionRightPlaceholder`)}
|
||||
value={step.condition?.right ?? ""}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
condition: { ...step.condition, right: e.target.value },
|
||||
} as Partial<Step>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{renderNested(
|
||||
step.then ?? [],
|
||||
(next) => patch({ then: next } as Partial<Step>),
|
||||
depth + 1,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.type !== "stop" && (
|
||||
<Field label={t(`${base}.fields.onError`)}>
|
||||
<Select
|
||||
value={step.onError ?? "stop"}
|
||||
onValueChange={(value) =>
|
||||
patch({ onError: value } as Partial<Step>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="min-w-0 w-full rounded-none border-border"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stop">
|
||||
{t(`${base}.onError.stop`)}
|
||||
</SelectItem>
|
||||
<SelectItem value="continue">
|
||||
{t(`${base}.onError.continue`)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ElevatedToggle({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Switch checked={checked} onCheckedChange={onChange} />
|
||||
{t("newUi.sidebar.automations.fields.elevated")}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps the step id when the type changes, so run history stays linked. */
|
||||
function changeStepType(step: Step, type: StepType): Step {
|
||||
const next: Record<string, unknown> = { id: step.id, type };
|
||||
switch (type) {
|
||||
case "notify":
|
||||
next.channelIds = [];
|
||||
break;
|
||||
case "http":
|
||||
next.method = "POST";
|
||||
next.url = "";
|
||||
break;
|
||||
case "run_command":
|
||||
next.command = "";
|
||||
next.hostSelector = { kind: "trigger" };
|
||||
break;
|
||||
case "run_snippet":
|
||||
next.hostSelector = { kind: "trigger" };
|
||||
break;
|
||||
case "docker":
|
||||
next.action = "restart";
|
||||
next.container = "";
|
||||
next.hostSelector = { kind: "trigger" };
|
||||
break;
|
||||
case "tunnel":
|
||||
next.action = "connect";
|
||||
next.tunnelName = "";
|
||||
break;
|
||||
case "wait":
|
||||
next.seconds = 30;
|
||||
break;
|
||||
case "set_var":
|
||||
next.name = "";
|
||||
next.value = "";
|
||||
break;
|
||||
case "if":
|
||||
next.condition = { left: "", operator: "==", right: "" };
|
||||
next.then = [];
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return next as unknown as Step;
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Label } from "@/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import type { Trigger, TriggerKind } from "@/types/automations";
|
||||
import { HostSelectorField } from "./HostSelectorField";
|
||||
import type { AutomationEditorOptions } from "./editor-types";
|
||||
|
||||
const TRIGGER_KINDS: TriggerKind[] = [
|
||||
"metric_threshold",
|
||||
"host_status",
|
||||
"health_check",
|
||||
"schedule",
|
||||
"docker_event",
|
||||
"webhook",
|
||||
];
|
||||
|
||||
/** Sentinel for "no zone set", which means the server's own time. */
|
||||
const SERVER_TIMEZONE = "__server__";
|
||||
|
||||
/**
|
||||
* Zones offered for a cron schedule. Uses the runtime's own list where it is
|
||||
* available so the picker is complete, with a small fallback for older
|
||||
* browsers that do not expose supportedValuesOf.
|
||||
*/
|
||||
const TIMEZONES: string[] = (() => {
|
||||
try {
|
||||
const supported = (
|
||||
Intl as unknown as { supportedValuesOf?: (key: string) => string[] }
|
||||
).supportedValuesOf?.("timeZone");
|
||||
if (supported?.length) return supported;
|
||||
} catch {
|
||||
// Fall through to the short list.
|
||||
}
|
||||
return [
|
||||
"UTC",
|
||||
"America/New_York",
|
||||
"America/Chicago",
|
||||
"America/Denver",
|
||||
"America/Los_Angeles",
|
||||
"Europe/London",
|
||||
"Europe/Berlin",
|
||||
"Europe/Moscow",
|
||||
"Asia/Kolkata",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Tokyo",
|
||||
"Australia/Sydney",
|
||||
];
|
||||
})();
|
||||
|
||||
const METRIC_PATHS = [
|
||||
"cpu.percent",
|
||||
"memory.percent",
|
||||
"disk.percent",
|
||||
"temperature.highestCelsius",
|
||||
"cpu.load1",
|
||||
"processes.total",
|
||||
"network.rxRateBps",
|
||||
"network.txRateBps",
|
||||
];
|
||||
|
||||
/** The single trigger at the top of an automation. */
|
||||
export function TriggerCard({
|
||||
trigger,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
trigger: Trigger;
|
||||
options: AutomationEditorOptions;
|
||||
onChange: (next: Trigger) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const base = "newUi.sidebar.automations";
|
||||
|
||||
function patch(changes: Record<string, unknown>) {
|
||||
onChange({ ...trigger, ...changes } as Trigger);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border p-3 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t(`${base}.trigger`)}
|
||||
</Label>
|
||||
<Select
|
||||
value={trigger.kind}
|
||||
onValueChange={(value) =>
|
||||
onChange(defaultTrigger(value as TriggerKind))
|
||||
}
|
||||
>
|
||||
<SelectTrigger size="sm" className="rounded-none border-border">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRIGGER_KINDS.map((kind) => (
|
||||
<SelectItem key={kind} value={kind}>
|
||||
{t(`${base}.triggerKinds.${kind}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{trigger.kind === "metric_threshold" && (
|
||||
<>
|
||||
<HostSelectorField
|
||||
value={trigger.hostSelector}
|
||||
options={options}
|
||||
allowTrigger={false}
|
||||
onChange={(hostSelector) => patch({ hostSelector })}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={trigger.metric?.path ?? "cpu.percent"}
|
||||
onValueChange={(value) =>
|
||||
patch({ metric: { ...trigger.metric, path: value } })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="flex-1 rounded-none border-border"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{METRIC_PATHS.map((path) => (
|
||||
<SelectItem key={path} value={path}>
|
||||
{path}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={trigger.operator}
|
||||
onValueChange={(value) => patch({ operator: value })}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-20 rounded-none border-border"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[">", ">=", "<", "<=", "==", "!="].map((op) => (
|
||||
<SelectItem key={op} value={op}>
|
||||
{op}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-8 w-24 rounded-none border-border"
|
||||
value={trigger.value ?? 0}
|
||||
onChange={(e) => patch({ value: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{trigger.metric?.path === "disk.percent" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t(`${base}.fields.mount`)}
|
||||
</Label>
|
||||
<Input
|
||||
className="h-8 rounded-none border-border font-mono text-xs"
|
||||
placeholder="/data"
|
||||
value={trigger.metric?.mount ?? ""}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
metric: {
|
||||
...trigger.metric,
|
||||
mount: e.target.value || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trigger.metric?.path.startsWith("network.") &&
|
||||
"iface" in trigger.metric && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t(`${base}.fields.interface`)}
|
||||
</Label>
|
||||
<Input
|
||||
className="h-8 rounded-none border-border font-mono text-xs"
|
||||
placeholder="eth0"
|
||||
value={trigger.metric.iface ?? ""}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
metric: {
|
||||
...trigger.metric,
|
||||
iface: e.target.value || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<NumberField
|
||||
label={t(`${base}.fields.forSeconds`)}
|
||||
value={trigger.forSeconds ?? 0}
|
||||
onChange={(value) => patch({ forSeconds: value })}
|
||||
/>
|
||||
<NumberField
|
||||
label={t(`${base}.fields.cooldown`)}
|
||||
value={trigger.cooldownMinutes ?? 15}
|
||||
onChange={(value) => patch({ cooldownMinutes: value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(trigger.kind === "host_status" || trigger.kind === "health_check") && (
|
||||
<>
|
||||
<HostSelectorField
|
||||
value={trigger.hostSelector}
|
||||
options={options}
|
||||
allowTrigger={false}
|
||||
onChange={(hostSelector) => patch({ hostSelector })}
|
||||
/>
|
||||
<Select
|
||||
value={trigger.to}
|
||||
onValueChange={(value) => patch({ to: value })}
|
||||
>
|
||||
<SelectTrigger size="sm" className="rounded-none border-border">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{trigger.kind === "host_status" ? (
|
||||
<>
|
||||
<SelectItem value="offline">offline</SelectItem>
|
||||
<SelectItem value="online">online</SelectItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SelectItem value="failing">failing</SelectItem>
|
||||
<SelectItem value="recovered">recovered</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<NumberField
|
||||
label={t(`${base}.fields.cooldown`)}
|
||||
value={trigger.cooldownMinutes ?? 15}
|
||||
onChange={(value) => patch({ cooldownMinutes: value })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{trigger.kind === "schedule" && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t(`${base}.fields.cron`)}
|
||||
</Label>
|
||||
<Input
|
||||
className="h-8 rounded-none border-border font-mono text-xs"
|
||||
placeholder="0 2 * * *"
|
||||
value={trigger.cron ?? ""}
|
||||
onChange={(e) =>
|
||||
patch({ cron: e.target.value, intervalSeconds: undefined })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<NumberField
|
||||
label={t(`${base}.fields.intervalSeconds`)}
|
||||
value={trigger.intervalSeconds ?? 0}
|
||||
onChange={(value) =>
|
||||
patch({ intervalSeconds: value || undefined, cron: undefined })
|
||||
}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t(`${base}.fields.timezone`)}
|
||||
</Label>
|
||||
<Select
|
||||
value={trigger.timezone ?? SERVER_TIMEZONE}
|
||||
onValueChange={(value) =>
|
||||
patch({
|
||||
timezone: value === SERVER_TIMEZONE ? undefined : value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-full rounded-none border-border"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={SERVER_TIMEZONE}>
|
||||
{t(`${base}.fields.timezoneServer`)}
|
||||
</SelectItem>
|
||||
{TIMEZONES.map((zone) => (
|
||||
<SelectItem key={zone} value={zone}>
|
||||
{zone}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`${base}.fields.timezoneHint`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trigger.kind === "docker_event" && (
|
||||
<>
|
||||
<HostSelectorField
|
||||
value={trigger.hostSelector}
|
||||
options={options}
|
||||
allowTrigger={false}
|
||||
onChange={(hostSelector) => patch({ hostSelector })}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="h-8 flex-1 rounded-none border-border"
|
||||
placeholder={t(`${base}.fields.container`)}
|
||||
value={trigger.container ?? ""}
|
||||
onChange={(e) => patch({ container: e.target.value })}
|
||||
/>
|
||||
<Select
|
||||
value={trigger.event}
|
||||
onValueChange={(value) => patch({ event: value })}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="min-w-0 flex-1 rounded-none border-border"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["exited", "started", "unhealthy", "restarting"].map((e) => (
|
||||
<SelectItem key={e} value={e}>
|
||||
{e}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{trigger.kind === "webhook" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`${base}.webhookTokenDescription`)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8 rounded-none border-border"
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function defaultTrigger(kind: TriggerKind): Trigger {
|
||||
switch (kind) {
|
||||
case "host_status":
|
||||
return {
|
||||
kind: "host_status",
|
||||
hostSelector: { kind: "all" },
|
||||
to: "offline",
|
||||
cooldownMinutes: 15,
|
||||
};
|
||||
case "health_check":
|
||||
return {
|
||||
kind: "health_check",
|
||||
hostSelector: { kind: "all" },
|
||||
to: "failing",
|
||||
cooldownMinutes: 15,
|
||||
};
|
||||
case "schedule":
|
||||
return { kind: "schedule", cron: "0 2 * * *" };
|
||||
case "docker_event":
|
||||
return {
|
||||
kind: "docker_event",
|
||||
hostSelector: { kind: "all" },
|
||||
event: "exited",
|
||||
cooldownMinutes: 15,
|
||||
};
|
||||
case "webhook":
|
||||
return { kind: "webhook", tokenHash: "" };
|
||||
default:
|
||||
return {
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "all" },
|
||||
metric: { path: "cpu.percent" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
forSeconds: 300,
|
||||
cooldownMinutes: 15,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/** Lookup data the editor needs to turn ids into names. */
|
||||
export interface AutomationEditorOptions {
|
||||
hosts: Array<{ id: number; name: string }>;
|
||||
snippets: Array<{ id: number; name: string }>;
|
||||
channels: Array<{ id: number; name: string }>;
|
||||
fleets: Array<{ id: number; name: string }>;
|
||||
}
|
||||
|
||||
export const EMPTY_EDITOR_OPTIONS: AutomationEditorOptions = {
|
||||
hosts: [],
|
||||
snippets: [],
|
||||
channels: [],
|
||||
fleets: [],
|
||||
};
|
||||
|
||||
/** Short unique id for a new step, stable for the life of the automation. */
|
||||
export function newStepId(): string {
|
||||
return `s${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultTrigger } from "@/sidebar/automations/TriggerCard";
|
||||
import { emptyDraft } from "@/sidebar/automations/AutomationEditor";
|
||||
import { newStepId } from "@/sidebar/automations/editor-types";
|
||||
import { defaultTrigger } from "../../../../../plugins/automations/frontend/automations/TriggerCard";
|
||||
import { emptyDraft } from "../../../../../plugins/automations/frontend/automations/AutomationEditor";
|
||||
import { newStepId } from "../../../../../plugins/automations/frontend/automations/editor-types";
|
||||
import { AUTOMATION_DEFINITION_VERSION } from "@/types/automations";
|
||||
|
||||
describe("emptyDraft", () => {
|
||||
Reference in new issue
Block a user