mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
586 lines
18 KiB
TypeScript
586 lines
18 KiB
TypeScript
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;
|
|
}
|