feat: move automations into a first-party plugin

This commit is contained in:
LukeGus committed 2026-09-22 08:14:03 -05:00
1 parent a6f6a0439c
commit e0f14f2ecf
47 files changed
+384 -125

No files matched your search

+1 -1
View File
@@ -102,7 +102,7 @@ const WorkspacesPanel = lazy(() =>
})),
);
const AutomationsPanel = lazy(() =>
import("@/sidebar/AutomationsPanel").then((m) => ({
import("../../plugins/automations/frontend/AutomationsPanel").then((m) => ({
default: m.AutomationsPanel,
})),
);
-143
View File
@@ -1,143 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
import type {
AutomationDefinition,
ConcurrencyPolicy,
RunStatus,
StepStatus,
StepType,
TriggerKind,
} from "@/types/automations";
export interface AutomationRow {
id: number;
user_id: string;
name: string;
description: string | null;
enabled: number;
definition: AutomationDefinition | null;
definition_version: number;
concurrency_policy: ConcurrencyPolicy;
max_run_seconds: number;
dry_run: number;
last_run_at: string | null;
last_run_status: RunStatus | null;
created_at: string;
updated_at: string;
channels: number[];
/** Returned once, only when a webhook automation is created. */
webhookToken?: string;
}
export interface AutomationRunRow {
id: number;
automation_id: number;
user_id: string;
trigger_type: TriggerKind | "manual";
trigger_context: string | null;
status: RunStatus;
started_at: string;
finished_at: string | null;
duration_ms: number | null;
error: string | null;
dry_run: number;
parent_run_id: number | null;
automation_name?: string | null;
}
export interface AutomationRunStepRow {
id: number;
run_id: number;
step_index: number;
step_id: string;
step_type: StepType;
status: StepStatus;
started_at: string;
finished_at: string | null;
output: string | null;
error: string | null;
truncated: number;
}
export interface AutomationRunOutcome {
runId: number | null;
status: RunStatus;
error?: string;
}
export interface AutomationInput {
name: string;
description?: string | null;
enabled?: boolean;
definition: AutomationDefinition;
concurrencyPolicy?: ConcurrencyPolicy;
channels?: number[];
}
export async function listAutomations(): Promise<AutomationRow[]> {
try {
return (await authApi.get("/automations")).data;
} catch (error) {
throw handleApiError(error, "fetch automations");
}
}
export async function createAutomation(
input: AutomationInput,
): Promise<AutomationRow> {
try {
return (await authApi.post("/automations", input)).data;
} catch (error) {
throw handleApiError(error, "create automation");
}
}
export async function updateAutomation(
id: number,
input: Partial<AutomationInput>,
): Promise<AutomationRow> {
try {
return (await authApi.put(`/automations/${id}`, input)).data;
} catch (error) {
throw handleApiError(error, "update automation");
}
}
export async function deleteAutomation(id: number): Promise<void> {
try {
await authApi.delete(`/automations/${id}`);
} catch (error) {
throw handleApiError(error, "delete automation");
}
}
export async function runAutomation(
id: number,
options: { dryRun?: boolean } = {},
): Promise<AutomationRunOutcome> {
try {
return (await authApi.post(`/automations/${id}/run`, options)).data;
} catch (error) {
throw handleApiError(error, "run automation");
}
}
export async function listAutomationRuns(
options: { automationId?: number; limit?: number; offset?: number } = {},
): Promise<AutomationRunRow[]> {
try {
return (await authApi.get("/automations/runs/history", { params: options }))
.data;
} catch (error) {
throw handleApiError(error, "fetch automation runs");
}
}
export async function listAutomationRunSteps(
runId: number,
): Promise<AutomationRunStepRow[]> {
try {
return (await authApi.get(`/automations/runs/${runId}/steps`)).data;
} catch (error) {
throw handleApiError(error, "fetch run steps");
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { getSnippets } from "@/api/snippets-api";
import { listAutomations } from "@/api/automations-api";
import { listAutomations } from "../../../../plugins/automations/frontend/automations-api";
/**
* Backs the @-mention picker in the composer.
+5 -3
View File
@@ -175,9 +175,11 @@ const SshToolsPanel = lazy(() =>
import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })),
);
const AutomationsPanel = lazy(() =>
import("@/sidebar/AutomationsPanel").then((m) => ({
default: m.AutomationsPanel,
})),
import("../../../plugins/automations/frontend/AutomationsPanel").then(
(m) => ({
default: m.AutomationsPanel,
}),
),
);
const AiPanel = lazy(() =>
-675
View File
@@ -1,675 +0,0 @@
import { getErrorMessage } from "../lib/error-message.js";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
ArrowLeft,
Copy,
FlaskConical,
Loader2,
Play,
Plus,
Trash2,
Workflow,
} from "lucide-react";
import { Button } from "@/components/button";
import { Badge } from "@/components/badge";
import {
createAutomation,
deleteAutomation,
listAutomationRunSteps,
listAutomationRuns,
listAutomations,
runAutomation,
updateAutomation,
type AutomationRow,
type AutomationRunRow,
type AutomationRunStepRow,
} from "@/api/automations-api";
import {
deleteNotificationChannel,
getNotificationChannels,
testNotificationChannel,
type NotificationChannel,
} from "@/api/alerts-api";
import { NotificationChannelDialog } from "./NotificationChannelDialog";
import { getSnippets } from "@/api/snippets-api";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { listFleets } from "@/api/fleets-api";
import {
AutomationEditor,
emptyDraft,
type AutomationDraft,
} from "./automations/AutomationEditor";
import {
EMPTY_EDITOR_OPTIONS,
type AutomationEditorOptions,
} from "./automations/editor-types";
type PanelTab = "automations" | "runs" | "channels";
const STATUS_CLASS: Record<string, string> = {
success: "text-green-500",
failed: "text-destructive",
timeout: "text-destructive",
running: "text-foreground",
skipped: "text-muted-foreground",
cancelled: "text-muted-foreground",
};
function timeAgo(iso: string | null): string {
if (!iso) return "";
const ms = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(ms)) return "";
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
export function AutomationsPanel({
active = true,
onEditingChange,
}: {
active?: boolean;
onEditingChange?: (editing: boolean) => void;
}) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
const [tab, setTab] = useState<PanelTab>("automations");
const [automations, setAutomations] = useState<AutomationRow[]>([]);
const [runs, setRuns] = useState<AutomationRunRow[]>([]);
const [loading, setLoading] = useState(false);
const [options, setOptions] =
useState<AutomationEditorOptions>(EMPTY_EDITOR_OPTIONS);
const [editorOpen, setEditorOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [draft, setDraft] = useState<AutomationDraft>(emptyDraft());
const [saving, setSaving] = useState(false);
const [webhookToken, setWebhookToken] = useState<string | null>(null);
const [expandedRun, setExpandedRun] = useState<number | null>(null);
const [runSteps, setRunSteps] = useState<AutomationRunStepRow[]>([]);
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [channelDialogOpen, setChannelDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] =
useState<NotificationChannel | null>(null);
const loadAutomations = useCallback(async () => {
setLoading(true);
try {
setAutomations(await listAutomations());
} catch {
// A failed load leaves the previous list on screen.
} finally {
setLoading(false);
}
}, []);
const loadRuns = useCallback(async () => {
try {
setRuns(await listAutomationRuns({ limit: 100 }));
} catch {
// Non-fatal.
}
}, []);
const loadOptions = useCallback(async () => {
const [hosts, snippets, channelsResult, fleets] = await Promise.allSettled([
getSSHHosts({ includeStatus: false }),
getSnippets(),
getNotificationChannels(),
listFleets(),
]);
if (channelsResult.status === "fulfilled") {
setChannels(channelsResult.value);
}
setOptions({
hosts:
hosts.status === "fulfilled"
? hosts.value.map((host) => ({
id: host.id,
name: host.name || host.ip,
}))
: [],
snippets:
snippets.status === "fulfilled"
? snippets.value.map((s) => ({
id: s.id,
// SnippetRow only types its id; the rest is narrowed by callers.
name: typeof s.name === "string" ? s.name : `#${s.id}`,
}))
: [],
channels:
channelsResult.status === "fulfilled"
? channelsResult.value.map((c) => ({ id: c.id, name: c.name }))
: [],
fleets:
fleets.status === "fulfilled"
? fleets.value.map((f) => ({ id: f.id, name: f.name }))
: [],
});
}, []);
useEffect(() => {
if (!active) return;
void loadAutomations();
void loadOptions();
}, [active, loadAutomations, loadOptions]);
useEffect(() => {
if (active && tab === "runs") void loadRuns();
}, [active, tab, loadRuns]);
// Widens the sidebar while the editor is open, and releases it again on the
// way out or when the user switches to another rail destination.
useEffect(() => {
if (active) onEditingChange?.(editorOpen);
}, [active, editorOpen, onEditingChange]);
useEffect(() => {
return () => onEditingChange?.(false);
}, [onEditingChange]);
function closeEditor() {
setWebhookToken(null);
setEditorOpen(false);
}
function openCreate() {
setEditingId(null);
setDraft(emptyDraft());
setWebhookToken(null);
setEditorOpen(true);
}
function openEdit(row: AutomationRow) {
setEditingId(row.id);
setDraft({
name: row.name,
description: row.description ?? "",
enabled: !!row.enabled,
concurrencyPolicy: row.concurrency_policy ?? "skip",
definition: row.definition ?? emptyDraft().definition,
});
setWebhookToken(null);
setEditorOpen(true);
}
async function save() {
if (!draft.name.trim()) {
toast.error(t(`${base}.nameRequired`));
return;
}
setSaving(true);
try {
if (editingId === null) {
const created = await createAutomation({
name: draft.name.trim(),
description: draft.description || null,
enabled: draft.enabled,
concurrencyPolicy: draft.concurrencyPolicy,
definition: draft.definition,
});
toast.success(t(`${base}.created`));
if (created.webhookToken) {
setWebhookToken(created.webhookToken);
} else {
setEditorOpen(false);
}
} else {
await updateAutomation(editingId, {
name: draft.name.trim(),
description: draft.description || null,
enabled: draft.enabled,
concurrencyPolicy: draft.concurrencyPolicy,
definition: draft.definition,
});
toast.success(t(`${base}.updated`));
setEditorOpen(false);
}
await loadAutomations();
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
} finally {
setSaving(false);
}
}
async function remove(row: AutomationRow) {
if (!confirm(t(`${base}.deleteConfirm`))) return;
try {
await deleteAutomation(row.id);
toast.success(t(`${base}.deleted`));
await loadAutomations();
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
}
}
async function run(row: AutomationRow, dryRun: boolean) {
try {
const outcome = await runAutomation(row.id, { dryRun });
toast.success(t(`${base}.runFinished`, { status: outcome.status }));
await loadAutomations();
if (tab === "runs") await loadRuns();
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
}
}
async function testChannel(channel: NotificationChannel) {
try {
// Throws with the reason when delivery fails.
await testNotificationChannel(channel.id);
toast.success(t(`${base}.channelTestSent`));
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
}
}
async function removeChannel(channel: NotificationChannel) {
if (!confirm(t(`${base}.deleteChannelConfirm`))) return;
try {
await deleteNotificationChannel(channel.id);
toast.success(t(`${base}.channelDeleted`));
await loadOptions();
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
}
}
async function toggleRun(runId: number) {
if (expandedRun === runId) {
setExpandedRun(null);
return;
}
setExpandedRun(runId);
try {
setRunSteps(await listAutomationRunSteps(runId));
} catch {
setRunSteps([]);
}
}
// Editing takes over the whole panel and widens the sidebar, the same way the
// host manager does, rather than opening a dialog over the app.
if (editorOpen) {
const webhookUrl = webhookToken
? `${window.location.origin}/automations/webhook/${webhookToken}`
: "";
return (
<div className="flex flex-col h-full min-h-0">
<button
onClick={closeEditor}
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50 shrink-0"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>{t(`${base}.backToAutomations`)}</span>
{editingId !== null && (
<span
className="ml-auto font-semibold text-foreground truncate max-w-[200px]"
title={draft.name}
>
{draft.name}
</span>
)}
</button>
<div className="flex-1 min-h-0 overflow-y-auto p-3">
{webhookToken ? (
<div className="space-y-2">
<p className="text-sm">{t(`${base}.webhookTokenTitle`)}</p>
<p className="text-xs text-muted-foreground">
{t(`${base}.webhookTokenDescription`)}
</p>
<div className="flex gap-2">
<code className="flex-1 p-2 bg-muted text-xs break-all">
{webhookUrl}
</code>
<Button
size="sm"
variant="outline"
className="rounded-none border-border shrink-0"
onClick={() => {
navigator.clipboard?.writeText(webhookUrl);
toast.success(t(`${base}.copied`));
}}
>
<Copy size={14} />
</Button>
</div>
</div>
) : (
<AutomationEditor
draft={draft}
options={options}
onChange={setDraft}
/>
)}
</div>
<div className="flex items-center gap-2 p-3 border-t border-border shrink-0">
{webhookToken ? (
<Button
variant="outline"
className="rounded-none ml-auto h-9 px-6 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={closeEditor}
>
{t("common.close")}
</Button>
) : (
<>
<Button
variant="outline"
className="rounded-none border-border h-9 px-5"
onClick={closeEditor}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="rounded-none ml-auto h-9 px-6 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={saving}
onClick={save}
>
{saving && (
<Loader2 size={14} className="mr-1.5 animate-spin" />
)}
{t(`${base}.save`)}
</Button>
</>
)}
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-1 p-2 border-b border-border">
{(["automations", "runs", "channels"] as PanelTab[]).map((key) => (
<Button
key={key}
variant={tab === key ? "outline" : "ghost"}
size="sm"
className={`min-w-0 flex-shrink rounded-none h-7 px-2 text-xs ${
tab === key
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand"
: ""
}`}
onClick={() => setTab(key)}
>
<span className="truncate">
{t(
`${base}.${key === "automations" ? "tabAutomations" : key === "runs" ? "tabRuns" : "tabChannels"}`,
)}
</span>
</Button>
))}
<a
href="https://docs.termix.site/features/automations/overview"
target="_blank"
rel="noreferrer"
className="ml-auto shrink-0 text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</div>
<div className="flex-1 overflow-y-auto p-2">
{tab === "automations" && (
<>
<Button
size="sm"
variant="outline"
className="rounded-none w-full mb-2 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={openCreate}
>
<Plus size={14} className="mr-1" />
{t(`${base}.createAutomation`)}
</Button>
{loading && automations.length === 0 && (
<div className="flex justify-center p-4">
<Loader2 size={16} className="animate-spin" />
</div>
)}
{!loading && automations.length === 0 && (
<p className="text-xs text-muted-foreground p-2">
{t(`${base}.empty`)}
</p>
)}
<div className="space-y-1">
{automations.map((row) => (
<div
key={row.id}
className="border border-border p-2.5 hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<Workflow size={14} className="shrink-0" />
<button
className="flex-1 text-left text-sm truncate"
onClick={() => openEdit(row)}
>
{row.name}
</button>
{!row.enabled && (
<Badge variant="outline" className="text-[10px]">
off
</Badge>
)}
</div>
<div className="flex items-center gap-1 mt-1">
<span className="text-[11px] text-muted-foreground flex-1 truncate">
{row.definition
? t(
`${base}.triggerKinds.${row.definition.trigger.kind}`,
)
: ""}
{row.last_run_status
? ` · ${timeAgo(row.last_run_at)}`
: ""}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
title={t(`${base}.testRun`)}
onClick={() => run(row, true)}
>
<FlaskConical size={12} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
title={t(`${base}.runNow`)}
onClick={() => run(row, false)}
>
<Play size={12} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
onClick={() => remove(row)}
>
<Trash2 size={12} />
</Button>
</div>
</div>
))}
</div>
</>
)}
{tab === "runs" && (
<div className="space-y-1">
{runs.length === 0 && (
<p className="text-xs text-muted-foreground p-2">
{t(`${base}.emptyRuns`)}
</p>
)}
{runs.map((entry) => (
<div key={entry.id} className="border border-border">
<button
className="w-full text-left p-2.5 hover:bg-muted/40"
onClick={() => toggleRun(entry.id)}
>
<div className="flex items-center gap-2">
<span className="text-sm flex-1 truncate">
{entry.automation_name ?? `#${entry.automation_id}`}
</span>
{entry.dry_run ? (
<Badge variant="outline" className="text-[10px]">
{t(`${base}.dryRunBadge`)}
</Badge>
) : null}
<span
className={`text-[11px] ${STATUS_CLASS[entry.status] ?? ""}`}
>
{t(`${base}.statuses.${entry.status}`)}
</span>
</div>
<div className="text-[11px] text-muted-foreground">
{timeAgo(entry.started_at)}
{entry.duration_ms !== null
? ` · ${Math.round(entry.duration_ms / 100) / 10}s`
: ""}
</div>
{entry.error && (
<div className="text-[11px] text-destructive truncate">
{entry.error}
</div>
)}
</button>
{expandedRun === entry.id && (
<div className="border-t border-border p-2 space-y-1">
{runSteps.map((step) => (
<div key={step.id} className="text-[11px]">
<div className="flex gap-2">
<span className="text-muted-foreground">
{step.step_index + 1}.
</span>
<span className="flex-1">
{t(`${base}.stepTypes.${step.step_type}`, {
defaultValue: step.step_type,
})}
</span>
<span className={STATUS_CLASS[step.status] ?? ""}>
{step.status}
</span>
</div>
{step.output && (
<pre className="mt-1 p-1 bg-muted overflow-x-auto whitespace-pre-wrap break-all text-[10px]">
{step.output}
</pre>
)}
{step.error && (
<div className="text-destructive">{step.error}</div>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{tab === "channels" && (
<>
<Button
size="sm"
variant="outline"
className="rounded-none w-full mb-2 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditingChannel(null);
setChannelDialogOpen(true);
}}
>
<Plus size={14} className="mr-1" />
{t(`${base}.addChannel`)}
</Button>
{channels.length === 0 && (
<p className="text-xs text-muted-foreground p-2">
{t(`${base}.emptyChannels`)}
</p>
)}
<div className="space-y-1">
{channels.map((channel) => (
<div
key={channel.id}
className="border border-border p-2.5 hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<button
className="flex-1 text-left text-sm truncate"
onClick={() => {
setEditingChannel(channel);
setChannelDialogOpen(true);
}}
>
{channel.name}
</button>
<Badge variant="outline" className="text-[10px] uppercase">
{channel.type}
</Badge>
</div>
<div className="flex items-center gap-1 mt-1">
<span className="flex-1" />
<Button
variant="ghost"
size="sm"
className="h-6 rounded-none text-[11px]"
onClick={() => testChannel(channel)}
>
{t(`${base}.testChannel`)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
onClick={() => removeChannel(channel)}
>
<Trash2 size={12} />
</Button>
</div>
</div>
))}
</div>
</>
)}
</div>
<NotificationChannelDialog
open={channelDialogOpen}
onOpenChange={setChannelDialogOpen}
channel={editingChannel}
onSaved={() => {
setChannelDialogOpen(false);
void loadOptions();
}}
/>
</div>
);
}
export default AutomationsPanel;
@@ -1,258 +0,0 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import { Switch } from "@/components/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import {
AUTOMATION_DEFINITION_VERSION,
type AutomationDefinition,
type ConcurrencyPolicy,
type Step,
} from "@/types/automations";
import { StepBlock } from "./StepBlock";
import { TriggerCard, defaultTrigger } from "./TriggerCard";
import { newStepId, type AutomationEditorOptions } from "./editor-types";
export interface AutomationDraft {
name: string;
description: string;
enabled: boolean;
concurrencyPolicy: ConcurrencyPolicy;
definition: AutomationDefinition;
}
export function emptyDraft(): AutomationDraft {
return {
name: "",
description: "",
enabled: true,
concurrencyPolicy: "skip",
definition: {
version: AUTOMATION_DEFINITION_VERSION,
trigger: defaultTrigger("metric_threshold"),
steps: [],
},
};
}
/**
* The automation builder: one trigger, then an ordered list of steps that can
* nest through if/otherwise. A JSON view is available for anything the form
* does not cover yet.
*/
export function AutomationEditor({
draft,
options,
onChange,
}: {
draft: AutomationDraft;
options: AutomationEditorOptions;
onChange: (next: AutomationDraft) => void;
}) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
const [view, setView] = useState<"builder" | "json">("builder");
const [jsonText, setJsonText] = useState("");
const [jsonError, setJsonError] = useState<string | null>(null);
function setDefinition(definition: AutomationDefinition) {
onChange({ ...draft, definition });
}
function setSteps(steps: Step[]) {
setDefinition({ ...draft.definition, steps });
}
function renderSteps(
steps: Step[],
onStepsChange: (next: Step[]) => void,
depth: number,
): React.ReactNode {
return (
<div className="space-y-2">
{steps.map((step, index) => (
<StepBlock
key={step.id}
step={step}
index={index}
total={steps.length}
depth={depth}
options={options}
onChange={(next) => {
const copy = [...steps];
copy[index] = next;
onStepsChange(copy);
}}
onRemove={() => onStepsChange(steps.filter((_, i) => i !== index))}
onMove={(direction) => {
const target = index + direction;
if (target < 0 || target >= steps.length) return;
const copy = [...steps];
[copy[index], copy[target]] = [copy[target], copy[index]];
onStepsChange(copy);
}}
renderNested={renderSteps}
/>
))}
<Button
variant="outline"
size="sm"
className="rounded-none w-full border-border"
onClick={() =>
onStepsChange([
...steps,
{ id: newStepId(), type: "notify", channelIds: [] } as Step,
])
}
>
<Plus size={14} className="mr-1" />
{t(`${base}.addStep`)}
</Button>
</div>
);
}
return (
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.nameLabel`)}
</Label>
<Input
className="h-8 rounded-none"
placeholder={t(`${base}.namePlaceholder`)}
value={draft.name}
onChange={(e) => onChange({ ...draft, name: e.target.value })}
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch
checked={draft.enabled}
onCheckedChange={(enabled) => onChange({ ...draft, enabled })}
/>
{t(`${base}.enabledLabel`)}
</label>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.concurrencyLabel`)}
</Label>
<Select
value={draft.concurrencyPolicy}
onValueChange={(value) =>
onChange({
...draft,
concurrencyPolicy: value as ConcurrencyPolicy,
})
}
>
<SelectTrigger
size="sm"
className="w-full rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">
{t(`${base}.concurrency.skip`)}
</SelectItem>
<SelectItem value="queue">
{t(`${base}.concurrency.queue`)}
</SelectItem>
<SelectItem value="allow">
{t(`${base}.concurrency.allow`)}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t(`${base}.concurrencyHint`)}
</p>
</div>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
className={`rounded-none ${
view === "builder"
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand"
: "border-border"
}`}
onClick={() => setView("builder")}
>
{t(`${base}.viewBuilder`)}
</Button>
<Button
variant="outline"
size="sm"
className={`rounded-none ${
view === "json"
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand"
: "border-border"
}`}
onClick={() => {
setJsonText(JSON.stringify(draft.definition, null, 2));
setJsonError(null);
setView("json");
}}
>
{t(`${base}.viewJson`)}
</Button>
</div>
{view === "builder" ? (
<>
<TriggerCard
trigger={draft.definition.trigger}
options={options}
onChange={(trigger) =>
setDefinition({ ...draft.definition, trigger })
}
/>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
{t(`${base}.steps`)}
</Label>
{draft.definition.steps.length === 0 && (
<p className="text-xs text-muted-foreground">
{t(`${base}.noSteps`)}
</p>
)}
{renderSteps(draft.definition.steps, setSteps, 0)}
</div>
</>
) : (
<div className="space-y-2">
<textarea
className="w-full min-h-64 bg-background border border-border p-2 font-mono text-xs"
value={jsonText}
onChange={(e) => {
setJsonText(e.target.value);
try {
const parsed = JSON.parse(e.target.value);
setDefinition(parsed as AutomationDefinition);
setJsonError(null);
} catch (error) {
setJsonError(getErrorMessage(error, String(error)));
}
}}
/>
{jsonError && <p className="text-xs text-destructive">{jsonError}</p>}
</div>
)}
</div>
);
}
@@ -1,135 +0,0 @@
import { useTranslation } from "react-i18next";
import { Label } from "@/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import type { HostSelector } from "@/types/automations";
import type { AutomationEditorOptions } from "./editor-types";
/**
* Picks which hosts a step acts on. "The host that triggered this" is the
* common case, so it is offered first and used as the default.
*/
export function HostSelectorField({
value,
options,
onChange,
allowTrigger = true,
}: {
value: HostSelector | undefined;
options: AutomationEditorOptions;
onChange: (next: HostSelector) => void;
allowTrigger?: boolean;
}) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
const kind = value?.kind ?? (allowTrigger ? "trigger" : "host");
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.host`)}
</Label>
<div className="flex gap-2">
<Select
value={kind}
onValueChange={(next) =>
onChange(defaultFor(next as HostSelector["kind"]))
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{allowTrigger && (
<SelectItem value="trigger">
{t(`${base}.hostSelector.trigger`)}
</SelectItem>
)}
<SelectItem value="host">
{t(`${base}.hostSelector.host`)}
</SelectItem>
<SelectItem value="fleet">
{t(`${base}.hostSelector.fleet`)}
</SelectItem>
<SelectItem value="all">{t(`${base}.hostSelector.all`)}</SelectItem>
</SelectContent>
</Select>
{kind === "host" && (
<Select
value={
value?.kind === "host" && value.hostId ? String(value.hostId) : ""
}
onValueChange={(next) =>
onChange({ kind: "host", hostId: Number(next) })
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.hosts.map((host) => (
<SelectItem key={host.id} value={String(host.id)}>
{host.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{kind === "fleet" && (
<Select
value={
value?.kind === "fleet" && value.fleetId
? String(value.fleetId)
: ""
}
onValueChange={(next) =>
onChange({ kind: "fleet", fleetId: Number(next) })
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.fleets.map((fleet) => (
<SelectItem key={fleet.id} value={String(fleet.id)}>
{fleet.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
);
}
function defaultFor(kind: HostSelector["kind"]): HostSelector {
switch (kind) {
case "host":
return { kind: "host", hostId: 0 };
case "fleet":
return { kind: "fleet", fleetId: 0 };
case "hosts":
return { kind: "hosts", hostIds: [] };
case "all":
return { kind: "all" };
default:
return { kind: "trigger" };
}
}
-585
View File
@@ -1,585 +0,0 @@
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import { Textarea } from "@/components/textarea";
import { Switch } from "@/components/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import type { Step, StepType } from "@/types/automations";
import { HostSelectorField } from "./HostSelectorField";
import type { AutomationEditorOptions } from "./editor-types";
const STEP_TYPES: StepType[] = [
"notify",
"http",
"run_command",
"run_snippet",
"docker",
"tunnel",
"wol",
"wait",
"set_var",
"if",
"stop",
];
interface StepBlockProps {
step: Step;
index: number;
total: number;
depth: number;
options: AutomationEditorOptions;
onChange: (next: Step) => void;
onRemove: () => void;
onMove: (direction: -1 | 1) => void;
renderNested: (
steps: Step[],
onChange: (next: Step[]) => void,
depth: number,
) => React.ReactNode;
}
/**
* One block in the automation's step list. Each step type renders only the
* fields it actually uses, so the form stays readable as more types are added.
*/
export function StepBlock({
step,
index,
total,
depth,
options,
onChange,
onRemove,
onMove,
renderNested,
}: StepBlockProps) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
function patch(changes: Partial<Step>) {
onChange({ ...step, ...changes } as Step);
}
return (
<div
className={`border border-border p-3 ${depth > 0 ? "border-l-2 border-l-primary/40" : ""}`}
>
<div className="flex items-center gap-2 mb-3">
<Select
value={step.type}
onValueChange={(value) =>
onChange(changeStepType(step, value as StepType))
}
>
<SelectTrigger
size="sm"
className="flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STEP_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{t(`${base}.stepTypes.${type}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none"
disabled={index === 0}
title={t(`${base}.moveUp`)}
onClick={() => onMove(-1)}
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none"
disabled={index === total - 1}
title={t(`${base}.moveDown`)}
onClick={() => onMove(1)}
>
<ChevronDown size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none"
title={t(`${base}.removeStep`)}
onClick={onRemove}
>
<Trash2 size={14} />
</Button>
</div>
<div className="space-y-3">
{step.type === "notify" && (
<>
<Field label={t(`${base}.fields.title`)}>
<Input
className="h-8 rounded-none border-border"
value={step.title ?? ""}
onChange={(e) =>
patch({ title: e.target.value } as Partial<Step>)
}
/>
</Field>
<Field label={t(`${base}.fields.message`)}>
<Textarea
className="rounded-none border-border min-h-16"
value={step.body ?? ""}
onChange={(e) =>
patch({ body: e.target.value } as Partial<Step>)
}
/>
</Field>
<Field label={t(`${base}.fields.channels`)}>
<div className="flex flex-wrap gap-2">
{options.channels.length === 0 && (
<span className="text-xs text-muted-foreground">
{t(`${base}.noChannelsHint`)}
</span>
)}
{options.channels.map((channel) => {
const checked = step.channelIds?.includes(channel.id);
return (
<button
key={channel.id}
type="button"
onClick={() =>
patch({
channelIds: checked
? step.channelIds.filter((id) => id !== channel.id)
: [...(step.channelIds ?? []), channel.id],
} as Partial<Step>)
}
className={`px-2 py-1 text-xs border ${
checked
? "border-primary text-foreground"
: "border-border text-muted-foreground"
}`}
>
{channel.name}
</button>
);
})}
</div>
</Field>
</>
)}
{step.type === "http" && (
<>
<div className="flex gap-2">
<Select
value={step.method}
onValueChange={(value) =>
patch({ method: value } as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="w-28 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["GET", "POST", "PUT", "PATCH", "DELETE"].map((method) => (
<SelectItem key={method} value={method}>
{method}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
className="h-8 flex-1 rounded-none border-border"
placeholder="https://"
value={step.url ?? ""}
onChange={(e) =>
patch({ url: e.target.value } as Partial<Step>)
}
/>
</div>
<Field label={t(`${base}.fields.body`)}>
<Textarea
className="rounded-none border-border min-h-16 font-mono text-xs"
value={step.body ?? ""}
onChange={(e) =>
patch({ body: e.target.value } as Partial<Step>)
}
/>
</Field>
<label className="flex items-start gap-2 text-xs text-muted-foreground">
<Switch
checked={!!step.allowPrivateNetwork}
onCheckedChange={(checked) =>
patch({ allowPrivateNetwork: checked } as Partial<Step>)
}
/>
<span>
{t(`${base}.fields.allowPrivateNetwork`)}
<span className="block">
{t(`${base}.fields.allowPrivateNetworkHint`)}
</span>
</span>
</label>
</>
)}
{step.type === "run_command" && (
<>
<HostSelectorField
value={step.hostSelector}
options={options}
onChange={(hostSelector) =>
patch({ hostSelector } as Partial<Step>)
}
/>
<Field label={t(`${base}.fields.command`)}>
<Textarea
className="rounded-none border-border min-h-16 font-mono text-xs"
value={step.command ?? ""}
onChange={(e) =>
patch({ command: e.target.value } as Partial<Step>)
}
/>
</Field>
<ElevatedToggle
checked={!!step.elevated}
onChange={(elevated) => patch({ elevated } as Partial<Step>)}
/>
</>
)}
{step.type === "run_snippet" && (
<>
<HostSelectorField
value={step.hostSelector}
options={options}
onChange={(hostSelector) =>
patch({ hostSelector } as Partial<Step>)
}
/>
<Field label={t(`${base}.fields.snippet`)}>
<Select
value={step.snippetId ? String(step.snippetId) : ""}
onValueChange={(value) =>
patch({ snippetId: Number(value) } as Partial<Step>)
}
>
<SelectTrigger size="sm" className="rounded-none border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.snippets.map((snippet) => (
<SelectItem key={snippet.id} value={String(snippet.id)}>
{snippet.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<ElevatedToggle
checked={!!step.elevated}
onChange={(elevated) => patch({ elevated } as Partial<Step>)}
/>
</>
)}
{step.type === "docker" && (
<>
<HostSelectorField
value={step.hostSelector}
options={options}
onChange={(hostSelector) =>
patch({ hostSelector } as Partial<Step>)
}
/>
<div className="flex gap-2">
<Select
value={step.action}
onValueChange={(value) =>
patch({ action: value } as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["start", "stop", "restart"].map((action) => (
<SelectItem key={action} value={action}>
{action}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
className="h-8 flex-1 rounded-none border-border"
placeholder={t(`${base}.fields.container`)}
value={step.container ?? ""}
onChange={(e) =>
patch({ container: e.target.value } as Partial<Step>)
}
/>
</div>
</>
)}
{step.type === "tunnel" && (
<div className="flex gap-2">
<Select
value={step.action}
onValueChange={(value) =>
patch({ action: value } as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="connect">connect</SelectItem>
<SelectItem value="disconnect">disconnect</SelectItem>
</SelectContent>
</Select>
<Input
className="h-8 flex-1 rounded-none border-border"
value={step.tunnelName ?? ""}
onChange={(e) =>
patch({ tunnelName: e.target.value } as Partial<Step>)
}
/>
</div>
)}
{step.type === "wol" && (
<Field label={t(`${base}.fields.host`)}>
<Select
value={step.hostId ? String(step.hostId) : ""}
onValueChange={(value) =>
patch({ hostId: Number(value) } as Partial<Step>)
}
>
<SelectTrigger size="sm" className="rounded-none border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.hosts.map((host) => (
<SelectItem key={host.id} value={String(host.id)}>
{host.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
{step.type === "wait" && (
<Field label={t(`${base}.fields.seconds`)}>
<Input
type="number"
min={1}
className="h-8 w-32 rounded-none border-border"
value={step.seconds ?? 0}
onChange={(e) =>
patch({ seconds: Number(e.target.value) } as Partial<Step>)
}
/>
</Field>
)}
{step.type === "set_var" && (
<div className="flex gap-2">
<Input
className="h-8 w-40 rounded-none border-border"
placeholder={t(`${base}.fields.variableName`)}
value={step.name ?? ""}
onChange={(e) => patch({ name: e.target.value } as Partial<Step>)}
/>
<Input
className="h-8 flex-1 rounded-none border-border"
placeholder={t(`${base}.fields.variableValue`)}
value={step.value ?? ""}
onChange={(e) =>
patch({ value: e.target.value } as Partial<Step>)
}
/>
</div>
)}
{step.type === "if" && (
<div className="space-y-3">
<div className="flex gap-2">
<Input
className="h-8 flex-1 rounded-none border-border font-mono text-xs"
placeholder={t(`${base}.fields.conditionLeftPlaceholder`)}
value={step.condition?.left ?? ""}
onChange={(e) =>
patch({
condition: { ...step.condition, left: e.target.value },
} as Partial<Step>)
}
/>
<Select
value={step.condition?.operator ?? "=="}
onValueChange={(value) =>
patch({
condition: { ...step.condition, operator: value },
} as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="w-28 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[">", "<", ">=", "<=", "==", "!=", "contains"].map((op) => (
<SelectItem key={op} value={op}>
{op}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
className="h-8 flex-1 rounded-none border-border font-mono text-xs"
placeholder={t(`${base}.fields.conditionRightPlaceholder`)}
value={step.condition?.right ?? ""}
onChange={(e) =>
patch({
condition: { ...step.condition, right: e.target.value },
} as Partial<Step>)
}
/>
</div>
{renderNested(
step.then ?? [],
(next) => patch({ then: next } as Partial<Step>),
depth + 1,
)}
</div>
)}
{step.type !== "stop" && (
<Field label={t(`${base}.fields.onError`)}>
<Select
value={step.onError ?? "stop"}
onValueChange={(value) =>
patch({ onError: value } as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="min-w-0 w-full rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stop">
{t(`${base}.onError.stop`)}
</SelectItem>
<SelectItem value="continue">
{t(`${base}.onError.continue`)}
</SelectItem>
</SelectContent>
</Select>
</Field>
)}
</div>
</div>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}
function ElevatedToggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (value: boolean) => void;
}) {
const { t } = useTranslation();
return (
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch checked={checked} onCheckedChange={onChange} />
{t("newUi.sidebar.automations.fields.elevated")}
</label>
);
}
/** Keeps the step id when the type changes, so run history stays linked. */
function changeStepType(step: Step, type: StepType): Step {
const next: Record<string, unknown> = { id: step.id, type };
switch (type) {
case "notify":
next.channelIds = [];
break;
case "http":
next.method = "POST";
next.url = "";
break;
case "run_command":
next.command = "";
next.hostSelector = { kind: "trigger" };
break;
case "run_snippet":
next.hostSelector = { kind: "trigger" };
break;
case "docker":
next.action = "restart";
next.container = "";
next.hostSelector = { kind: "trigger" };
break;
case "tunnel":
next.action = "connect";
next.tunnelName = "";
break;
case "wait":
next.seconds = 30;
break;
case "set_var":
next.name = "";
next.value = "";
break;
case "if":
next.condition = { left: "", operator: "==", right: "" };
next.then = [];
break;
default:
break;
}
return next as unknown as Step;
}
-425
View File
@@ -1,425 +0,0 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import type { Trigger, TriggerKind } from "@/types/automations";
import { HostSelectorField } from "./HostSelectorField";
import type { AutomationEditorOptions } from "./editor-types";
const TRIGGER_KINDS: TriggerKind[] = [
"metric_threshold",
"host_status",
"health_check",
"schedule",
"docker_event",
"webhook",
];
/** Sentinel for "no zone set", which means the server's own time. */
const SERVER_TIMEZONE = "__server__";
/**
* Zones offered for a cron schedule. Uses the runtime's own list where it is
* available so the picker is complete, with a small fallback for older
* browsers that do not expose supportedValuesOf.
*/
const TIMEZONES: string[] = (() => {
try {
const supported = (
Intl as unknown as { supportedValuesOf?: (key: string) => string[] }
).supportedValuesOf?.("timeZone");
if (supported?.length) return supported;
} catch {
// Fall through to the short list.
}
return [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"Europe/London",
"Europe/Berlin",
"Europe/Moscow",
"Asia/Kolkata",
"Asia/Shanghai",
"Asia/Tokyo",
"Australia/Sydney",
];
})();
const METRIC_PATHS = [
"cpu.percent",
"memory.percent",
"disk.percent",
"temperature.highestCelsius",
"cpu.load1",
"processes.total",
"network.rxRateBps",
"network.txRateBps",
];
/** The single trigger at the top of an automation. */
export function TriggerCard({
trigger,
options,
onChange,
}: {
trigger: Trigger;
options: AutomationEditorOptions;
onChange: (next: Trigger) => void;
}) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
function patch(changes: Record<string, unknown>) {
onChange({ ...trigger, ...changes } as Trigger);
}
return (
<div className="border border-border p-3 space-y-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.trigger`)}
</Label>
<Select
value={trigger.kind}
onValueChange={(value) =>
onChange(defaultTrigger(value as TriggerKind))
}
>
<SelectTrigger size="sm" className="rounded-none border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{t(`${base}.triggerKinds.${kind}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{trigger.kind === "metric_threshold" && (
<>
<HostSelectorField
value={trigger.hostSelector}
options={options}
allowTrigger={false}
onChange={(hostSelector) => patch({ hostSelector })}
/>
<div className="flex gap-2">
<Select
value={trigger.metric?.path ?? "cpu.percent"}
onValueChange={(value) =>
patch({ metric: { ...trigger.metric, path: value } })
}
>
<SelectTrigger
size="sm"
className="flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{METRIC_PATHS.map((path) => (
<SelectItem key={path} value={path}>
{path}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={trigger.operator}
onValueChange={(value) => patch({ operator: value })}
>
<SelectTrigger
size="sm"
className="w-20 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[">", ">=", "<", "<=", "==", "!="].map((op) => (
<SelectItem key={op} value={op}>
{op}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
className="h-8 w-24 rounded-none border-border"
value={trigger.value ?? 0}
onChange={(e) => patch({ value: Number(e.target.value) })}
/>
</div>
{trigger.metric?.path === "disk.percent" && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.mount`)}
</Label>
<Input
className="h-8 rounded-none border-border font-mono text-xs"
placeholder="/data"
value={trigger.metric?.mount ?? ""}
onChange={(e) =>
patch({
metric: {
...trigger.metric,
mount: e.target.value || undefined,
},
})
}
/>
</div>
)}
{trigger.metric?.path.startsWith("network.") &&
"iface" in trigger.metric && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.interface`)}
</Label>
<Input
className="h-8 rounded-none border-border font-mono text-xs"
placeholder="eth0"
value={trigger.metric.iface ?? ""}
onChange={(e) =>
patch({
metric: {
...trigger.metric,
iface: e.target.value || undefined,
},
})
}
/>
</div>
)}
<div className="flex gap-2">
<NumberField
label={t(`${base}.fields.forSeconds`)}
value={trigger.forSeconds ?? 0}
onChange={(value) => patch({ forSeconds: value })}
/>
<NumberField
label={t(`${base}.fields.cooldown`)}
value={trigger.cooldownMinutes ?? 15}
onChange={(value) => patch({ cooldownMinutes: value })}
/>
</div>
</>
)}
{(trigger.kind === "host_status" || trigger.kind === "health_check") && (
<>
<HostSelectorField
value={trigger.hostSelector}
options={options}
allowTrigger={false}
onChange={(hostSelector) => patch({ hostSelector })}
/>
<Select
value={trigger.to}
onValueChange={(value) => patch({ to: value })}
>
<SelectTrigger size="sm" className="rounded-none border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{trigger.kind === "host_status" ? (
<>
<SelectItem value="offline">offline</SelectItem>
<SelectItem value="online">online</SelectItem>
</>
) : (
<>
<SelectItem value="failing">failing</SelectItem>
<SelectItem value="recovered">recovered</SelectItem>
</>
)}
</SelectContent>
</Select>
<NumberField
label={t(`${base}.fields.cooldown`)}
value={trigger.cooldownMinutes ?? 15}
onChange={(value) => patch({ cooldownMinutes: value })}
/>
</>
)}
{trigger.kind === "schedule" && (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.cron`)}
</Label>
<Input
className="h-8 rounded-none border-border font-mono text-xs"
placeholder="0 2 * * *"
value={trigger.cron ?? ""}
onChange={(e) =>
patch({ cron: e.target.value, intervalSeconds: undefined })
}
/>
</div>
<NumberField
label={t(`${base}.fields.intervalSeconds`)}
value={trigger.intervalSeconds ?? 0}
onChange={(value) =>
patch({ intervalSeconds: value || undefined, cron: undefined })
}
/>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.timezone`)}
</Label>
<Select
value={trigger.timezone ?? SERVER_TIMEZONE}
onValueChange={(value) =>
patch({
timezone: value === SERVER_TIMEZONE ? undefined : value,
})
}
>
<SelectTrigger
size="sm"
className="w-full rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={SERVER_TIMEZONE}>
{t(`${base}.fields.timezoneServer`)}
</SelectItem>
{TIMEZONES.map((zone) => (
<SelectItem key={zone} value={zone}>
{zone}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t(`${base}.fields.timezoneHint`)}
</p>
</div>
</div>
)}
{trigger.kind === "docker_event" && (
<>
<HostSelectorField
value={trigger.hostSelector}
options={options}
allowTrigger={false}
onChange={(hostSelector) => patch({ hostSelector })}
/>
<div className="flex gap-2">
<Input
className="h-8 flex-1 rounded-none border-border"
placeholder={t(`${base}.fields.container`)}
value={trigger.container ?? ""}
onChange={(e) => patch({ container: e.target.value })}
/>
<Select
value={trigger.event}
onValueChange={(value) => patch({ event: value })}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["exited", "started", "unhealthy", "restarting"].map((e) => (
<SelectItem key={e} value={e}>
{e}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{trigger.kind === "webhook" && (
<p className="text-xs text-muted-foreground">
{t(`${base}.webhookTokenDescription`)}
</p>
)}
</div>
);
}
function NumberField({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (value: number) => void;
}) {
return (
<div className="space-y-1 flex-1">
<Label className="text-xs text-muted-foreground">{label}</Label>
<Input
type="number"
min={0}
className="h-8 rounded-none border-border"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
);
}
export function defaultTrigger(kind: TriggerKind): Trigger {
switch (kind) {
case "host_status":
return {
kind: "host_status",
hostSelector: { kind: "all" },
to: "offline",
cooldownMinutes: 15,
};
case "health_check":
return {
kind: "health_check",
hostSelector: { kind: "all" },
to: "failing",
cooldownMinutes: 15,
};
case "schedule":
return { kind: "schedule", cron: "0 2 * * *" };
case "docker_event":
return {
kind: "docker_event",
hostSelector: { kind: "all" },
event: "exited",
cooldownMinutes: 15,
};
case "webhook":
return { kind: "webhook", tokenHash: "" };
default:
return {
kind: "metric_threshold",
hostSelector: { kind: "all" },
metric: { path: "cpu.percent" },
operator: ">",
value: 90,
forSeconds: 300,
cooldownMinutes: 15,
};
}
}
@@ -1,19 +0,0 @@
/** Lookup data the editor needs to turn ids into names. */
export interface AutomationEditorOptions {
hosts: Array<{ id: number; name: string }>;
snippets: Array<{ id: number; name: string }>;
channels: Array<{ id: number; name: string }>;
fleets: Array<{ id: number; name: string }>;
}
export const EMPTY_EDITOR_OPTIONS: AutomationEditorOptions = {
hosts: [],
snippets: [],
channels: [],
fleets: [],
};
/** Short unique id for a new step, stable for the life of the automation. */
export function newStepId(): string {
return `s${Math.random().toString(36).slice(2, 8)}`;
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { defaultTrigger } from "@/sidebar/automations/TriggerCard";
import { emptyDraft } from "@/sidebar/automations/AutomationEditor";
import { newStepId } from "@/sidebar/automations/editor-types";
import { defaultTrigger } from "../../../../../plugins/automations/frontend/automations/TriggerCard";
import { emptyDraft } from "../../../../../plugins/automations/frontend/automations/AutomationEditor";
import { newStepId } from "../../../../../plugins/automations/frontend/automations/editor-types";
import { AUTOMATION_DEFINITION_VERSION } from "@/types/automations";
describe("emptyDraft", () => {