@checkstack/automation-frontend 0.7.1 → 0.8.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 +160 -0
- package/package.json +16 -16
- package/src/components/RunAsServiceAccountPicker.tsx +12 -12
- package/src/components/SaveBlockersSummary.tsx +75 -0
- package/src/editor/RunReplayPicker.tsx +1 -1
- package/src/editor/blocking-issues.test.ts +77 -0
- package/src/editor/blocking-issues.ts +68 -0
- package/src/editor/condition-kind.ts +1 -1
- package/src/pages/AutomationEditPage.tsx +132 -32
- package/src/pages/AutomationListPage.tsx +241 -38
- package/src/pages/AutomationTemplatePickerPage.tsx +27 -31
- package/src/pages/RunDetailPage.tsx +89 -32
- package/src/pages/RunsPage.tsx +96 -76
- package/src/pages/run-duration.test.ts +36 -0
- package/src/pages/run-duration.ts +18 -0
- package/src/pages/run-status-pill.tsx +78 -0
|
@@ -42,10 +42,13 @@ import {
|
|
|
42
42
|
Toggle,
|
|
43
43
|
useToast,
|
|
44
44
|
useInitOnceForKey,
|
|
45
|
+
useUnsavedChanges,
|
|
46
|
+
ConfirmationModal,
|
|
45
47
|
Tabs,
|
|
46
48
|
TabPanel,
|
|
49
|
+
toastError,
|
|
47
50
|
} from "@checkstack/ui";
|
|
48
|
-
import {
|
|
51
|
+
import { resolveRoute } from "@checkstack/common";
|
|
49
52
|
import {
|
|
50
53
|
GitOpsLockBanner,
|
|
51
54
|
useProvenanceLock,
|
|
@@ -59,6 +62,11 @@ import { computeYamlMarkers } from "../editor/yaml-markers";
|
|
|
59
62
|
import { partitionIssues } from "../editor/editor-validation";
|
|
60
63
|
import { AutomationGroupCombobox } from "../components/AutomationGroupCombobox";
|
|
61
64
|
import { RunAsServiceAccountPicker } from "../components/RunAsServiceAccountPicker";
|
|
65
|
+
import { SaveBlockersSummary } from "../components/SaveBlockersSummary";
|
|
66
|
+
import {
|
|
67
|
+
summarizeBlockingIssues,
|
|
68
|
+
type BlockingIssue,
|
|
69
|
+
} from "../editor/blocking-issues";
|
|
62
70
|
|
|
63
71
|
const STARTER_DEFINITION: AutomationDefinition = {
|
|
64
72
|
name: "New Automation",
|
|
@@ -161,6 +169,12 @@ const AutomationEditContent: React.FC = () => {
|
|
|
161
169
|
Array<{ path: Array<string | number>; message: string }>
|
|
162
170
|
>([]);
|
|
163
171
|
|
|
172
|
+
// Unsaved-changes tracking. Flipped true by any user edit (see `markDirty`),
|
|
173
|
+
// reset when the form is (re)seeded from a record/template and after a save.
|
|
174
|
+
// Drives the shared `useUnsavedChanges` guard below.
|
|
175
|
+
const [isDirty, setIsDirty] = React.useState(false);
|
|
176
|
+
const markDirty = React.useCallback(() => setIsDirty(true), []);
|
|
177
|
+
|
|
164
178
|
// Seed local form state from the loaded automation, once per record.
|
|
165
179
|
// `useInitOnceForKey` seeds during render (not in an effect), so it survives
|
|
166
180
|
// StrictMode's double-mount even when the query resolves from a warm cache on
|
|
@@ -189,6 +203,8 @@ const AutomationEditContent: React.FC = () => {
|
|
|
189
203
|
setStatusEnabled(a.status === "enabled");
|
|
190
204
|
setDefinition(normalized);
|
|
191
205
|
setYamlText(stringifyYaml(normalized));
|
|
206
|
+
// Seeding from the stored record is not a user edit.
|
|
207
|
+
setIsDirty(false);
|
|
192
208
|
},
|
|
193
209
|
);
|
|
194
210
|
|
|
@@ -243,7 +259,7 @@ const AutomationEditContent: React.FC = () => {
|
|
|
243
259
|
// Don't switch tabs while YAML is unparseable — the operator
|
|
244
260
|
// would silently lose their edits. The Monaco markers already
|
|
245
261
|
// squiggle the syntax error in place.
|
|
246
|
-
toast
|
|
262
|
+
toastError(toast, "Cannot switch - YAML is invalid", error);
|
|
247
263
|
return;
|
|
248
264
|
}
|
|
249
265
|
}
|
|
@@ -254,7 +270,7 @@ const AutomationEditContent: React.FC = () => {
|
|
|
254
270
|
onSuccess: (result) => {
|
|
255
271
|
setValidationErrors(result.valid ? [] : result.errors);
|
|
256
272
|
},
|
|
257
|
-
onError: (error) => toast
|
|
273
|
+
onError: (error) => toastError(toast, "Failed to validate automation", error),
|
|
258
274
|
});
|
|
259
275
|
|
|
260
276
|
// Live validation — separate mutation instance from the save-path one
|
|
@@ -302,10 +318,16 @@ const AutomationEditContent: React.FC = () => {
|
|
|
302
318
|
|
|
303
319
|
const createMutation = client.createAutomation.useMutation({
|
|
304
320
|
onSuccess: (data) => {
|
|
321
|
+
// Saved: clear the dirty flag, then redirect on the next tick so the
|
|
322
|
+
// unsaved-changes guard has committed `isDirty = false` and doesn't
|
|
323
|
+
// block the post-create navigation.
|
|
324
|
+
setIsDirty(false);
|
|
305
325
|
toast.success(`Created ${data.name}`);
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
326
|
+
setTimeout(() => {
|
|
327
|
+
navigate(
|
|
328
|
+
resolveRoute(automationRoutes.routes.edit, { automationId: data.id }),
|
|
329
|
+
);
|
|
330
|
+
}, 0);
|
|
309
331
|
},
|
|
310
332
|
onError: (error) => {
|
|
311
333
|
const inline = teamCreateErrorMessage(error);
|
|
@@ -313,15 +335,16 @@ const AutomationEditContent: React.FC = () => {
|
|
|
313
335
|
setOwnerTeamError(inline);
|
|
314
336
|
return;
|
|
315
337
|
}
|
|
316
|
-
toast
|
|
338
|
+
toastError(toast, "Failed to create automation", error);
|
|
317
339
|
},
|
|
318
340
|
});
|
|
319
341
|
|
|
320
342
|
const updateMutation = client.updateAutomation.useMutation({
|
|
321
343
|
onSuccess: (data) => {
|
|
344
|
+
setIsDirty(false);
|
|
322
345
|
toast.success(`Saved ${data.name}`);
|
|
323
346
|
},
|
|
324
|
-
onError: (error) => toast
|
|
347
|
+
onError: (error) => toastError(toast, "Failed to save automation", error),
|
|
325
348
|
});
|
|
326
349
|
|
|
327
350
|
const manualRunMutation = client.manualRun.useMutation({
|
|
@@ -336,7 +359,7 @@ const AutomationEditContent: React.FC = () => {
|
|
|
336
359
|
);
|
|
337
360
|
}
|
|
338
361
|
},
|
|
339
|
-
onError: (error) => toast
|
|
362
|
+
onError: (error) => toastError(toast, "Failed to queue manual run", error),
|
|
340
363
|
});
|
|
341
364
|
|
|
342
365
|
/**
|
|
@@ -352,7 +375,7 @@ const AutomationEditContent: React.FC = () => {
|
|
|
352
375
|
try {
|
|
353
376
|
return parseYaml(yamlText) as AutomationDefinition;
|
|
354
377
|
} catch (error) {
|
|
355
|
-
toast
|
|
378
|
+
toastError(toast, "Fix the YAML syntax error before saving", error);
|
|
356
379
|
return null;
|
|
357
380
|
}
|
|
358
381
|
};
|
|
@@ -412,7 +435,7 @@ const AutomationEditContent: React.FC = () => {
|
|
|
412
435
|
if (!committed) return;
|
|
413
436
|
const firstTrigger = committed.triggers[0];
|
|
414
437
|
if (!firstTrigger) {
|
|
415
|
-
toast.error("Automation has no triggers
|
|
438
|
+
toast.error("Automation has no triggers - add one before running.");
|
|
416
439
|
return;
|
|
417
440
|
}
|
|
418
441
|
manualRunMutation.mutate({
|
|
@@ -457,6 +480,40 @@ const AutomationEditContent: React.FC = () => {
|
|
|
457
480
|
const canSave =
|
|
458
481
|
!nameError && !runAsError && validationErrors.length === 0 && !isSaving;
|
|
459
482
|
|
|
483
|
+
// Explain WHY Save is disabled: a flat, ordered list of blockers shown in a
|
|
484
|
+
// popover next to the button, each linking to the offending field/section.
|
|
485
|
+
const blockers = React.useMemo(
|
|
486
|
+
() =>
|
|
487
|
+
summarizeBlockingIssues({
|
|
488
|
+
nameError,
|
|
489
|
+
runAsError,
|
|
490
|
+
definitionIssues: validationErrors,
|
|
491
|
+
}),
|
|
492
|
+
[nameError, runAsError, validationErrors],
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
// Resolve a blocker: bring its surface into view and focus the field.
|
|
496
|
+
// Definition issues attach to the visual editor (their inline markers live
|
|
497
|
+
// there), so we ensure that tab is active.
|
|
498
|
+
const handleResolveBlocker = React.useCallback((blocker: BlockingIssue) => {
|
|
499
|
+
if (blocker.target.kind === "definition") {
|
|
500
|
+
setTab("visual");
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const elementId = blocker.target.field === "name" ? "name" : "runAs";
|
|
504
|
+
const element = document.querySelector(`#${elementId}`);
|
|
505
|
+
if (element instanceof HTMLElement) {
|
|
506
|
+
element.scrollIntoView({ block: "center" });
|
|
507
|
+
element.focus();
|
|
508
|
+
}
|
|
509
|
+
}, []);
|
|
510
|
+
|
|
511
|
+
// Unsaved-changes guard: native prompt on tab close / refresh and an in-app
|
|
512
|
+
// confirm on navigation while there are pending edits.
|
|
513
|
+
const { isBlocked, confirmDiscard, cancelDiscard } = useUnsavedChanges({
|
|
514
|
+
isDirty,
|
|
515
|
+
});
|
|
516
|
+
|
|
460
517
|
return (
|
|
461
518
|
<PageLayout
|
|
462
519
|
title={isNew ? "New automation" : name || "Edit automation"}
|
|
@@ -498,14 +555,20 @@ const AutomationEditContent: React.FC = () => {
|
|
|
498
555
|
</>
|
|
499
556
|
)}
|
|
500
557
|
{canManage && (
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
<
|
|
507
|
-
|
|
508
|
-
|
|
558
|
+
<>
|
|
559
|
+
<SaveBlockersSummary
|
|
560
|
+
blockers={blockers}
|
|
561
|
+
onResolve={handleResolveBlocker}
|
|
562
|
+
/>
|
|
563
|
+
<Button
|
|
564
|
+
size="sm"
|
|
565
|
+
onClick={handleSave}
|
|
566
|
+
disabled={!canSave || validateMutation.isPending}
|
|
567
|
+
>
|
|
568
|
+
<Save className="mr-1 h-4 w-4" />
|
|
569
|
+
Save
|
|
570
|
+
</Button>
|
|
571
|
+
</>
|
|
509
572
|
)}
|
|
510
573
|
</div>
|
|
511
574
|
}
|
|
@@ -532,8 +595,14 @@ const AutomationEditContent: React.FC = () => {
|
|
|
532
595
|
<Label htmlFor="name">Name</Label>
|
|
533
596
|
<Input
|
|
534
597
|
id="name"
|
|
598
|
+
// Land focus in the first field when opening a fresh editor so
|
|
599
|
+
// keyboard-first users can type immediately.
|
|
600
|
+
autoFocus={isNew}
|
|
535
601
|
value={name}
|
|
536
|
-
onChange={(e) =>
|
|
602
|
+
onChange={(e) => {
|
|
603
|
+
setName(e.target.value);
|
|
604
|
+
markDirty();
|
|
605
|
+
}}
|
|
537
606
|
disabled={!canManage}
|
|
538
607
|
placeholder="Open Jira issue when incident fires"
|
|
539
608
|
aria-invalid={nameError ? true : undefined}
|
|
@@ -548,7 +617,10 @@ const AutomationEditContent: React.FC = () => {
|
|
|
548
617
|
<Input
|
|
549
618
|
id="description"
|
|
550
619
|
value={description}
|
|
551
|
-
onChange={(e) =>
|
|
620
|
+
onChange={(e) => {
|
|
621
|
+
setDescription(e.target.value);
|
|
622
|
+
markDirty();
|
|
623
|
+
}}
|
|
552
624
|
disabled={!canManage}
|
|
553
625
|
placeholder="Optional"
|
|
554
626
|
/>
|
|
@@ -558,7 +630,10 @@ const AutomationEditContent: React.FC = () => {
|
|
|
558
630
|
<AutomationGroupCombobox
|
|
559
631
|
id="group"
|
|
560
632
|
value={group}
|
|
561
|
-
onValueChange={
|
|
633
|
+
onValueChange={(next) => {
|
|
634
|
+
setGroup(next);
|
|
635
|
+
markDirty();
|
|
636
|
+
}}
|
|
562
637
|
suggestions={groupSuggestions}
|
|
563
638
|
disabled={!canManage}
|
|
564
639
|
/>
|
|
@@ -568,7 +643,10 @@ const AutomationEditContent: React.FC = () => {
|
|
|
568
643
|
</div>
|
|
569
644
|
<RunAsServiceAccountPicker
|
|
570
645
|
value={runAsApplicationId}
|
|
571
|
-
onValueChange={
|
|
646
|
+
onValueChange={(next) => {
|
|
647
|
+
setRunAsApplicationId(next);
|
|
648
|
+
markDirty();
|
|
649
|
+
}}
|
|
572
650
|
disabled={!canManage}
|
|
573
651
|
showError={!!runAsError}
|
|
574
652
|
/>
|
|
@@ -579,6 +657,7 @@ const AutomationEditContent: React.FC = () => {
|
|
|
579
657
|
onChange={(id) => {
|
|
580
658
|
setOwnerTeamId(id);
|
|
581
659
|
setOwnerTeamError(null);
|
|
660
|
+
markDirty();
|
|
582
661
|
}}
|
|
583
662
|
allowGlobal={allowGlobal}
|
|
584
663
|
disabled={!canManage}
|
|
@@ -589,7 +668,10 @@ const AutomationEditContent: React.FC = () => {
|
|
|
589
668
|
<Label htmlFor="enabled">Enabled</Label>
|
|
590
669
|
<Toggle
|
|
591
670
|
checked={statusEnabled}
|
|
592
|
-
onCheckedChange={
|
|
671
|
+
onCheckedChange={(next) => {
|
|
672
|
+
setStatusEnabled(next);
|
|
673
|
+
markDirty();
|
|
674
|
+
}}
|
|
593
675
|
disabled={!canManage}
|
|
594
676
|
aria-label="Enable automation"
|
|
595
677
|
/>
|
|
@@ -598,12 +680,13 @@ const AutomationEditContent: React.FC = () => {
|
|
|
598
680
|
<Label htmlFor="mode">Concurrency mode</Label>
|
|
599
681
|
<Select
|
|
600
682
|
value={definition.mode}
|
|
601
|
-
onValueChange={(value) =>
|
|
683
|
+
onValueChange={(value) => {
|
|
602
684
|
setDefinition({
|
|
603
685
|
...definition,
|
|
604
686
|
mode: value as AutomationDefinition["mode"],
|
|
605
|
-
})
|
|
606
|
-
|
|
687
|
+
});
|
|
688
|
+
markDirty();
|
|
689
|
+
}}
|
|
607
690
|
disabled={!canManage}
|
|
608
691
|
>
|
|
609
692
|
<SelectTrigger id="mode">
|
|
@@ -625,12 +708,13 @@ const AutomationEditContent: React.FC = () => {
|
|
|
625
708
|
min={1}
|
|
626
709
|
max={1000}
|
|
627
710
|
value={definition.max_runs}
|
|
628
|
-
onChange={(e) =>
|
|
711
|
+
onChange={(e) => {
|
|
629
712
|
setDefinition({
|
|
630
713
|
...definition,
|
|
631
714
|
max_runs: Math.max(1, Number(e.target.value)),
|
|
632
|
-
})
|
|
633
|
-
|
|
715
|
+
});
|
|
716
|
+
markDirty();
|
|
717
|
+
}}
|
|
634
718
|
disabled={!canManage}
|
|
635
719
|
/>
|
|
636
720
|
</div>
|
|
@@ -664,7 +748,10 @@ const AutomationEditContent: React.FC = () => {
|
|
|
664
748
|
computes. */}
|
|
665
749
|
<AutomationDefinitionEditor
|
|
666
750
|
value={definition}
|
|
667
|
-
onChange={
|
|
751
|
+
onChange={(next) => {
|
|
752
|
+
setDefinition(next);
|
|
753
|
+
markDirty();
|
|
754
|
+
}}
|
|
668
755
|
disabled={!canManage}
|
|
669
756
|
automationId={isNew ? undefined : automationId}
|
|
670
757
|
structuralIssues={validationErrors}
|
|
@@ -675,7 +762,10 @@ const AutomationEditContent: React.FC = () => {
|
|
|
675
762
|
<CardContent className="p-0">
|
|
676
763
|
<CodeEditor
|
|
677
764
|
value={yamlText}
|
|
678
|
-
onChange={
|
|
765
|
+
onChange={(next) => {
|
|
766
|
+
setYamlText(next);
|
|
767
|
+
markDirty();
|
|
768
|
+
}}
|
|
679
769
|
language="yaml"
|
|
680
770
|
minHeight="520px"
|
|
681
771
|
readOnly={!canManage}
|
|
@@ -688,6 +778,16 @@ const AutomationEditContent: React.FC = () => {
|
|
|
688
778
|
</div>
|
|
689
779
|
</div>
|
|
690
780
|
)}
|
|
781
|
+
<ConfirmationModal
|
|
782
|
+
isOpen={isBlocked}
|
|
783
|
+
onClose={cancelDiscard}
|
|
784
|
+
onConfirm={confirmDiscard}
|
|
785
|
+
title="Discard unsaved changes?"
|
|
786
|
+
message="You have unsaved changes to this automation. Leaving now will discard them."
|
|
787
|
+
confirmText="Discard changes"
|
|
788
|
+
cancelText="Keep editing"
|
|
789
|
+
variant="warning"
|
|
790
|
+
/>
|
|
691
791
|
</PageLayout>
|
|
692
792
|
);
|
|
693
793
|
};
|