@checkstack/automation-frontend 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +664 -0
- package/package.json +38 -0
- package/src/components/AutomationMenuItems.tsx +37 -0
- package/src/editor/ActionEditor.tsx +367 -0
- package/src/editor/ActionListEditor.tsx +203 -0
- package/src/editor/AddActionDialog.tsx +225 -0
- package/src/editor/AutomationDefinitionContext.tsx +37 -0
- package/src/editor/AutomationDefinitionEditor.tsx +99 -0
- package/src/editor/ConditionEditor.tsx +218 -0
- package/src/editor/ConditionsEditor.tsx +89 -0
- package/src/editor/ItemPicker.tsx +147 -0
- package/src/editor/TriggersEditor.tsx +269 -0
- package/src/editor/action-composite-cards.tsx +390 -0
- package/src/editor/action-helpers.ts +365 -0
- package/src/editor/action-leaf-cards.tsx +426 -0
- package/src/editor/editor-validation.test.ts +95 -0
- package/src/editor/editor-validation.tsx +200 -0
- package/src/editor/registry-context.tsx +192 -0
- package/src/editor/template-completion.test.ts +412 -0
- package/src/editor/template-completion.ts +664 -0
- package/src/editor/template-helpers.test.ts +145 -0
- package/src/editor/template-helpers.ts +95 -0
- package/src/editor/trigger-helpers.test.ts +58 -0
- package/src/editor/trigger-helpers.ts +67 -0
- package/src/editor/useConnectionOptionResolvers.ts +80 -0
- package/src/editor/yaml-markers.ts +116 -0
- package/src/index.tsx +95 -0
- package/src/pages/AutomationEditPage.tsx +567 -0
- package/src/pages/AutomationListPage.tsx +304 -0
- package/src/pages/RunDetailPage.tsx +333 -0
- package/src/pages/RunsPage.tsx +233 -0
- package/src/pages/TemplatePlaygroundPage.tsx +224 -0
- package/src/script-context.test.ts +247 -0
- package/src/script-context.ts +218 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ChevronDown, Search } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Input,
|
|
6
|
+
Popover,
|
|
7
|
+
PopoverContent,
|
|
8
|
+
PopoverTrigger,
|
|
9
|
+
} from "@checkstack/ui";
|
|
10
|
+
|
|
11
|
+
export interface PickerItem {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
category?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ItemPickerProps {
|
|
19
|
+
items: PickerItem[];
|
|
20
|
+
value?: string;
|
|
21
|
+
onSelect: (id: string) => void;
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
emptyText?: string;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Type-as-you-go combobox for selecting an item from a registered list
|
|
30
|
+
* (action ids, trigger event ids, artifact types). Composes
|
|
31
|
+
* `<Popover>` + `<Input>` + a filtered list — Radix Select doesn't do
|
|
32
|
+
* substring filtering, only jump-to-letter, so we roll our own.
|
|
33
|
+
*
|
|
34
|
+
* Groups items by `category` when set; otherwise renders a flat list.
|
|
35
|
+
* Selecting an item closes the popover and fires `onSelect(id)`.
|
|
36
|
+
*/
|
|
37
|
+
export const ItemPicker: React.FC<ItemPickerProps> = ({
|
|
38
|
+
items,
|
|
39
|
+
value,
|
|
40
|
+
onSelect,
|
|
41
|
+
placeholder = "Select…",
|
|
42
|
+
emptyText = "No matches",
|
|
43
|
+
disabled,
|
|
44
|
+
className,
|
|
45
|
+
}) => {
|
|
46
|
+
const [open, setOpen] = React.useState(false);
|
|
47
|
+
const [query, setQuery] = React.useState("");
|
|
48
|
+
|
|
49
|
+
const selected = items.find((item) => item.id === value);
|
|
50
|
+
|
|
51
|
+
const filtered = React.useMemo(() => {
|
|
52
|
+
const q = query.trim().toLowerCase();
|
|
53
|
+
if (!q) return items;
|
|
54
|
+
return items.filter(
|
|
55
|
+
(item) =>
|
|
56
|
+
item.id.toLowerCase().includes(q) ||
|
|
57
|
+
item.label.toLowerCase().includes(q) ||
|
|
58
|
+
item.description?.toLowerCase().includes(q),
|
|
59
|
+
);
|
|
60
|
+
}, [items, query]);
|
|
61
|
+
|
|
62
|
+
const grouped = React.useMemo(() => {
|
|
63
|
+
const groups = new Map<string, PickerItem[]>();
|
|
64
|
+
for (const item of filtered) {
|
|
65
|
+
const key = item.category ?? "";
|
|
66
|
+
const list = groups.get(key) ?? [];
|
|
67
|
+
list.push(item);
|
|
68
|
+
groups.set(key, list);
|
|
69
|
+
}
|
|
70
|
+
return [...groups.entries()];
|
|
71
|
+
}, [filtered]);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
75
|
+
<PopoverTrigger asChild>
|
|
76
|
+
<Button
|
|
77
|
+
type="button"
|
|
78
|
+
variant="outline"
|
|
79
|
+
disabled={disabled}
|
|
80
|
+
className={`w-full justify-between font-normal ${className ?? ""}`.trim()}
|
|
81
|
+
>
|
|
82
|
+
<span className={selected ? "" : "text-muted-foreground"}>
|
|
83
|
+
{selected ? selected.label : placeholder}
|
|
84
|
+
</span>
|
|
85
|
+
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
86
|
+
</Button>
|
|
87
|
+
</PopoverTrigger>
|
|
88
|
+
<PopoverContent className="w-96 p-0" align="start">
|
|
89
|
+
<div className="border-b border-border p-2">
|
|
90
|
+
<div className="relative">
|
|
91
|
+
<Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
|
92
|
+
<Input
|
|
93
|
+
autoFocus
|
|
94
|
+
value={query}
|
|
95
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
96
|
+
placeholder="Filter…"
|
|
97
|
+
className="h-7 pl-7 text-xs"
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
<div className="max-h-80 overflow-y-auto py-1">
|
|
102
|
+
{filtered.length === 0 ? (
|
|
103
|
+
<p className="px-3 py-4 text-center text-xs italic text-muted-foreground">
|
|
104
|
+
{emptyText}
|
|
105
|
+
</p>
|
|
106
|
+
) : (
|
|
107
|
+
grouped.map(([category, list]) => (
|
|
108
|
+
<div key={category || "default"}>
|
|
109
|
+
{category && (
|
|
110
|
+
<p className="px-2 pt-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
111
|
+
{category}
|
|
112
|
+
</p>
|
|
113
|
+
)}
|
|
114
|
+
{list.map((item) => (
|
|
115
|
+
<button
|
|
116
|
+
key={item.id}
|
|
117
|
+
type="button"
|
|
118
|
+
onClick={() => {
|
|
119
|
+
onSelect(item.id);
|
|
120
|
+
setOpen(false);
|
|
121
|
+
setQuery("");
|
|
122
|
+
}}
|
|
123
|
+
className={`flex w-full flex-col gap-0.5 px-3 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground ${
|
|
124
|
+
item.id === value ? "bg-accent/50" : ""
|
|
125
|
+
}`}
|
|
126
|
+
>
|
|
127
|
+
<span className="flex items-center gap-2">
|
|
128
|
+
<span className="font-medium">{item.label}</span>
|
|
129
|
+
<code className="font-mono text-[10px] text-muted-foreground">
|
|
130
|
+
{item.id}
|
|
131
|
+
</code>
|
|
132
|
+
</span>
|
|
133
|
+
{item.description && (
|
|
134
|
+
<span className="text-[10px] text-muted-foreground">
|
|
135
|
+
{item.description}
|
|
136
|
+
</span>
|
|
137
|
+
)}
|
|
138
|
+
</button>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
))
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
</PopoverContent>
|
|
145
|
+
</Popover>
|
|
146
|
+
);
|
|
147
|
+
};
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Plus, Trash2 } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Card,
|
|
6
|
+
CardContent,
|
|
7
|
+
CardHeader,
|
|
8
|
+
CardTitle,
|
|
9
|
+
Input,
|
|
10
|
+
Label,
|
|
11
|
+
DynamicForm,
|
|
12
|
+
TemplateValueInput,
|
|
13
|
+
Badge,
|
|
14
|
+
} from "@checkstack/ui";
|
|
15
|
+
import type {
|
|
16
|
+
AutomationDefinition,
|
|
17
|
+
Trigger,
|
|
18
|
+
} from "@checkstack/automation-common";
|
|
19
|
+
import { useAutomationRegistry, useVariableScope } from "./registry-context";
|
|
20
|
+
import { ItemPicker } from "./ItemPicker";
|
|
21
|
+
import { useTriggerIssues } from "./editor-validation";
|
|
22
|
+
import { collectTriggerIds, defaultTriggerId } from "./trigger-helpers";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a minimal `AutomationDefinition` that only subscribes to the
|
|
26
|
+
* given trigger and has no actions — used to feed `useVariableScope`
|
|
27
|
+
* for a trigger's `filter:` field, where the in-scope variables are
|
|
28
|
+
* just this specific trigger's payload (no upstream actions exist
|
|
29
|
+
* yet, no other triggers are relevant because the filter runs
|
|
30
|
+
* per-trigger). Memoised in the consumer so the resolver's `useMemo`
|
|
31
|
+
* dep array stays stable across re-renders.
|
|
32
|
+
*/
|
|
33
|
+
function buildTriggerFilterDefinition(triggerEvent: string): AutomationDefinition {
|
|
34
|
+
return {
|
|
35
|
+
name: "_",
|
|
36
|
+
triggers: [{ event: triggerEvent }],
|
|
37
|
+
conditions: [],
|
|
38
|
+
actions: [],
|
|
39
|
+
mode: "single",
|
|
40
|
+
max_runs: 1,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TriggersEditorProps {
|
|
45
|
+
value: Trigger[];
|
|
46
|
+
onChange: (next: Trigger[]) => void;
|
|
47
|
+
disabled?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Editor for the automation's `triggers` array. Each trigger card has:
|
|
52
|
+
*
|
|
53
|
+
* - Event picker (combobox over `listTriggers()` from the registry).
|
|
54
|
+
* - Optional operator-assigned `id` field — used as a discriminator
|
|
55
|
+
* in `choose: when: trigger.id == "x"` expressions.
|
|
56
|
+
* - Optional `filter` template that gates the trigger before any
|
|
57
|
+
* action runs.
|
|
58
|
+
* - When the selected trigger declares a `configSchema`, a
|
|
59
|
+
* DynamicForm renders the per-trigger configuration (e.g.
|
|
60
|
+
* `cronPattern` for `automation.cron`, `intervalSeconds` for
|
|
61
|
+
* `automation.interval`).
|
|
62
|
+
*
|
|
63
|
+
* The triggers list itself isn't drag-reorderable — order doesn't
|
|
64
|
+
* affect runtime behaviour for triggers (any matching trigger fires
|
|
65
|
+
* the automation), and a static list keeps the UI calmer.
|
|
66
|
+
*/
|
|
67
|
+
export const TriggersEditor: React.FC<TriggersEditorProps> = ({
|
|
68
|
+
value,
|
|
69
|
+
onChange,
|
|
70
|
+
disabled,
|
|
71
|
+
}) => {
|
|
72
|
+
const { triggers } = useAutomationRegistry();
|
|
73
|
+
const pickerItems = React.useMemo(
|
|
74
|
+
() =>
|
|
75
|
+
triggers.map((t) => ({
|
|
76
|
+
id: t.qualifiedId,
|
|
77
|
+
label: t.displayName,
|
|
78
|
+
description: t.description,
|
|
79
|
+
category: t.category,
|
|
80
|
+
})),
|
|
81
|
+
[triggers],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const handleAdd = () => {
|
|
85
|
+
// Assign a unique default id up front (deduped against existing triggers)
|
|
86
|
+
// so the new trigger is immediately referenceable as `trigger.id` and the
|
|
87
|
+
// field shows a value rather than appearing blank.
|
|
88
|
+
const fresh: Trigger = { event: triggers[0]?.qualifiedId ?? "" };
|
|
89
|
+
const id = defaultTriggerId(fresh, collectTriggerIds(value));
|
|
90
|
+
onChange([...value, { ...fresh, id }]);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Card>
|
|
95
|
+
<CardHeader className="border-b">
|
|
96
|
+
<div className="flex items-center justify-between">
|
|
97
|
+
<CardTitle className="text-base">Triggers</CardTitle>
|
|
98
|
+
<Button
|
|
99
|
+
type="button"
|
|
100
|
+
variant="outline"
|
|
101
|
+
size="sm"
|
|
102
|
+
onClick={handleAdd}
|
|
103
|
+
disabled={disabled}
|
|
104
|
+
>
|
|
105
|
+
<Plus className="mr-1 h-3 w-3" />
|
|
106
|
+
Add trigger
|
|
107
|
+
</Button>
|
|
108
|
+
</div>
|
|
109
|
+
</CardHeader>
|
|
110
|
+
<CardContent className="space-y-2 p-3">
|
|
111
|
+
{value.length === 0 && (
|
|
112
|
+
<p className="text-xs italic text-muted-foreground">
|
|
113
|
+
An automation needs at least one trigger.
|
|
114
|
+
</p>
|
|
115
|
+
)}
|
|
116
|
+
{value.map((trigger, index) => (
|
|
117
|
+
<TriggerCard
|
|
118
|
+
key={index}
|
|
119
|
+
index={index}
|
|
120
|
+
value={trigger}
|
|
121
|
+
onChange={(next) => {
|
|
122
|
+
const list = [...value];
|
|
123
|
+
list[index] = next;
|
|
124
|
+
onChange(list);
|
|
125
|
+
}}
|
|
126
|
+
onRemove={() => onChange(value.filter((_, i) => i !== index))}
|
|
127
|
+
disabled={disabled}
|
|
128
|
+
pickerItems={pickerItems}
|
|
129
|
+
// Ids of the other triggers — used to keep this trigger's
|
|
130
|
+
// auto-filled id unique when the operator clears the field.
|
|
131
|
+
siblingIds={collectTriggerIds(value.filter((_, i) => i !== index))}
|
|
132
|
+
/>
|
|
133
|
+
))}
|
|
134
|
+
</CardContent>
|
|
135
|
+
</Card>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const TriggerCard: React.FC<{
|
|
140
|
+
index: number;
|
|
141
|
+
value: Trigger;
|
|
142
|
+
onChange: (next: Trigger) => void;
|
|
143
|
+
onRemove: () => void;
|
|
144
|
+
disabled?: boolean;
|
|
145
|
+
pickerItems: Array<{ id: string; label: string; description?: string; category?: string }>;
|
|
146
|
+
siblingIds: Set<string>;
|
|
147
|
+
}> = ({ index, value, onChange, onRemove, disabled, pickerItems, siblingIds }) => {
|
|
148
|
+
const { triggers } = useAutomationRegistry();
|
|
149
|
+
const selected = triggers.find((t) => t.qualifiedId === value.event);
|
|
150
|
+
const issues = useTriggerIssues(index);
|
|
151
|
+
|
|
152
|
+
// Templates inside the filter / config see only the selected
|
|
153
|
+
// trigger's payload — there are no other triggers, no upstream
|
|
154
|
+
// actions, and no variables in scope at filter-evaluation time.
|
|
155
|
+
const filterScopeDefinition = React.useMemo(
|
|
156
|
+
() => buildTriggerFilterDefinition(value.event),
|
|
157
|
+
[value.event],
|
|
158
|
+
);
|
|
159
|
+
const { templateCompletion } = useVariableScope({
|
|
160
|
+
definition: filterScopeDefinition,
|
|
161
|
+
path: [{ slot: "root", index: 0 }],
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<Card
|
|
166
|
+
className={
|
|
167
|
+
issues.length > 0
|
|
168
|
+
? "border-destructive/60 bg-muted/30 ring-1 ring-destructive/30"
|
|
169
|
+
: "border-border/60 bg-muted/30"
|
|
170
|
+
}
|
|
171
|
+
>
|
|
172
|
+
<CardContent className="space-y-3 p-3">
|
|
173
|
+
{issues.length > 0 && (
|
|
174
|
+
<ul className="space-y-0.5">
|
|
175
|
+
{issues.map((issue, i) => (
|
|
176
|
+
<li key={i} className="text-[11px] font-mono text-destructive">
|
|
177
|
+
{issue}
|
|
178
|
+
</li>
|
|
179
|
+
))}
|
|
180
|
+
</ul>
|
|
181
|
+
)}
|
|
182
|
+
<div className="flex items-start gap-2">
|
|
183
|
+
<div className="flex-1 space-y-3">
|
|
184
|
+
<div className="space-y-1">
|
|
185
|
+
<Label className="text-xs">Event</Label>
|
|
186
|
+
<ItemPicker
|
|
187
|
+
items={pickerItems}
|
|
188
|
+
value={value.event}
|
|
189
|
+
onSelect={(id) => onChange({ ...value, event: id })}
|
|
190
|
+
placeholder="Pick a trigger event"
|
|
191
|
+
disabled={disabled}
|
|
192
|
+
/>
|
|
193
|
+
{selected && (
|
|
194
|
+
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
|
195
|
+
<Badge variant="outline" className="text-[10px]">
|
|
196
|
+
{selected.ownerPluginId}
|
|
197
|
+
</Badge>
|
|
198
|
+
{selected.description}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
<div className="grid gap-2 sm:grid-cols-2">
|
|
203
|
+
<div className="space-y-1">
|
|
204
|
+
<Label className="text-xs" htmlFor={`trigger-id-${index}`}>
|
|
205
|
+
ID
|
|
206
|
+
</Label>
|
|
207
|
+
<Input
|
|
208
|
+
id={`trigger-id-${index}`}
|
|
209
|
+
value={value.id ?? ""}
|
|
210
|
+
onChange={(event) =>
|
|
211
|
+
onChange({
|
|
212
|
+
...value,
|
|
213
|
+
id: event.target.value || undefined,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
onBlur={() => {
|
|
217
|
+
// Never leave the id blank: re-fill a unique default so the
|
|
218
|
+
// trigger stays referenceable as `trigger.id` and is
|
|
219
|
+
// distinguishable from sibling triggers.
|
|
220
|
+
if (value.id) return;
|
|
221
|
+
onChange({ ...value, id: defaultTriggerId(value, siblingIds) });
|
|
222
|
+
}}
|
|
223
|
+
placeholder="Generated on blur"
|
|
224
|
+
disabled={disabled}
|
|
225
|
+
className="font-mono text-xs"
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
<div className="space-y-1">
|
|
229
|
+
<Label className="text-xs">Filter template</Label>
|
|
230
|
+
<TemplateValueInput
|
|
231
|
+
value={value.filter ?? ""}
|
|
232
|
+
onChange={(next) =>
|
|
233
|
+
onChange({ ...value, filter: next || undefined })
|
|
234
|
+
}
|
|
235
|
+
placeholder="{{ trigger.payload.severity == "high" }}"
|
|
236
|
+
completionProvider={templateCompletion}
|
|
237
|
+
disabled={disabled}
|
|
238
|
+
/>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
{selected?.configSchema && (
|
|
242
|
+
<div className="space-y-1">
|
|
243
|
+
<Label className="text-xs">Trigger configuration</Label>
|
|
244
|
+
<DynamicForm
|
|
245
|
+
schema={selected.configSchema}
|
|
246
|
+
value={value.config ?? {}}
|
|
247
|
+
onChange={(next) =>
|
|
248
|
+
onChange({ ...value, config: next })
|
|
249
|
+
}
|
|
250
|
+
/>
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
<Button
|
|
255
|
+
type="button"
|
|
256
|
+
variant="ghost"
|
|
257
|
+
size="icon"
|
|
258
|
+
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
|
259
|
+
onClick={onRemove}
|
|
260
|
+
disabled={disabled}
|
|
261
|
+
aria-label="Remove trigger"
|
|
262
|
+
>
|
|
263
|
+
<Trash2 className="h-3 w-3" />
|
|
264
|
+
</Button>
|
|
265
|
+
</div>
|
|
266
|
+
</CardContent>
|
|
267
|
+
</Card>
|
|
268
|
+
);
|
|
269
|
+
};
|