@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +664 -0
  2. package/package.json +38 -0
  3. package/src/components/AutomationMenuItems.tsx +37 -0
  4. package/src/editor/ActionEditor.tsx +367 -0
  5. package/src/editor/ActionListEditor.tsx +203 -0
  6. package/src/editor/AddActionDialog.tsx +225 -0
  7. package/src/editor/AutomationDefinitionContext.tsx +37 -0
  8. package/src/editor/AutomationDefinitionEditor.tsx +99 -0
  9. package/src/editor/ConditionEditor.tsx +218 -0
  10. package/src/editor/ConditionsEditor.tsx +89 -0
  11. package/src/editor/ItemPicker.tsx +147 -0
  12. package/src/editor/TriggersEditor.tsx +269 -0
  13. package/src/editor/action-composite-cards.tsx +390 -0
  14. package/src/editor/action-helpers.ts +365 -0
  15. package/src/editor/action-leaf-cards.tsx +426 -0
  16. package/src/editor/editor-validation.test.ts +95 -0
  17. package/src/editor/editor-validation.tsx +200 -0
  18. package/src/editor/registry-context.tsx +192 -0
  19. package/src/editor/template-completion.test.ts +412 -0
  20. package/src/editor/template-completion.ts +664 -0
  21. package/src/editor/template-helpers.test.ts +145 -0
  22. package/src/editor/template-helpers.ts +95 -0
  23. package/src/editor/trigger-helpers.test.ts +58 -0
  24. package/src/editor/trigger-helpers.ts +67 -0
  25. package/src/editor/useConnectionOptionResolvers.ts +80 -0
  26. package/src/editor/yaml-markers.ts +116 -0
  27. package/src/index.tsx +95 -0
  28. package/src/pages/AutomationEditPage.tsx +567 -0
  29. package/src/pages/AutomationListPage.tsx +304 -0
  30. package/src/pages/RunDetailPage.tsx +333 -0
  31. package/src/pages/RunsPage.tsx +233 -0
  32. package/src/pages/TemplatePlaygroundPage.tsx +224 -0
  33. package/src/script-context.test.ts +247 -0
  34. package/src/script-context.ts +218 -0
  35. 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);