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