mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: finish the automations plugin (B17)
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user