feat: finish the automations plugin (B17)

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

No files matched your search

@@ -1,4 +1,3 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
@@ -12,30 +11,22 @@ import {
Trash2,
Workflow,
} from "lucide-react";
import { Button } from "@/components/button";
import { Badge } from "@/components/badge";
import {
createAutomation,
deleteAutomation,
listAutomationRunSteps,
listAutomationRuns,
listAutomations,
runAutomation,
updateAutomation,
Badge,
Button,
NotificationChannelDialog,
deleteNotificationChannel,
getBasePath,
getNotificationChannels,
testNotificationChannel,
type NotificationChannel,
} from "@termix/plugin-sdk/ui";
import {
useAutomationsApi,
type AutomationRow,
type AutomationRunRow,
type AutomationRunStepRow,
} from "./automations-api";
import {
deleteNotificationChannel,
getNotificationChannels,
testNotificationChannel,
type NotificationChannel,
} from "@/api/notification-channels-api";
import { NotificationChannelDialog } from "@/sidebar/NotificationChannelDialog";
import { getSnippets } from "@/api/snippets-api";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { invokeAction } from "@termix/plugin-sdk/frontend";
import {
AutomationEditor,
@@ -47,6 +38,10 @@ import {
type AutomationEditorOptions,
} from "./automations/editor-types";
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
type PanelTab = "automations" | "runs" | "channels";
const STATUS_CLASS: Record<string, string> = {
@@ -58,25 +53,20 @@ const STATUS_CLASS: Record<string, string> = {
cancelled: "text-muted-foreground",
};
function timeAgo(iso: string | null): string {
type Translate = (key: string, options?: Record<string, unknown>) => string;
function timeAgo(iso: string | null, t: Translate): string {
if (!iso) return "";
const ms = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(ms)) return "";
const base = "newUi.sidebar.automations.ago";
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
if (sec < 60) return t(`${base}.seconds`, { count: sec });
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
if (min < 60) return t(`${base}.minutes`, { count: min });
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
/** Fleets from the fleets plugin, or none when it is not running. */
async function listFleets(): Promise<{ id: number; name: string }[]> {
const result = await invokeAction("fleets.list");
return Array.isArray(result)
? (result as { id: number; name: string }[])
: [];
if (hr < 24) return t(`${base}.hours`, { count: hr });
return t(`${base}.days`, { count: Math.floor(hr / 24) });
}
export function AutomationsPanel({
@@ -87,6 +77,7 @@ export function AutomationsPanel({
onEditingChange?: (editing: boolean) => void;
}) {
const { t } = useTranslation();
const api = useAutomationsApi();
const base = "newUi.sidebar.automations";
const [tab, setTab] = useState<PanelTab>("automations");
@@ -113,60 +104,34 @@ export function AutomationsPanel({
const loadAutomations = useCallback(async () => {
setLoading(true);
try {
setAutomations(await listAutomations());
setAutomations(await api.list());
} catch {
// A failed load leaves the previous list on screen.
} finally {
setLoading(false);
}
}, []);
}, [api]);
const loadRuns = useCallback(async () => {
try {
setRuns(await listAutomationRuns({ limit: 100 }));
setRuns(await api.runs({ limit: 100 }));
} catch {
// Non-fatal.
}
}, []);
}, [api]);
const loadOptions = useCallback(async () => {
const [hosts, snippets, channelsResult, fleets] = await Promise.allSettled([
getSSHHosts({ includeStatus: false }),
getSnippets(),
const [editorOptions, channelsResult] = await Promise.allSettled([
api.editorOptions(),
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 }))
: [],
});
}, []);
if (editorOptions.status === "fulfilled") {
setOptions(editorOptions.value);
}
}, [api]);
useEffect(() => {
if (!active) return;
@@ -222,7 +187,7 @@ export function AutomationsPanel({
setSaving(true);
try {
if (editingId === null) {
const created = await createAutomation({
const created = await api.create({
name: draft.name.trim(),
description: draft.description || null,
enabled: draft.enabled,
@@ -236,7 +201,7 @@ export function AutomationsPanel({
setEditorOpen(false);
}
} else {
await updateAutomation(editingId, {
await api.update(editingId, {
name: draft.name.trim(),
description: draft.description || null,
enabled: draft.enabled,
@@ -257,7 +222,7 @@ export function AutomationsPanel({
async function remove(row: AutomationRow) {
if (!confirm(t(`${base}.deleteConfirm`))) return;
try {
await deleteAutomation(row.id);
await api.remove(row.id);
toast.success(t(`${base}.deleted`));
await loadAutomations();
} catch (error) {
@@ -267,7 +232,7 @@ export function AutomationsPanel({
async function run(row: AutomationRow, dryRun: boolean) {
try {
const outcome = await runAutomation(row.id, { dryRun });
const outcome = await api.run(row.id, { dryRun });
toast.success(t(`${base}.runFinished`, { status: outcome.status }));
await loadAutomations();
if (tab === "runs") await loadRuns();
@@ -304,7 +269,7 @@ export function AutomationsPanel({
}
setExpandedRun(runId);
try {
setRunSteps(await listAutomationRunSteps(runId));
setRunSteps(await api.runSteps(runId));
} catch {
setRunSteps([]);
}
@@ -314,7 +279,7 @@ export function AutomationsPanel({
// host manager does, rather than opening a dialog over the app.
if (editorOpen) {
const webhookUrl = webhookToken
? `${window.location.origin}/automations/webhook/${webhookToken}`
? `${window.location.origin}${getBasePath()}/plugin-api/automations/webhook/${webhookToken}`
: "";
return (
@@ -432,7 +397,7 @@ export function AutomationsPanel({
rel="noreferrer"
className="ml-auto shrink-0 text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
{t(`${base}.docsLink`)}
</a>
</div>
@@ -475,9 +440,20 @@ export function AutomationsPanel({
>
{row.name}
</button>
{!!row.missingPlugins?.length && (
<Badge
variant="outline"
className="text-[10px] border-destructive/50 text-destructive"
title={t(`${base}.needsPluginHint`)}
>
{t(`${base}.needsPlugin`, {
plugin: row.missingPlugins.join(", "),
})}
</Badge>
)}
{!row.enabled && (
<Badge variant="outline" className="text-[10px]">
off
{t(`${base}.offBadge`)}
</Badge>
)}
</div>
@@ -490,7 +466,7 @@ export function AutomationsPanel({
)
: ""}
{row.last_run_status
? ` · ${timeAgo(row.last_run_at)}`
? ` · ${timeAgo(row.last_run_at, t)}`
: ""}
</span>
<Button
@@ -555,7 +531,7 @@ export function AutomationsPanel({
</span>
</div>
<div className="text-[11px] text-muted-foreground">
{timeAgo(entry.started_at)}
{timeAgo(entry.started_at, t)}
{entry.duration_ms !== null
? ` · ${Math.round(entry.duration_ms / 100) / 10}s`
: ""}
@@ -1,4 +1,8 @@
import { authApi, handleApiError } from "@/main-axios";
import { useMemo } from "react";
import {
usePluginApi,
type PluginApiClient,
} from "@termix/plugin-sdk/frontend";
import type {
AutomationDefinition,
ConcurrencyPolicy,
@@ -6,7 +10,7 @@ import type {
StepStatus,
StepType,
TriggerKind,
} from "@/types/automations";
} from "../types";
export interface AutomationRow {
id: number;
@@ -24,6 +28,8 @@ export interface AutomationRow {
created_at: string;
updated_at: string;
channels: number[];
/** Plugins this automation needs that are not running. */
missingPlugins?: string[];
/** Returned once, only when a webhook automation is created. */
webhookToken?: string;
}
@@ -73,76 +79,82 @@ export interface AutomationInput {
channels?: number[];
}
export async function listAutomations(): Promise<AutomationRow[]> {
/** Which optional plugins are running, keyed like the backend's deps. */
export interface AutomationProviders {
snippets: boolean;
fleets: boolean;
tunnels: boolean;
docker: boolean;
"docker-events": boolean;
"host-metrics": boolean;
"wake-on-lan": boolean;
}
export interface AutomationEditorOptionsResponse {
hosts: Array<{ id: number; name: string }>;
snippets: Array<{ id: number; name: string }>;
fleets: Array<{ id: number; name: string }>;
channels: Array<{ id: number; name: string }>;
providers: AutomationProviders;
}
function apiError(error: unknown, action: string): Error {
const data = (
error as { response?: { data?: { error?: string; message?: string } } }
)?.response?.data;
return new Error(
data?.error ||
data?.message ||
(error instanceof Error ? error.message : `Failed to ${action}`),
);
}
async function call<T>(
action: string,
request: () => Promise<{ data: T }>,
): Promise<T> {
try {
return (await authApi.get("/plugin-api/automations")).data;
return (await request()).data;
} catch (error) {
throw handleApiError(error, "fetch automations");
throw apiError(error, action);
}
}
export async function createAutomation(
input: AutomationInput,
): Promise<AutomationRow> {
try {
return (await authApi.post("/plugin-api/automations", input)).data;
} catch (error) {
throw handleApiError(error, "create automation");
}
export function createAutomationsApi(api: PluginApiClient) {
return {
list: () => call<AutomationRow[]>("fetch automations", () => api.get("/")),
editorOptions: () =>
call<AutomationEditorOptionsResponse>("fetch editor options", () =>
api.get("/editor-options"),
),
create: (input: AutomationInput) =>
call<AutomationRow>("create automation", () => api.post("/", input)),
update: (id: number, input: Partial<AutomationInput>) =>
call<AutomationRow>("update automation", () => api.put(`/${id}`, input)),
remove: (id: number) =>
call<{ success: boolean }>("delete automation", () =>
api.delete(`/${id}`),
),
run: (id: number, options: { dryRun?: boolean } = {}) =>
call<AutomationRunOutcome>("run automation", () =>
api.post(`/${id}/run`, options),
),
runs: (
options: { automationId?: number; limit?: number; offset?: number } = {},
) =>
call<AutomationRunRow[]>("fetch automation runs", () =>
api.get("/runs/history", { params: options }),
),
runSteps: (runId: number) =>
call<AutomationRunStepRow[]>("fetch run steps", () =>
api.get(`/runs/${runId}/steps`),
),
};
}
export async function updateAutomation(
id: number,
input: Partial<AutomationInput>,
): Promise<AutomationRow> {
try {
return (await authApi.put(`/plugin-api/automations/${id}`, input)).data;
} catch (error) {
throw handleApiError(error, "update automation");
}
}
export type AutomationsApi = ReturnType<typeof createAutomationsApi>;
export async function deleteAutomation(id: number): Promise<void> {
try {
await authApi.delete(`/plugin-api/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(`/plugin-api/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("/plugin-api/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(`/plugin-api/automations/runs/${runId}/steps`))
.data;
} catch (error) {
throw handleApiError(error, "fetch run steps");
}
export function useAutomationsApi(): AutomationsApi {
const api = usePluginApi();
return useMemo(() => createAutomationsApi(api), [api]);
}
@@ -1,24 +1,23 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
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 {
Button,
Input,
Label,
Switch,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
} from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Plus } from "lucide-react";
import {
AUTOMATION_DEFINITION_VERSION,
type AutomationDefinition,
type ConcurrencyPolicy,
type Step,
} from "@/types/automations";
} from "../../types";
import { StepBlock } from "./StepBlock";
import { TriggerCard, defaultTrigger } from "./TriggerCard";
import { newStepId, type AutomationEditorOptions } from "./editor-types";
@@ -246,7 +245,9 @@ export function AutomationEditor({
setDefinition(parsed as AutomationDefinition);
setJsonError(null);
} catch (error) {
setJsonError(getErrorMessage(error, String(error)));
setJsonError(
error instanceof Error ? error.message : String(error),
);
}
}}
/>
@@ -1,13 +1,13 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Label } from "@/components/label";
import {
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import type { HostSelector } from "@/types/automations";
} from "@termix/plugin-sdk/ui";
import type { HostSelector } from "../../types";
import type { AutomationEditorOptions } from "./editor-types";
/**
@@ -56,9 +56,11 @@ export function HostSelectorField({
<SelectItem value="host">
{t(`${base}.hostSelector.host`)}
</SelectItem>
<SelectItem value="fleet">
{t(`${base}.hostSelector.fleet`)}
</SelectItem>
{(options.providers.fleets || kind === "fleet") && (
<SelectItem value="fleet">
{t(`${base}.hostSelector.fleet`)}
</SelectItem>
)}
<SelectItem value="all">{t(`${base}.hostSelector.all`)}</SelectItem>
</SelectContent>
</Select>
@@ -1,20 +1,23 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
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 {
Button,
Input,
Label,
Textarea,
Switch,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import type { Step, StepType } from "@/types/automations";
} from "@termix/plugin-sdk/ui";
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
import type { Step, StepType } from "../../types";
import { HostSelectorField } from "./HostSelectorField";
import type { AutomationEditorOptions } from "./editor-types";
import {
missingStepProvider,
type AutomationEditorOptions,
} from "./editor-types";
const STEP_TYPES: StepType[] = [
"notify",
@@ -86,11 +89,20 @@ export function StepBlock({
<SelectValue />
</SelectTrigger>
<SelectContent>
{STEP_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{t(`${base}.stepTypes.${type}`)}
</SelectItem>
))}
{STEP_TYPES.map((type) => {
const needs = missingStepProvider(type, options.providers);
// A type whose plugin is off is only listed when this step
// already uses it, marked as needing that plugin.
if (needs && type !== step.type) return null;
return (
<SelectItem key={type} value={type}>
{t(`${base}.stepTypes.${type}`)}
{needs
? ` (${t(`${base}.needsPlugin`, { plugin: needs })})`
: ""}
</SelectItem>
);
})}
</SelectContent>
</Select>
@@ -1,16 +1,19 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import {
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import type { Trigger, TriggerKind } from "@/types/automations";
} from "@termix/plugin-sdk/ui";
import type { Trigger, TriggerKind } from "../../types";
import { HostSelectorField } from "./HostSelectorField";
import type { AutomationEditorOptions } from "./editor-types";
import {
missingTriggerProvider,
type AutomationEditorOptions,
} from "./editor-types";
const TRIGGER_KINDS: TriggerKind[] = [
"metric_threshold",
@@ -98,11 +101,20 @@ export function TriggerCard({
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{t(`${base}.triggerKinds.${kind}`)}
</SelectItem>
))}
{TRIGGER_KINDS.map((kind) => {
const needs = missingTriggerProvider(kind, options.providers);
// A kind whose plugin is off is only listed when this trigger
// already uses it, marked as needing that plugin.
if (needs && kind !== trigger.kind) return null;
return (
<SelectItem key={kind} value={kind}>
{t(`${base}.triggerKinds.${kind}`)}
{needs
? ` (${t(`${base}.needsPlugin`, { plugin: needs })})`
: ""}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
@@ -1,9 +1,14 @@
import type { StepType, TriggerKind } from "../../types";
import type { AutomationProviders } from "../automations-api";
/** 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 }>;
/** Which optional plugins are running. */
providers: AutomationProviders;
}
export const EMPTY_EDITOR_OPTIONS: AutomationEditorOptions = {
@@ -11,9 +16,57 @@ export const EMPTY_EDITOR_OPTIONS: AutomationEditorOptions = {
snippets: [],
channels: [],
fleets: [],
providers: {
snippets: false,
fleets: false,
tunnels: false,
docker: false,
"docker-events": false,
"host-metrics": false,
"wake-on-lan": false,
},
};
/** 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)}`;
}
/** The plugin a trigger kind needs, when it is not core. */
const TRIGGER_PROVIDERS: Partial<
Record<TriggerKind, keyof AutomationProviders>
> = {
metric_threshold: "host-metrics",
health_check: "host-metrics",
docker_event: "docker-events",
};
/** The plugin a step type needs, when it is not core. */
const STEP_PROVIDERS: Partial<Record<StepType, keyof AutomationProviders>> = {
run_snippet: "snippets",
docker: "docker",
tunnel: "tunnels",
wol: "wake-on-lan",
};
/** The plugin id shown in "needs <plugin>", for a provider key. */
export function providerPlugin(key: keyof AutomationProviders): string {
return key === "docker-events" ? "docker" : key;
}
/** The provider a kind needs that is not running, or null when it can run. */
export function missingTriggerProvider(
kind: TriggerKind,
providers: AutomationProviders,
): string | null {
const key = TRIGGER_PROVIDERS[kind];
return key && !providers[key] ? providerPlugin(key) : null;
}
export function missingStepProvider(
type: StepType,
providers: AutomationProviders,
): string | null {
const key = STEP_PROVIDERS[type];
return key && !providers[key] ? providerPlugin(key) : null;
}
+3 -2
View File
@@ -5,7 +5,7 @@ import type {
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { AutomationsPanel } from "./AutomationsPanel";
import { listAutomations } from "./automations-api";
import { createAutomationsApi } from "./automations-api";
const VIEW_ID = "automations";
@@ -40,7 +40,8 @@ export function activate(app: TermixApp): void {
});
// The assistant's @-mentions list automations through this.
app.registerAction("automations.list", () => listAutomations());
const api = createAutomationsApi(app.api);
app.registerAction("automations.list", () => api.list());
app.registerSlotContribution("onboarding.features", {
actionId: "automations.feature",