mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25: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
@@ -5,7 +5,7 @@ import {
|
|||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
createCurrentSnippetRepository,
|
createCurrentSnippetRepository,
|
||||||
} from "../../../../src/backend/database/repositories/factory.js";
|
} from "../../../../src/backend/database/repositories/factory.js";
|
||||||
import { validateDefinition } from "../../../../src/backend/database/routes/automations.js";
|
import { validateDefinition } from "../../../automations/backend/routes.js";
|
||||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||||
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
import type { HostSelector } from "../../../types/automations.js";
|
import type { HostSelector } from "../../../../src/types/automations.js";
|
||||||
import { resolveHostById } from "../../hosts/host-resolver.js";
|
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||||
import { createCurrentFleetRepository } from "../../database/repositories/factory.js";
|
import { createCurrentFleetRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
|
||||||
import type { StepExecutionContext } from "./types.js";
|
import type { StepExecutionContext } from "./types.js";
|
||||||
|
|
||||||
/** A host the caller is allowed to act on. `host` is always resolved. */
|
/** A host the caller is allowed to act on. `host` is always resolved. */
|
||||||
@@ -75,7 +75,7 @@ async function fleetHostIds(
|
|||||||
async function allAccessibleHostIds(userId: string): Promise<number[]> {
|
async function allAccessibleHostIds(userId: string): Promise<number[]> {
|
||||||
try {
|
try {
|
||||||
const { createCurrentHostRepository } =
|
const { createCurrentHostRepository } =
|
||||||
await import("../../database/repositories/factory.js");
|
await import("../../../../src/backend/database/repositories/factory.js");
|
||||||
const hosts = await createCurrentHostRepository().listByUserId(userId);
|
const hosts = await createCurrentHostRepository().listByUserId(userId);
|
||||||
const ids = hosts.map((host) => host.id);
|
const ids = hosts.map((host) => host.id);
|
||||||
const allowed =
|
const allowed =
|
||||||
+10
-9
@@ -1,22 +1,22 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_STEP_TIMEOUT_MS,
|
DEFAULT_STEP_TIMEOUT_MS,
|
||||||
type Step,
|
type Step,
|
||||||
} from "../../../types/automations.js";
|
} from "../../../../src/types/automations.js";
|
||||||
import { execCommand } from "../../hosts/metrics-shared/common-utils.js";
|
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||||
import {
|
import {
|
||||||
execElevated,
|
execElevated,
|
||||||
shellSingleQuote,
|
shellSingleQuote,
|
||||||
} from "../../hosts/metrics-shared/exec-elevated.js";
|
} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||||
import {
|
import {
|
||||||
createFleetSshFactory,
|
createFleetSshFactory,
|
||||||
getFleetPoolKey,
|
getFleetPoolKey,
|
||||||
} from "../../hosts/ssh-client-factory.js";
|
} from "../../../../src/backend/hosts/ssh-client-factory.js";
|
||||||
import { withConnection } from "../../hosts/ssh-connection-pool.js";
|
import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||||
import { resolveSnippetCommand } from "../../database/routes/snippets-execution.js";
|
import { resolveSnippetCommand } from "../../../../src/backend/database/routes/snippets-execution.js";
|
||||||
import {
|
import {
|
||||||
createCurrentAlertRepository,
|
createCurrentAlertRepository,
|
||||||
createCurrentSnippetRepository,
|
createCurrentSnippetRepository,
|
||||||
} from "../../database/repositories/factory.js";
|
} from "../../../../src/backend/database/repositories/factory.js";
|
||||||
import { sendAutomationNotification } from "../notify.js";
|
import { sendAutomationNotification } from "../notify.js";
|
||||||
import { automationFetch } from "../http.js";
|
import { automationFetch } from "../http.js";
|
||||||
import { renderRecord, renderTemplate } from "../template.js";
|
import { renderRecord, renderTemplate } from "../template.js";
|
||||||
@@ -247,7 +247,8 @@ async function runTunnel(
|
|||||||
if (context.dryRun) return ok(`Would ${step.action} tunnel ${name}`);
|
if (context.dryRun) return ok(`Would ${step.action} tunnel ${name}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const manager = await import("../../hosts/tunnel/manager.js");
|
const manager =
|
||||||
|
await import("../../../../src/backend/hosts/tunnel/manager.js");
|
||||||
const config = manager.tunnelConfigs?.get(name);
|
const config = manager.tunnelConfigs?.get(name);
|
||||||
if (!config) return fail(`Tunnel "${name}" is not configured`);
|
if (!config) return fail(`Tunnel "${name}" is not configured`);
|
||||||
|
|
||||||
@@ -283,7 +284,7 @@ async function runWol(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { sendWakeOnLan, isValidMac } =
|
const { sendWakeOnLan, isValidMac } =
|
||||||
await import("../../utils/wake-on-lan.js");
|
await import("../../../../src/backend/utils/wake-on-lan.js");
|
||||||
if (!isValidMac(mac)) return fail(`Invalid MAC address: ${mac}`);
|
if (!isValidMac(mac)) return fail(`Invalid MAC address: ${mac}`);
|
||||||
await sendWakeOnLan(mac);
|
await sendWakeOnLan(mac);
|
||||||
return ok(`Sent magic packet to ${target.name}`);
|
return ok(`Sent magic packet to ${target.name}`);
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { Step } from "../../../types/automations.js";
|
import type { Step } from "../../../../src/types/automations.js";
|
||||||
import type { TemplateContext } from "../template.js";
|
import type { TemplateContext } from "../template.js";
|
||||||
|
|
||||||
/** What every executor returns. `output` is what later steps can read. */
|
/** What every executor returns. `output` is what later steps can read. */
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { MetricPath, Operator } from "../../types/automations.js";
|
import type { MetricPath, Operator } from "../../../src/types/automations.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Operator evaluation and metric extraction.
|
* Operator evaluation and metric extraction.
|
||||||
File renamed without changes.
+8
-8
@@ -1,14 +1,14 @@
|
|||||||
import type { AutomationDefinition } from "../../types/automations.js";
|
import type { AutomationDefinition } from "../../../src/types/automations.js";
|
||||||
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
|
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||||
import { resolveHostById } from "../hosts/host-resolver.js";
|
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
|
||||||
import { DataCrypto } from "../utils/data-crypto.js";
|
import { DataCrypto } from "../../../src/backend/utils/data-crypto.js";
|
||||||
import { execCommand } from "../hosts/metrics-shared/common-utils.js";
|
import { execCommand } from "../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||||
import {
|
import {
|
||||||
createFleetSshFactory,
|
createFleetSshFactory,
|
||||||
getFleetPoolKey,
|
getFleetPoolKey,
|
||||||
} from "../hosts/ssh-client-factory.js";
|
} from "../../../src/backend/hosts/ssh-client-factory.js";
|
||||||
import { withConnection } from "../hosts/ssh-connection-pool.js";
|
import { withConnection } from "../../../src/backend/hosts/ssh-connection-pool.js";
|
||||||
import { statsLogger } from "../utils/logger.js";
|
import { statsLogger } from "../../../src/backend/utils/logger.js";
|
||||||
import { onDockerEvent } from "./triggers.js";
|
import { onDockerEvent } from "./triggers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2,16 +2,16 @@ import type {
|
|||||||
AutomationDefinition,
|
AutomationDefinition,
|
||||||
RunStatus,
|
RunStatus,
|
||||||
Step,
|
Step,
|
||||||
} from "../../types/automations.js";
|
} from "../../../src/types/automations.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_MAX_RUN_SECONDS,
|
DEFAULT_MAX_RUN_SECONDS,
|
||||||
MAX_AUTOMATION_DEPTH,
|
MAX_AUTOMATION_DEPTH,
|
||||||
MAX_STEP_OUTPUT_BYTES,
|
MAX_STEP_OUTPUT_BYTES,
|
||||||
} from "../../types/automations.js";
|
} from "../../../src/types/automations.js";
|
||||||
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
|
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||||
import { statsLogger } from "../utils/logger.js";
|
import { statsLogger } from "../../../src/backend/utils/logger.js";
|
||||||
import { resolveHostById } from "../hosts/host-resolver.js";
|
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
|
||||||
import { notifyAutomationInternalEvent } from "../hosts/automation-events.js";
|
import { notifyAutomationInternalEvent } from "../../../src/backend/hosts/automation-events.js";
|
||||||
import { executeStep } from "./actions/index.js";
|
import { executeStep } from "./actions/index.js";
|
||||||
import type { StepExecutionContext, StepResult } from "./actions/types.js";
|
import type { StepExecutionContext, StepResult } from "./actions/types.js";
|
||||||
import { compare } from "./conditions.js";
|
import { compare } from "./conditions.js";
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { statsLogger } from "../utils/logger.js";
|
import { statsLogger } from "../../../src/backend/utils/logger.js";
|
||||||
import { listAutomationWatchedHosts } from "./triggers.js";
|
import { listAutomationWatchedHosts } from "./triggers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
import { safeOutboundFetch } from "../../../src/backend/utils/safe-outbound-fetch.js";
|
||||||
import { readNotificationPrivateAllowlist } from "../utils/notification-egress.js";
|
import { readNotificationPrivateAllowlist } from "../../../src/backend/utils/notification-egress.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outbound HTTP for automation steps and notification channels.
|
* Outbound HTTP for automation steps and notification channels.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Automations - first-party, in-process plugin.
|
||||||
|
*
|
||||||
|
* This plugin runs on the main thread rather than in a worker for the same
|
||||||
|
* reason ai and fleets do (see src/backend/plugins/first-party.ts): 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 event-bus subscription that the worker ctx's
|
||||||
|
* read-only ctx.hosts and single-postMessage ctx.http.route cannot support.
|
||||||
|
*
|
||||||
|
* Like fleets, this plugin does not own a live transport (no WebSocket
|
||||||
|
* server, no dedicated port), so it registers its Express router with the
|
||||||
|
* always-mounted /automations dispatcher in
|
||||||
|
* src/backend/database/routes/automation-dispatch.ts instead of getting its
|
||||||
|
* own port.
|
||||||
|
*
|
||||||
|
* routes.ts is what used to be src/backend/database/routes/automations.ts,
|
||||||
|
* moved here and rewritten from `export default router` into
|
||||||
|
* startAutomationsService()/stopAutomationsService(), called from
|
||||||
|
* activate()/deactivate() below. scheduler.ts owns the one setInterval the
|
||||||
|
* feature has always used for due schedules, dwell rechecks, docker-event
|
||||||
|
* polling and history pruning -- there is no ctx.schedule API for an
|
||||||
|
* in-process plugin, so it starts and stops the same way it always has. The
|
||||||
|
* automations/automationTriggerState/automationSchedules/automationRuns/
|
||||||
|
* automationRunSteps/automationChannels tables and their repository stay in
|
||||||
|
* core (src/backend/database/db/schema.ts,
|
||||||
|
* src/backend/database/repositories/automation-repository.ts) -- this plugin
|
||||||
|
* system has no owned-table migration mechanism of its own, and these tables
|
||||||
|
* carry FKs into core's hosts/users tables.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a module by its path relative to this file's own directory.
|
||||||
|
*
|
||||||
|
* The plugin ships beside dist/backend, so in a built server the compiled
|
||||||
|
* .js for routes.js/scheduler.js sits right next to this .mjs, and core
|
||||||
|
* modules resolve via ../../../src/backend/... from there. Under
|
||||||
|
* vitest/dev nothing imports this plugin directly (the loader loads
|
||||||
|
* in-process plugins with a plain dynamic import, no tsx loader involved),
|
||||||
|
* so the only layout that needs to resolve here is the built one.
|
||||||
|
*/
|
||||||
|
async function loadRelative(relativePath) {
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const target = path.join(here, `${relativePath}.js`);
|
||||||
|
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
|
||||||
|
}
|
||||||
|
|
||||||
|
let routesModule = null;
|
||||||
|
let schedulerModule = null;
|
||||||
|
|
||||||
|
export async function activate(ctx) {
|
||||||
|
routesModule = await loadRelative("routes");
|
||||||
|
routesModule.startAutomationsService();
|
||||||
|
|
||||||
|
schedulerModule = await loadRelative("scheduler");
|
||||||
|
schedulerModule.startAutomationScheduler();
|
||||||
|
|
||||||
|
ctx.log.info(
|
||||||
|
"Automations router registered at /automations; scheduler started",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivate() {
|
||||||
|
if (schedulerModule) {
|
||||||
|
schedulerModule.stopAutomationScheduler();
|
||||||
|
schedulerModule = null;
|
||||||
|
}
|
||||||
|
if (routesModule) {
|
||||||
|
routesModule.stopAutomationsService();
|
||||||
|
routesModule = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { statsLogger } from "../utils/logger.js";
|
import { statsLogger } from "../../../src/backend/utils/logger.js";
|
||||||
import { automationFetch } from "./http.js";
|
import { automationFetch } from "./http.js";
|
||||||
import type { TemplateContext } from "./template.js";
|
import type { TemplateContext } from "./template.js";
|
||||||
|
|
||||||
+26
-16
@@ -1,30 +1,30 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response } from "express";
|
||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../src/types/index.js";
|
||||||
import type {
|
import type {
|
||||||
AutomationDefinition,
|
AutomationDefinition,
|
||||||
Step,
|
Step,
|
||||||
Trigger,
|
Trigger,
|
||||||
} from "../../../types/automations.js";
|
} from "../../../src/types/automations.js";
|
||||||
import { AUTOMATION_DEFINITION_VERSION } from "../../../types/automations.js";
|
import { AUTOMATION_DEFINITION_VERSION } from "../../../src/types/automations.js";
|
||||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
import { PermissionManager } from "../../../src/backend/utils/permission-manager.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
import { databaseLogger } from "../../../src/backend/utils/logger.js";
|
||||||
import {
|
import {
|
||||||
getAuditUsername,
|
getAuditUsername,
|
||||||
getRequestMeta,
|
getRequestMeta,
|
||||||
logAudit,
|
logAudit,
|
||||||
} from "../../utils/audit-logger.js";
|
} from "../../../src/backend/utils/audit-logger.js";
|
||||||
import { createCurrentAutomationRepository } from "../repositories/factory.js";
|
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||||
import type { AutomationRow } from "../repositories/automation-repository.js";
|
import type { AutomationRow } from "../../../src/backend/database/repositories/automation-repository.js";
|
||||||
import { AutomationEngine } from "../../automations/engine.js";
|
import { AutomationEngine } from "./engine.js";
|
||||||
|
import { computeNextDueAt, isValidCron, isValidTimezone } from "./cron.js";
|
||||||
import {
|
import {
|
||||||
computeNextDueAt,
|
registerAutomationsRouter,
|
||||||
isValidCron,
|
unregisterAutomationsRouter,
|
||||||
isValidTimezone,
|
} from "../../../src/backend/database/routes/automation-dispatch.js";
|
||||||
} from "../../automations/cron.js";
|
|
||||||
|
|
||||||
const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const permissionManager = PermissionManager.getInstance();
|
const permissionManager = PermissionManager.getInstance();
|
||||||
@@ -819,4 +819,14 @@ function timingSafeEqual(a: string, b: string): boolean {
|
|||||||
return crypto.timingSafeEqual(left, right);
|
return crypto.timingSafeEqual(left, right);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default router;
|
/** Called from activate(). Mounts this router at /automations via the shared
|
||||||
|
* dispatcher. */
|
||||||
|
export function startAutomationsService(): void {
|
||||||
|
registerAutomationsRouter(router);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called from deactivate(). /automations/* falls back to 404 until
|
||||||
|
* reactivated. */
|
||||||
|
export function stopAutomationsService(): void {
|
||||||
|
unregisterAutomationsRouter();
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { AutomationDefinition } from "../../types/automations.js";
|
import type { AutomationDefinition } from "../../../src/types/automations.js";
|
||||||
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
|
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||||
import { DataCrypto } from "../utils/data-crypto.js";
|
import { DataCrypto } from "../../../src/backend/utils/data-crypto.js";
|
||||||
import { statsLogger } from "../utils/logger.js";
|
import { statsLogger } from "../../../src/backend/utils/logger.js";
|
||||||
import { computeNextDueAt } from "./cron.js";
|
import { computeNextDueAt } from "./cron.js";
|
||||||
import { hasDwelled, isCoolingDown } from "./conditions.js";
|
import { hasDwelled, isCoolingDown } from "./conditions.js";
|
||||||
import { pollDockerEvents } from "./docker-watcher.js";
|
import { pollDockerEvents } from "./docker-watcher.js";
|
||||||
File renamed without changes.
@@ -2,10 +2,10 @@ import type {
|
|||||||
AutomationDefinition,
|
AutomationDefinition,
|
||||||
HostSelector,
|
HostSelector,
|
||||||
Trigger,
|
Trigger,
|
||||||
} from "../../types/automations.js";
|
} from "../../../src/types/automations.js";
|
||||||
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
|
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||||
import type { AutomationEngineRow } from "../database/repositories/automation-repository.js";
|
import type { AutomationEngineRow } from "../../../src/backend/database/repositories/automation-repository.js";
|
||||||
import { statsLogger } from "../utils/logger.js";
|
import { statsLogger } from "../../../src/backend/utils/logger.js";
|
||||||
import {
|
import {
|
||||||
compare,
|
compare,
|
||||||
extractMetricValue,
|
extractMetricValue,
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
type MetricsSnapshot,
|
type MetricsSnapshot,
|
||||||
} from "./conditions.js";
|
} from "./conditions.js";
|
||||||
import { AutomationEngine } from "./engine.js";
|
import { AutomationEngine } from "./engine.js";
|
||||||
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
import { pluginEvents, TOPICS } from "../../../src/backend/plugins/events.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Matches events against automation triggers and decides what fires.
|
* Matches events against automation triggers and decides what fires.
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
import { getErrorMessage } from "../lib/error-message.js";
|
import { getErrorMessage } from "@/lib/error-message.js";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -25,14 +25,14 @@ import {
|
|||||||
type AutomationRow,
|
type AutomationRow,
|
||||||
type AutomationRunRow,
|
type AutomationRunRow,
|
||||||
type AutomationRunStepRow,
|
type AutomationRunStepRow,
|
||||||
} from "@/api/automations-api";
|
} from "./automations-api";
|
||||||
import {
|
import {
|
||||||
deleteNotificationChannel,
|
deleteNotificationChannel,
|
||||||
getNotificationChannels,
|
getNotificationChannels,
|
||||||
testNotificationChannel,
|
testNotificationChannel,
|
||||||
type NotificationChannel,
|
type NotificationChannel,
|
||||||
} from "@/api/alerts-api";
|
} from "@/api/alerts-api";
|
||||||
import { NotificationChannelDialog } from "./NotificationChannelDialog";
|
import { NotificationChannelDialog } from "@/sidebar/NotificationChannelDialog";
|
||||||
import { getSnippets } from "@/api/snippets-api";
|
import { getSnippets } from "@/api/snippets-api";
|
||||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||||
import { listFleets } from "@/api/fleets-api";
|
import { listFleets } from "@/api/fleets-api";
|
||||||
File renamed without changes.
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { getErrorMessage } from "../../lib/error-message.js";
|
import { getErrorMessage } from "@/lib/error-message.js";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Frontend half of the automations plugin.
|
||||||
|
*
|
||||||
|
* AutomationsPanel is a left-sidebar railView panel (opened from the rail,
|
||||||
|
* not a Tab), the same shape as FleetsPanel/HostsPanel/AiPanel. None of those
|
||||||
|
* go through registerRailItem/registerTabComponent either -- per
|
||||||
|
* src/ui/shell/pluginLoader.ts's own comment, a rail-view panel like this is
|
||||||
|
* rendered directly by AppShell.tsx rather than registered at runtime, the
|
||||||
|
* same way ai's AiPanel is. AutomationsPanel is reached today through a
|
||||||
|
* direct relative import in AppShell.tsx
|
||||||
|
* (../../plugins/automations/frontend/AutomationsPanel) and in
|
||||||
|
* src/ui/shell/tabUtils.tsx (for its promotable/dockable tab surface), not
|
||||||
|
* through register() below.
|
||||||
|
*
|
||||||
|
* register()/unregister() still follow the same shape every other plugin's
|
||||||
|
* frontend entry does, so this is ready the moment a real frontend plugin
|
||||||
|
* loader exists -- see plugins/ssh-terminal/frontend/index.mjs and
|
||||||
|
* src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright
|
||||||
|
* "there is no plugin loader yet".
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const id = "automations";
|
||||||
|
export const railViewId = "automations";
|
||||||
|
|
||||||
|
export async function register({ registerRailItem, icons }) {
|
||||||
|
registerRailItem?.({
|
||||||
|
id: railViewId,
|
||||||
|
icon: icons.Workflow,
|
||||||
|
labelKey: "nav.automations",
|
||||||
|
separatorAfter: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregister({ unregisterRailItem }) {
|
||||||
|
unregisterRailItem?.(railViewId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"id": "automations",
|
||||||
|
"name": "Automations",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Trigger-and-action automation engine: schedules, metric/status/docker-event triggers, and multi-step actions across managed hosts.",
|
||||||
|
"author": { "name": "Termix" },
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": "https://github.com/Termix-SSH/Termix",
|
||||||
|
"category": "Infrastructure",
|
||||||
|
"icon": "Workflow",
|
||||||
|
"engine": { "termix": ">=2.9.0", "api": "1" },
|
||||||
|
"capabilities": {
|
||||||
|
"backend": true,
|
||||||
|
"frontend": true,
|
||||||
|
"electron": true,
|
||||||
|
"platforms": ["linux", "win32", "darwin"]
|
||||||
|
},
|
||||||
|
"permissions": ["process:transport-owner"],
|
||||||
|
"sidecars": []
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { pluginEvents, TOPICS } from "../../../src/backend/plugins/events.js";
|
import { pluginEvents, TOPICS } from "../../../src/backend/plugins/events.js";
|
||||||
import type { MetricsSnapshot } from "../../../src/backend/automations/conditions.js";
|
import type { MetricsSnapshot } from "../../automations/backend/conditions.js";
|
||||||
|
|
||||||
export interface HostMetricsPayload {
|
export interface HostMetricsPayload {
|
||||||
hostId: number;
|
hostId: number;
|
||||||
|
|||||||
@@ -3046,7 +3046,7 @@ app.post("/metrics/connect-totp", async (req, res) => {
|
|||||||
|
|
||||||
// Lets automations keep metrics flowing for hosts they watch, through the same
|
// Lets automations keep metrics flowing for hosts they watch, through the same
|
||||||
// viewer refcount the UI uses rather than around it.
|
// viewer refcount the UI uses rather than around it.
|
||||||
import("../../../src/backend/automations/headless-viewer.js")
|
import("../../automations/backend/headless-viewer.js")
|
||||||
.then(({ setViewerRegistry }) => {
|
.then(({ setViewerRegistry }) => {
|
||||||
setViewerRegistry({
|
setViewerRegistry({
|
||||||
registerViewer: (hostId, sessionId, userId) =>
|
registerViewer: (hostId, sessionId, userId) =>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import vaultRoutes from "./routes/vault.js";
|
|||||||
import secretSourceRoutes from "./routes/secret-sources.js";
|
import secretSourceRoutes from "./routes/secret-sources.js";
|
||||||
import alertRulesRoutes from "./routes/alert-rules-routes.js";
|
import alertRulesRoutes from "./routes/alert-rules-routes.js";
|
||||||
import aiDispatch from "./routes/ai-dispatch.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 syncRoutes from "./routes/sync.js";
|
||||||
import pluginApiRoutes from "./routes/plugin-api-routes.js";
|
import pluginApiRoutes from "./routes/plugin-api-routes.js";
|
||||||
import pluginRoutes from "./routes/plugins.js";
|
import pluginRoutes from "./routes/plugins.js";
|
||||||
@@ -1776,7 +1776,7 @@ app.use("/vault", vaultRoutes);
|
|||||||
app.use("/secret-sources", secretSourceRoutes);
|
app.use("/secret-sources", secretSourceRoutes);
|
||||||
// Before the alert routes, which are mounted at the root and would otherwise
|
// Before the alert routes, which are mounted at the root and would otherwise
|
||||||
// have first claim on the path.
|
// have first claim on the path.
|
||||||
app.use("/automations", automationsRoutes);
|
app.use("/automations", automationsDispatch);
|
||||||
app.use("/ai", aiDispatch);
|
app.use("/ai", aiDispatch);
|
||||||
app.use("/", alertRulesRoutes);
|
app.use("/", alertRulesRoutes);
|
||||||
app.use("/sync", syncRoutes);
|
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);
|
||||||
|
}
|
||||||
@@ -75,6 +75,14 @@
|
|||||||
* reads/writes and a zip archive for its push/pull routes, and buffer
|
* 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
|
* multipart file uploads. None of that fits the worker's single-exec
|
||||||
* ctx.ssh or single-postMessage-reply ctx.http.route.
|
* 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([
|
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||||
"ssh-terminal",
|
"ssh-terminal",
|
||||||
@@ -84,6 +92,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
|||||||
"proxmox",
|
"proxmox",
|
||||||
"remote-desktop",
|
"remote-desktop",
|
||||||
"fleets",
|
"fleets",
|
||||||
|
"automations",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
|
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,
|
// ssh-terminal, docker and host-metrics plugins start their own servers,
|
||||||
// so disabling any of them stops its WS/HTTP server. See
|
// so disabling any of them stops its WS/HTTP server. See
|
||||||
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
|
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
|
||||||
// AI, Proxmox, Remote Desktop and Fleets are also absent for the same
|
// AI, Proxmox, Remote Desktop, Fleets and Automations are also absent for
|
||||||
// reason: each plugin registers its router with its own dispatcher in
|
// the same reason: each plugin registers its router with its own
|
||||||
// database.ts on activate (/ai, /proxmox, /guacamole, /fleets), so
|
// dispatcher in database.ts on activate (/ai, /proxmox, /guacamole,
|
||||||
// disabling any of them makes its routes 404 instead of leaving a dead
|
// /fleets, /automations), so disabling any of them makes its routes 404
|
||||||
// import here. See plugins/ai, plugins/proxmox, plugins/remote-desktop
|
// instead of leaving a dead import here. Automations' scheduler (due
|
||||||
// and plugins/fleets.
|
// 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/tunnel/index.js");
|
||||||
await import("./hosts/file-manager/index.js");
|
await import("./hosts/file-manager/index.js");
|
||||||
await import("./hosts/tmux/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
|
// 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.
|
// fails to load must not stop the backend, so this never rejects.
|
||||||
try {
|
try {
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ import {
|
|||||||
metricStateKey,
|
metricStateKey,
|
||||||
severityForValue,
|
severityForValue,
|
||||||
type MetricsSnapshot,
|
type MetricsSnapshot,
|
||||||
} from "../../automations/conditions.js";
|
} from "../../../../../plugins/automations/backend/conditions.js";
|
||||||
|
|
||||||
const metrics: MetricsSnapshot = {
|
const metrics: MetricsSnapshot = {
|
||||||
cpu: { percent: 42.5, load: [1.5, 1.2, 0.9] },
|
cpu: { percent: 42.5, load: [1.5, 1.2, 0.9] },
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
|||||||
isValidTimezone,
|
isValidTimezone,
|
||||||
nextCronRun,
|
nextCronRun,
|
||||||
parseCron,
|
parseCron,
|
||||||
} from "../../automations/cron.js";
|
} from "../../../../../plugins/automations/backend/cron.js";
|
||||||
|
|
||||||
describe("parseCron", () => {
|
describe("parseCron", () => {
|
||||||
it("rejects anything that is not five fields", () => {
|
it("rejects anything that is not five fields", () => {
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
diffContainerStates,
|
diffContainerStates,
|
||||||
parseContainerStates,
|
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
|
* 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 { 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
|
* The engine reaches the database through the repository factory and the
|
||||||
@@ -49,20 +52,21 @@ const repository = {
|
|||||||
|
|
||||||
const resolveHostById = vi.fn();
|
const resolveHostById = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../hosts/host-resolver.js", () => ({
|
vi.mock("../../../hosts/host-resolver.js", () => ({
|
||||||
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
|
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../database/repositories/factory.js", () => ({
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
createCurrentAutomationRepository: () => repository,
|
createCurrentAutomationRepository: () => repository,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const executeStep = vi.fn();
|
const executeStep = vi.fn();
|
||||||
vi.mock("../../automations/actions/index.js", () => ({
|
vi.mock("../../../../../plugins/automations/backend/actions/index.js", () => ({
|
||||||
executeStep: (...args: unknown[]) => executeStep(...args),
|
executeStep: (...args: unknown[]) => executeStep(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { AutomationEngine } = await import("../../automations/engine.js");
|
const { AutomationEngine } =
|
||||||
|
await import("../../../../../plugins/automations/backend/engine.js");
|
||||||
|
|
||||||
function defineAutomation(
|
function defineAutomation(
|
||||||
steps: Step[],
|
steps: Step[],
|
||||||
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const listAutomationWatchedHosts = vi.fn();
|
const listAutomationWatchedHosts = vi.fn();
|
||||||
vi.mock("../../automations/triggers.js", () => ({
|
vi.mock("../../../../../plugins/automations/backend/triggers.js", () => ({
|
||||||
listAutomationWatchedHosts: () => listAutomationWatchedHosts(),
|
listAutomationWatchedHosts: () => listAutomationWatchedHosts(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -10,7 +10,8 @@ const {
|
|||||||
reconcileHeadlessViewers,
|
reconcileHeadlessViewers,
|
||||||
releaseHeadlessViewers,
|
releaseHeadlessViewers,
|
||||||
setViewerRegistry,
|
setViewerRegistry,
|
||||||
} = await import("../../automations/headless-viewer.js");
|
} =
|
||||||
|
await import("../../../../../plugins/automations/backend/headless-viewer.js");
|
||||||
|
|
||||||
const registry = {
|
const registry = {
|
||||||
registerViewer: vi.fn(),
|
registerViewer: vi.fn(),
|
||||||
+2
-2
@@ -2,12 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
const automationFetch = vi.fn();
|
const automationFetch = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../automations/http.js", () => ({
|
vi.mock("../../../../../plugins/automations/backend/http.js", () => ({
|
||||||
automationFetch: (...args: unknown[]) => automationFetch(...args),
|
automationFetch: (...args: unknown[]) => automationFetch(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { sendAutomationNotification } =
|
const { sendAutomationNotification } =
|
||||||
await import("../../automations/notify.js");
|
await import("../../../../../plugins/automations/backend/notify.js");
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
automationFetch.mockReset();
|
automationFetch.mockReset();
|
||||||
+8
-18
@@ -44,10 +44,15 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const run = vi.hoisted(() => vi.fn());
|
const run = vi.hoisted(() => vi.fn());
|
||||||
vi.mock("../../../automations/engine.js", () => ({
|
vi.mock("../../../../../plugins/automations/backend/engine.js", () => ({
|
||||||
AutomationEngine: { getInstance: () => ({ run }) },
|
AutomationEngine: { getInstance: () => ({ run }) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../database/routes/automation-dispatch.js", () => ({
|
||||||
|
registerAutomationsRouter: vi.fn(),
|
||||||
|
unregisterAutomationsRouter: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||||
PermissionManager: {
|
PermissionManager: {
|
||||||
getInstance: () => ({
|
getInstance: () => ({
|
||||||
@@ -80,23 +85,8 @@ vi.mock("../../../utils/audit-logger.js", () => ({
|
|||||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { default: router } =
|
const { router } =
|
||||||
await import("../../../database/routes/automations.js");
|
await import("../../../../../plugins/automations/backend/routes.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Runs the whole middleware chain for the route, not just its handler. */
|
/** Runs the whole middleware chain for the route, not just its handler. */
|
||||||
async function invoke(
|
async function invoke(
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
|||||||
redactSecrets,
|
redactSecrets,
|
||||||
renderRecord,
|
renderRecord,
|
||||||
renderTemplate,
|
renderTemplate,
|
||||||
} from "../../automations/template.js";
|
} from "../../../../../plugins/automations/backend/template.js";
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
host: { id: 7, name: "Zeus", ip: "10.0.0.5", username: "root", port: 22 },
|
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 {
|
import type {
|
||||||
AutomationDefinition,
|
AutomationDefinition,
|
||||||
Trigger,
|
Trigger,
|
||||||
} from "../../../types/automations.js";
|
} from "../../../../types/automations.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger matching, dwell windows and cooldowns. The repository and the engine
|
* 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,
|
createCurrentAutomationRepository: () => repository,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const run = vi.fn(async () => ({ runId: 1, status: "success" as const }));
|
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 }) },
|
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> = {}) {
|
function addAutomation(trigger: Trigger, overrides: Partial<FakeRow> = {}) {
|
||||||
const definition: AutomationDefinition = { version: 1, trigger, steps: [] };
|
const definition: AutomationDefinition = { version: 1, trigger, steps: [] };
|
||||||
@@ -283,3 +283,44 @@ describe("bundled fleets plugin", () => {
|
|||||||
expect(source).toMatch(/export async function deactivate/);
|
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", () => {
|
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([
|
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
|
||||||
"ai",
|
"ai",
|
||||||
|
"automations",
|
||||||
"docker",
|
"docker",
|
||||||
"fleets",
|
"fleets",
|
||||||
"host-metrics",
|
"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("ssh-terminal")).toBe(true);
|
||||||
expect(isFirstParty("docker")).toBe(true);
|
expect(isFirstParty("docker")).toBe(true);
|
||||||
expect(isFirstParty("host-metrics")).toBe(true);
|
expect(isFirstParty("host-metrics")).toBe(true);
|
||||||
@@ -59,6 +60,7 @@ describe("first-party allowlist", () => {
|
|||||||
expect(isFirstParty("proxmox")).toBe(true);
|
expect(isFirstParty("proxmox")).toBe(true);
|
||||||
expect(isFirstParty("remote-desktop")).toBe(true);
|
expect(isFirstParty("remote-desktop")).toBe(true);
|
||||||
expect(isFirstParty("fleets")).toBe(true);
|
expect(isFirstParty("fleets")).toBe(true);
|
||||||
|
expect(isFirstParty("automations")).toBe(true);
|
||||||
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
||||||
expect(isFirstParty("community-plugin")).toBe(false);
|
expect(isFirstParty("community-plugin")).toBe(false);
|
||||||
expect(isFirstParty("")).toBe(false);
|
expect(isFirstParty("")).toBe(false);
|
||||||
@@ -79,6 +81,10 @@ describe("runsInProcess", () => {
|
|||||||
expect(runsInProcess("remote-desktop", [TRANSPORT_OWNER_CAPABILITY])).toBe(
|
expect(runsInProcess("remote-desktop", [TRANSPORT_OWNER_CAPABILITY])).toBe(
|
||||||
true,
|
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
|
// On the list but not asking for it: stays in a worker, so the manifest
|
||||||
// remains an honest description of what the plugin does.
|
// remains an honest description of what the plugin does.
|
||||||
@@ -88,6 +94,8 @@ describe("runsInProcess", () => {
|
|||||||
expect(runsInProcess("ai", ["hosts.read"])).toBe(false);
|
expect(runsInProcess("ai", ["hosts.read"])).toBe(false);
|
||||||
expect(runsInProcess("proxmox", ["hosts.read"])).toBe(false);
|
expect(runsInProcess("proxmox", ["hosts.read"])).toBe(false);
|
||||||
expect(runsInProcess("remote-desktop", ["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.
|
// Asking for it but not on the list.
|
||||||
expect(
|
expect(
|
||||||
@@ -179,6 +187,18 @@ describe("manifest gate on the reserved capability", () => {
|
|||||||
expect(parsed?.id).toBe("proxmox");
|
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", () => {
|
it("still accepts an ordinary manifest that never mentions it", () => {
|
||||||
const { manifest: parsed, errors } = parseManifest(
|
const { manifest: parsed, errors } = parseManifest(
|
||||||
manifest({ permissions: ["hosts.read"] }),
|
manifest({ permissions: ["hosts.read"] }),
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@ const WorkspacesPanel = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const AutomationsPanel = lazy(() =>
|
const AutomationsPanel = lazy(() =>
|
||||||
import("@/sidebar/AutomationsPanel").then((m) => ({
|
import("../../plugins/automations/frontend/AutomationsPanel").then((m) => ({
|
||||||
default: m.AutomationsPanel,
|
default: m.AutomationsPanel,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||||
import { getSnippets } from "@/api/snippets-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.
|
* Backs the @-mention picker in the composer.
|
||||||
|
|||||||
@@ -175,9 +175,11 @@ const SshToolsPanel = lazy(() =>
|
|||||||
import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })),
|
import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })),
|
||||||
);
|
);
|
||||||
const AutomationsPanel = lazy(() =>
|
const AutomationsPanel = lazy(() =>
|
||||||
import("@/sidebar/AutomationsPanel").then((m) => ({
|
import("../../../plugins/automations/frontend/AutomationsPanel").then(
|
||||||
default: m.AutomationsPanel,
|
(m) => ({
|
||||||
})),
|
default: m.AutomationsPanel,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const AiPanel = lazy(() =>
|
const AiPanel = lazy(() =>
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { defaultTrigger } from "@/sidebar/automations/TriggerCard";
|
import { defaultTrigger } from "../../../../../plugins/automations/frontend/automations/TriggerCard";
|
||||||
import { emptyDraft } from "@/sidebar/automations/AutomationEditor";
|
import { emptyDraft } from "../../../../../plugins/automations/frontend/automations/AutomationEditor";
|
||||||
import { newStepId } from "@/sidebar/automations/editor-types";
|
import { newStepId } from "../../../../../plugins/automations/frontend/automations/editor-types";
|
||||||
import { AUTOMATION_DEFINITION_VERSION } from "@/types/automations";
|
import { AUTOMATION_DEFINITION_VERSION } from "@/types/automations";
|
||||||
|
|
||||||
describe("emptyDraft", () => {
|
describe("emptyDraft", () => {
|
||||||
@@ -32,7 +32,8 @@
|
|||||||
"plugins/ai/backend/**/*.ts",
|
"plugins/ai/backend/**/*.ts",
|
||||||
"plugins/proxmox/backend/**/*.ts",
|
"plugins/proxmox/backend/**/*.ts",
|
||||||
"plugins/remote-desktop/backend/**/*.ts",
|
"plugins/remote-desktop/backend/**/*.ts",
|
||||||
"plugins/fleets/backend/**/*.ts"
|
"plugins/fleets/backend/**/*.ts",
|
||||||
|
"plugins/automations/backend/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["src/**/*.test.ts"]
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user