mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 08:05:23 +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
@@ -0,0 +1,258 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user