@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,567 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
3
|
+
import {
|
|
4
|
+
ChevronLeft,
|
|
5
|
+
History as HistoryIcon,
|
|
6
|
+
Play,
|
|
7
|
+
Save,
|
|
8
|
+
Workflow,
|
|
9
|
+
} from "lucide-react";
|
|
10
|
+
import {
|
|
11
|
+
usePluginClient,
|
|
12
|
+
accessApiRef,
|
|
13
|
+
useApi,
|
|
14
|
+
wrapInSuspense,
|
|
15
|
+
} from "@checkstack/frontend-api";
|
|
16
|
+
import {
|
|
17
|
+
AutomationApi,
|
|
18
|
+
automationAccess,
|
|
19
|
+
automationRoutes,
|
|
20
|
+
type AutomationDefinition,
|
|
21
|
+
} from "@checkstack/automation-common";
|
|
22
|
+
import {
|
|
23
|
+
PageLayout,
|
|
24
|
+
Card,
|
|
25
|
+
CardContent,
|
|
26
|
+
CardHeader,
|
|
27
|
+
CardTitle,
|
|
28
|
+
Button,
|
|
29
|
+
Input,
|
|
30
|
+
Label,
|
|
31
|
+
Select,
|
|
32
|
+
SelectContent,
|
|
33
|
+
SelectItem,
|
|
34
|
+
SelectTrigger,
|
|
35
|
+
SelectValue,
|
|
36
|
+
CodeEditor,
|
|
37
|
+
LoadingSpinner,
|
|
38
|
+
QueryErrorState,
|
|
39
|
+
Alert,
|
|
40
|
+
AlertTitle,
|
|
41
|
+
AlertDescription,
|
|
42
|
+
Toggle,
|
|
43
|
+
useToast,
|
|
44
|
+
useInitOnceForKey,
|
|
45
|
+
Tabs,
|
|
46
|
+
TabPanel,
|
|
47
|
+
} from "@checkstack/ui";
|
|
48
|
+
import { extractErrorMessage, resolveRoute } from "@checkstack/common";
|
|
49
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
50
|
+
import { AutomationDefinitionEditor } from "../editor/AutomationDefinitionEditor";
|
|
51
|
+
import { assignDefaultIds } from "../editor/action-helpers";
|
|
52
|
+
import { assignDefaultTriggerIds } from "../editor/trigger-helpers";
|
|
53
|
+
import { computeYamlMarkers } from "../editor/yaml-markers";
|
|
54
|
+
import {
|
|
55
|
+
ValidationProvider,
|
|
56
|
+
partitionIssues,
|
|
57
|
+
} from "../editor/editor-validation";
|
|
58
|
+
|
|
59
|
+
const STARTER_DEFINITION: AutomationDefinition = {
|
|
60
|
+
name: "New Automation",
|
|
61
|
+
// Seed the starter trigger's id the same way actions are seeded, so it is
|
|
62
|
+
// shown (and referenceable as `trigger.id`) immediately.
|
|
63
|
+
triggers: assignDefaultTriggerIds([{ event: "incident.created" }]),
|
|
64
|
+
conditions: [],
|
|
65
|
+
// Run the seeded starter action through the same default-id assignment the
|
|
66
|
+
// "Add step" path uses, so its `id` is filled in (and shown) immediately
|
|
67
|
+
// rather than appearing blank until the field is focused.
|
|
68
|
+
actions: assignDefaultIds(
|
|
69
|
+
[
|
|
70
|
+
{
|
|
71
|
+
action: "automation.log",
|
|
72
|
+
config: {
|
|
73
|
+
message: "Incident {{ trigger.payload.title }} fired",
|
|
74
|
+
level: "info",
|
|
75
|
+
},
|
|
76
|
+
enabled: true,
|
|
77
|
+
continue_on_error: false,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
new Set(),
|
|
81
|
+
),
|
|
82
|
+
mode: "single",
|
|
83
|
+
max_runs: 10,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type EditTab = "visual" | "yaml";
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Full editor for an automation. Visual ↔ YAML tab switcher; both tabs
|
|
90
|
+
* read from and write to the same canonical `definition` state object.
|
|
91
|
+
*
|
|
92
|
+
* - **Visual** tab renders `<AutomationDefinitionEditor>` from
|
|
93
|
+
* `src/editor/` — triggers + pre-run conditions + drag-to-reorder
|
|
94
|
+
* actions, each action card backed by the Phase 11 `ActionCard`
|
|
95
|
+
* primitive and the matching per-kind body (Provider, Choose,
|
|
96
|
+
* Parallel, Repeat, Variables, ConditionGuard, Stop,
|
|
97
|
+
* WaitForTrigger, Sequence, Delay). Inline templates use the
|
|
98
|
+
* Phase 11 `TemplateValueInput` + `VariablePicker`, fed by the
|
|
99
|
+
* `VariableScopeResolver`-driven `useVariableScope` hook so each
|
|
100
|
+
* field sees only what's actually in scope at its action position.
|
|
101
|
+
*
|
|
102
|
+
* - **YAML** tab renders the same definition as a Monaco yaml
|
|
103
|
+
* editor. Round-trips losslessly via `yaml.parse` / `yaml.stringify`.
|
|
104
|
+
*
|
|
105
|
+
* The save flow is identical regardless of which tab is active: parse
|
|
106
|
+
* → `validateDefinition` RPC → `createAutomation` or `updateAutomation`.
|
|
107
|
+
* Switching tabs first commits the active tab's state into
|
|
108
|
+
* `definition` (parsing YAML on YAML→Visual transitions), so neither
|
|
109
|
+
* side ever wins by accident.
|
|
110
|
+
*/
|
|
111
|
+
const AutomationEditContent: React.FC = () => {
|
|
112
|
+
const { automationId } = useParams<{ automationId: string }>();
|
|
113
|
+
const isNew = !automationId || automationId === "new";
|
|
114
|
+
const client = usePluginClient(AutomationApi);
|
|
115
|
+
const accessApi = useApi(accessApiRef);
|
|
116
|
+
const toast = useToast();
|
|
117
|
+
const navigate = useNavigate();
|
|
118
|
+
|
|
119
|
+
const { allowed: canRead, loading: accessLoading } = accessApi.useAccess(
|
|
120
|
+
automationAccess.read,
|
|
121
|
+
);
|
|
122
|
+
const { allowed: canManage } = accessApi.useAccess(automationAccess.manage);
|
|
123
|
+
|
|
124
|
+
const loadQuery = client.getAutomation.useQuery(
|
|
125
|
+
{ id: automationId ?? "" },
|
|
126
|
+
{ enabled: !isNew, gcTime: 0 },
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Top-level form state.
|
|
130
|
+
const [name, setName] = React.useState("");
|
|
131
|
+
const [description, setDescription] = React.useState("");
|
|
132
|
+
const [statusEnabled, setStatusEnabled] = React.useState(true);
|
|
133
|
+
const [definition, setDefinition] =
|
|
134
|
+
React.useState<AutomationDefinition>(STARTER_DEFINITION);
|
|
135
|
+
const [yamlText, setYamlText] = React.useState<string>(() =>
|
|
136
|
+
stringifyYaml(STARTER_DEFINITION),
|
|
137
|
+
);
|
|
138
|
+
const [tab, setTab] = React.useState<EditTab>("visual");
|
|
139
|
+
const [validationErrors, setValidationErrors] = React.useState<
|
|
140
|
+
Array<{ path: Array<string | number>; message: string }>
|
|
141
|
+
>([]);
|
|
142
|
+
|
|
143
|
+
// Seed local form state from the loaded automation, once per record.
|
|
144
|
+
// `useInitOnceForKey` seeds during render (not in an effect), so it survives
|
|
145
|
+
// StrictMode's double-mount even when the query resolves from a warm cache on
|
|
146
|
+
// reopen, and ignores background refetches of the same record so in-progress
|
|
147
|
+
// edits are not clobbered. `isFetchedAfterMount` keeps it to genuinely fresh
|
|
148
|
+
// data rather than a stale cache entry served instantly on mount.
|
|
149
|
+
useInitOnceForKey(
|
|
150
|
+
loadQuery.isFetchedAfterMount ? loadQuery.data : undefined,
|
|
151
|
+
loadQuery.data?.id,
|
|
152
|
+
(a) => {
|
|
153
|
+
setName(a.name);
|
|
154
|
+
setDescription(a.description ?? "");
|
|
155
|
+
setStatusEnabled(a.status === "enabled");
|
|
156
|
+
setDefinition(a.definition);
|
|
157
|
+
setYamlText(stringifyYaml(a.definition));
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Keep the YAML mirror in sync with definition while the visual editor
|
|
162
|
+
// is active — the YAML tab needs a non-stale starting point when the
|
|
163
|
+
// operator switches over.
|
|
164
|
+
React.useEffect(() => {
|
|
165
|
+
if (tab === "visual") {
|
|
166
|
+
setYamlText(stringifyYaml(definition));
|
|
167
|
+
}
|
|
168
|
+
}, [definition, tab]);
|
|
169
|
+
|
|
170
|
+
const switchTab = (next: EditTab) => {
|
|
171
|
+
if (next === tab) return;
|
|
172
|
+
if (tab === "yaml") {
|
|
173
|
+
// Commit YAML into definition before switching to Visual.
|
|
174
|
+
try {
|
|
175
|
+
const parsed = parseYaml(yamlText) as AutomationDefinition;
|
|
176
|
+
if (parsed && typeof parsed === "object") {
|
|
177
|
+
setDefinition(parsed);
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// Don't switch tabs while YAML is unparseable — the operator
|
|
181
|
+
// would silently lose their edits. The Monaco markers already
|
|
182
|
+
// squiggle the syntax error in place.
|
|
183
|
+
toast.error(`Cannot switch — YAML is invalid: ${extractErrorMessage(error)}`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
setTab(next);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const validateMutation = client.validateDefinition.useMutation({
|
|
191
|
+
onSuccess: (result) => {
|
|
192
|
+
setValidationErrors(result.valid ? [] : result.errors);
|
|
193
|
+
},
|
|
194
|
+
onError: (error) => toast.error(extractErrorMessage(error)),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Live validation — separate mutation instance from the save-path one
|
|
198
|
+
// so its constant background runs don't flicker the Save button's
|
|
199
|
+
// pending state. `mutateAsync` is stable across renders.
|
|
200
|
+
const { mutateAsync: runLiveValidation } =
|
|
201
|
+
client.validateDefinition.useMutation();
|
|
202
|
+
|
|
203
|
+
// Re-validate (debounced) on every edit in either tab, so invalid
|
|
204
|
+
// values / keys / ids surface as the operator types — not just on
|
|
205
|
+
// save or tab-switch. A generation counter discards stale async
|
|
206
|
+
// results that resolve after a newer edit.
|
|
207
|
+
const liveValidateGenerationRef = React.useRef(0);
|
|
208
|
+
React.useEffect(() => {
|
|
209
|
+
const generation = ++liveValidateGenerationRef.current;
|
|
210
|
+
const handle = setTimeout(() => {
|
|
211
|
+
let candidate: AutomationDefinition;
|
|
212
|
+
if (tab === "yaml") {
|
|
213
|
+
try {
|
|
214
|
+
candidate = parseYaml(yamlText) as AutomationDefinition;
|
|
215
|
+
} catch {
|
|
216
|
+
// Unparseable YAML — the syntax-error markers come from
|
|
217
|
+
// `computeYamlMarkers` parsing the same text, so just clear
|
|
218
|
+
// the (now-unmappable) semantic issues.
|
|
219
|
+
if (generation === liveValidateGenerationRef.current) {
|
|
220
|
+
setValidationErrors([]);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
candidate = definition;
|
|
226
|
+
}
|
|
227
|
+
void runLiveValidation({ definition: candidate })
|
|
228
|
+
.then((result) => {
|
|
229
|
+
if (generation !== liveValidateGenerationRef.current) return;
|
|
230
|
+
setValidationErrors(result.valid ? [] : result.errors);
|
|
231
|
+
})
|
|
232
|
+
.catch(() => {
|
|
233
|
+
// Transient RPC/permission error — the save path surfaces a
|
|
234
|
+
// toast if it matters; live validation stays quiet.
|
|
235
|
+
});
|
|
236
|
+
}, 400);
|
|
237
|
+
return () => clearTimeout(handle);
|
|
238
|
+
}, [tab, yamlText, definition, runLiveValidation]);
|
|
239
|
+
|
|
240
|
+
const createMutation = client.createAutomation.useMutation({
|
|
241
|
+
onSuccess: (data) => {
|
|
242
|
+
toast.success(`Created ${data.name}`);
|
|
243
|
+
navigate(
|
|
244
|
+
resolveRoute(automationRoutes.routes.edit, { automationId: data.id }),
|
|
245
|
+
);
|
|
246
|
+
},
|
|
247
|
+
onError: (error) => toast.error(extractErrorMessage(error)),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const updateMutation = client.updateAutomation.useMutation({
|
|
251
|
+
onSuccess: (data) => {
|
|
252
|
+
toast.success(`Saved ${data.name}`);
|
|
253
|
+
},
|
|
254
|
+
onError: (error) => toast.error(extractErrorMessage(error)),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const manualRunMutation = client.manualRun.useMutation({
|
|
258
|
+
onSuccess: (data) => {
|
|
259
|
+
toast.success(`Manual run queued`);
|
|
260
|
+
if (automationId) {
|
|
261
|
+
navigate(
|
|
262
|
+
resolveRoute(automationRoutes.routes.runDetail, {
|
|
263
|
+
automationId,
|
|
264
|
+
runId: data.runId,
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
onError: (error) => toast.error(extractErrorMessage(error)),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Resolve the canonical definition from whichever tab is active. The
|
|
274
|
+
* Visual tab keeps `definition` live as the operator edits, so it's
|
|
275
|
+
* the trivial case. The YAML tab keeps the parsed object in
|
|
276
|
+
* `definition` only after a successful tab switch; on Save we
|
|
277
|
+
* re-parse the YAML directly so a Save click without first switching
|
|
278
|
+
* tabs still commits the latest YAML edits.
|
|
279
|
+
*/
|
|
280
|
+
const commitActiveTab = (): AutomationDefinition | null => {
|
|
281
|
+
if (tab === "visual") return definition;
|
|
282
|
+
try {
|
|
283
|
+
return parseYaml(yamlText) as AutomationDefinition;
|
|
284
|
+
} catch (error) {
|
|
285
|
+
toast.error(`Fix the YAML syntax error before saving: ${extractErrorMessage(error)}`);
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const handleSave = async () => {
|
|
291
|
+
const committed = commitActiveTab();
|
|
292
|
+
if (!committed) return;
|
|
293
|
+
|
|
294
|
+
// The top-level form `name`/`description` are the source of truth and
|
|
295
|
+
// overwrite whatever the definition carried (e.g. the starter's "New
|
|
296
|
+
// Automation"). Merge BEFORE validating so we validate exactly what we
|
|
297
|
+
// submit — otherwise an empty name passes definition validation (which
|
|
298
|
+
// sees the starter name) but is rejected at the create RPC's input
|
|
299
|
+
// boundary with a generic toast.
|
|
300
|
+
const merged: AutomationDefinition = {
|
|
301
|
+
...committed,
|
|
302
|
+
name,
|
|
303
|
+
description: description || undefined,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const validation = await validateMutation.mutateAsync({
|
|
307
|
+
definition: merged,
|
|
308
|
+
});
|
|
309
|
+
if (!validation.valid) return;
|
|
310
|
+
|
|
311
|
+
if (isNew) {
|
|
312
|
+
createMutation.mutate({
|
|
313
|
+
name,
|
|
314
|
+
description: description || undefined,
|
|
315
|
+
status: statusEnabled ? "enabled" : "disabled",
|
|
316
|
+
definition: merged,
|
|
317
|
+
});
|
|
318
|
+
} else if (automationId) {
|
|
319
|
+
updateMutation.mutate({
|
|
320
|
+
id: automationId,
|
|
321
|
+
name,
|
|
322
|
+
description: description || undefined,
|
|
323
|
+
status: statusEnabled ? "enabled" : "disabled",
|
|
324
|
+
definition: merged,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const handleManualRun = () => {
|
|
330
|
+
if (!automationId) return;
|
|
331
|
+
const committed = commitActiveTab();
|
|
332
|
+
if (!committed) return;
|
|
333
|
+
const firstTrigger = committed.triggers[0];
|
|
334
|
+
if (!firstTrigger) {
|
|
335
|
+
toast.error("Automation has no triggers — add one before running.");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
manualRunMutation.mutate({
|
|
339
|
+
automationId,
|
|
340
|
+
triggerId: firstTrigger.id,
|
|
341
|
+
payload: {},
|
|
342
|
+
});
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const tabItems = [
|
|
346
|
+
{ id: "visual", label: "Visual" },
|
|
347
|
+
{ id: "yaml", label: "YAML" },
|
|
348
|
+
];
|
|
349
|
+
|
|
350
|
+
// YAML tab: squiggle the offending nodes (syntax errors + mapped
|
|
351
|
+
// validation issues) inline instead of listing them in a panel.
|
|
352
|
+
const yamlMarkers = React.useMemo(
|
|
353
|
+
() => computeYamlMarkers(yamlText, validationErrors),
|
|
354
|
+
[yamlText, validationErrors],
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// Visual tab: most issues attach to a specific card; anything that
|
|
358
|
+
// can't (top-level fields) is shown as a slim fallback note.
|
|
359
|
+
const unattributedIssues = React.useMemo(
|
|
360
|
+
() => partitionIssues(validationErrors).other,
|
|
361
|
+
[validationErrors],
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
// The top-level `name` lives outside `definition`, so the definition
|
|
365
|
+
// validator never checks it — an empty name slipped through to the create
|
|
366
|
+
// RPC, which rejected it at its input boundary with a generic
|
|
367
|
+
// "Input validation failed" toast. Validate it here so the Name field can
|
|
368
|
+
// surface the error and Save can be disabled instead.
|
|
369
|
+
const nameError = name.trim().length === 0 ? "Name is required" : undefined;
|
|
370
|
+
const isSaving = createMutation.isPending || updateMutation.isPending;
|
|
371
|
+
const canSave = !nameError && validationErrors.length === 0 && !isSaving;
|
|
372
|
+
|
|
373
|
+
return (
|
|
374
|
+
<PageLayout
|
|
375
|
+
title={isNew ? "New automation" : name || "Edit automation"}
|
|
376
|
+
subtitle={isNew ? "Wire a trigger to one or more actions" : undefined}
|
|
377
|
+
icon={Workflow}
|
|
378
|
+
loading={accessLoading || (!isNew && loadQuery.isLoading)}
|
|
379
|
+
allowed={canRead && (isNew ? canManage : true)}
|
|
380
|
+
actions={
|
|
381
|
+
<div className="flex items-center gap-2">
|
|
382
|
+
<Link to={resolveRoute(automationRoutes.routes.list)}>
|
|
383
|
+
<Button variant="outline" size="sm">
|
|
384
|
+
<ChevronLeft className="mr-1 h-4 w-4" />
|
|
385
|
+
All automations
|
|
386
|
+
</Button>
|
|
387
|
+
</Link>
|
|
388
|
+
{!isNew && automationId && (
|
|
389
|
+
<>
|
|
390
|
+
<Link
|
|
391
|
+
to={resolveRoute(automationRoutes.routes.runs, {
|
|
392
|
+
automationId,
|
|
393
|
+
})}
|
|
394
|
+
>
|
|
395
|
+
<Button variant="outline" size="sm">
|
|
396
|
+
<HistoryIcon className="mr-1 h-4 w-4" />
|
|
397
|
+
Runs
|
|
398
|
+
</Button>
|
|
399
|
+
</Link>
|
|
400
|
+
{canManage && (
|
|
401
|
+
<Button
|
|
402
|
+
variant="outline"
|
|
403
|
+
size="sm"
|
|
404
|
+
onClick={handleManualRun}
|
|
405
|
+
disabled={manualRunMutation.isPending}
|
|
406
|
+
>
|
|
407
|
+
<Play className="mr-1 h-4 w-4" />
|
|
408
|
+
Run now
|
|
409
|
+
</Button>
|
|
410
|
+
)}
|
|
411
|
+
</>
|
|
412
|
+
)}
|
|
413
|
+
{canManage && (
|
|
414
|
+
<Button
|
|
415
|
+
size="sm"
|
|
416
|
+
onClick={handleSave}
|
|
417
|
+
disabled={!canSave || validateMutation.isPending}
|
|
418
|
+
>
|
|
419
|
+
<Save className="mr-1 h-4 w-4" />
|
|
420
|
+
Save
|
|
421
|
+
</Button>
|
|
422
|
+
)}
|
|
423
|
+
</div>
|
|
424
|
+
}
|
|
425
|
+
>
|
|
426
|
+
{!isNew && loadQuery.isError ? (
|
|
427
|
+
<QueryErrorState
|
|
428
|
+
error={loadQuery.error}
|
|
429
|
+
onRetry={() => loadQuery.refetch()}
|
|
430
|
+
/>
|
|
431
|
+
) : !isNew && loadQuery.isLoading ? (
|
|
432
|
+
<LoadingSpinner />
|
|
433
|
+
) : (
|
|
434
|
+
<div className="grid gap-4 lg:grid-cols-[1fr_2fr]">
|
|
435
|
+
<Card>
|
|
436
|
+
<CardHeader className="border-b">
|
|
437
|
+
<CardTitle className="text-base">Metadata</CardTitle>
|
|
438
|
+
</CardHeader>
|
|
439
|
+
<CardContent className="space-y-3 p-4">
|
|
440
|
+
<div className="space-y-1">
|
|
441
|
+
<Label htmlFor="name">Name</Label>
|
|
442
|
+
<Input
|
|
443
|
+
id="name"
|
|
444
|
+
value={name}
|
|
445
|
+
onChange={(e) => setName(e.target.value)}
|
|
446
|
+
disabled={!canManage}
|
|
447
|
+
placeholder="Open Jira issue when incident fires"
|
|
448
|
+
aria-invalid={nameError ? true : undefined}
|
|
449
|
+
className={nameError ? "border-destructive" : undefined}
|
|
450
|
+
/>
|
|
451
|
+
{nameError && (
|
|
452
|
+
<p className="text-xs text-destructive">{nameError}</p>
|
|
453
|
+
)}
|
|
454
|
+
</div>
|
|
455
|
+
<div className="space-y-1">
|
|
456
|
+
<Label htmlFor="description">Description</Label>
|
|
457
|
+
<Input
|
|
458
|
+
id="description"
|
|
459
|
+
value={description}
|
|
460
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
461
|
+
disabled={!canManage}
|
|
462
|
+
placeholder="Optional"
|
|
463
|
+
/>
|
|
464
|
+
</div>
|
|
465
|
+
<div className="flex items-center justify-between">
|
|
466
|
+
<Label htmlFor="enabled">Enabled</Label>
|
|
467
|
+
<Toggle
|
|
468
|
+
checked={statusEnabled}
|
|
469
|
+
onCheckedChange={setStatusEnabled}
|
|
470
|
+
disabled={!canManage}
|
|
471
|
+
aria-label="Enable automation"
|
|
472
|
+
/>
|
|
473
|
+
</div>
|
|
474
|
+
<div className="space-y-1">
|
|
475
|
+
<Label htmlFor="mode">Concurrency mode</Label>
|
|
476
|
+
<Select
|
|
477
|
+
value={definition.mode}
|
|
478
|
+
onValueChange={(value) =>
|
|
479
|
+
setDefinition({
|
|
480
|
+
...definition,
|
|
481
|
+
mode: value as AutomationDefinition["mode"],
|
|
482
|
+
})
|
|
483
|
+
}
|
|
484
|
+
disabled={!canManage}
|
|
485
|
+
>
|
|
486
|
+
<SelectTrigger id="mode">
|
|
487
|
+
<SelectValue />
|
|
488
|
+
</SelectTrigger>
|
|
489
|
+
<SelectContent>
|
|
490
|
+
<SelectItem value="single">single</SelectItem>
|
|
491
|
+
<SelectItem value="parallel">parallel</SelectItem>
|
|
492
|
+
<SelectItem value="queued">queued</SelectItem>
|
|
493
|
+
<SelectItem value="restart">restart</SelectItem>
|
|
494
|
+
</SelectContent>
|
|
495
|
+
</Select>
|
|
496
|
+
</div>
|
|
497
|
+
<div className="space-y-1">
|
|
498
|
+
<Label htmlFor="max_runs">Max concurrent runs</Label>
|
|
499
|
+
<Input
|
|
500
|
+
id="max_runs"
|
|
501
|
+
type="number"
|
|
502
|
+
min={1}
|
|
503
|
+
max={1000}
|
|
504
|
+
value={definition.max_runs}
|
|
505
|
+
onChange={(e) =>
|
|
506
|
+
setDefinition({
|
|
507
|
+
...definition,
|
|
508
|
+
max_runs: Math.max(1, Number(e.target.value)),
|
|
509
|
+
})
|
|
510
|
+
}
|
|
511
|
+
disabled={!canManage}
|
|
512
|
+
/>
|
|
513
|
+
</div>
|
|
514
|
+
</CardContent>
|
|
515
|
+
</Card>
|
|
516
|
+
|
|
517
|
+
<div>
|
|
518
|
+
<div className="mb-2">
|
|
519
|
+
<Tabs
|
|
520
|
+
items={tabItems}
|
|
521
|
+
activeTab={tab}
|
|
522
|
+
onTabChange={(id) => switchTab(id as EditTab)}
|
|
523
|
+
/>
|
|
524
|
+
</div>
|
|
525
|
+
<TabPanel id="visual" activeTab={tab}>
|
|
526
|
+
<ValidationProvider issues={validationErrors}>
|
|
527
|
+
{unattributedIssues.length > 0 && (
|
|
528
|
+
<Alert variant="error" className="mb-2">
|
|
529
|
+
<AlertTitle>Definition issues</AlertTitle>
|
|
530
|
+
<AlertDescription>
|
|
531
|
+
<ul className="space-y-1 text-xs font-mono">
|
|
532
|
+
{unattributedIssues.map((issue, index) => (
|
|
533
|
+
<li key={index}>{issue}</li>
|
|
534
|
+
))}
|
|
535
|
+
</ul>
|
|
536
|
+
</AlertDescription>
|
|
537
|
+
</Alert>
|
|
538
|
+
)}
|
|
539
|
+
<AutomationDefinitionEditor
|
|
540
|
+
value={definition}
|
|
541
|
+
onChange={setDefinition}
|
|
542
|
+
disabled={!canManage}
|
|
543
|
+
/>
|
|
544
|
+
</ValidationProvider>
|
|
545
|
+
</TabPanel>
|
|
546
|
+
<TabPanel id="yaml" activeTab={tab}>
|
|
547
|
+
<Card>
|
|
548
|
+
<CardContent className="p-0">
|
|
549
|
+
<CodeEditor
|
|
550
|
+
value={yamlText}
|
|
551
|
+
onChange={setYamlText}
|
|
552
|
+
language="yaml"
|
|
553
|
+
minHeight="520px"
|
|
554
|
+
readOnly={!canManage}
|
|
555
|
+
markers={yamlMarkers}
|
|
556
|
+
/>
|
|
557
|
+
</CardContent>
|
|
558
|
+
</Card>
|
|
559
|
+
</TabPanel>
|
|
560
|
+
</div>
|
|
561
|
+
</div>
|
|
562
|
+
)}
|
|
563
|
+
</PageLayout>
|
|
564
|
+
);
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
export const AutomationEditPage = wrapInSuspense(AutomationEditContent);
|