@checkstack/automation-frontend 0.2.0 → 0.3.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 +352 -0
- package/package.json +13 -9
- package/src/components/AutomationGroupCombobox.tsx +133 -0
- package/src/editor/ActionEditor.tsx +180 -90
- package/src/editor/ActionListEditor.tsx +27 -1
- package/src/editor/AddActionDialog.tsx +15 -45
- package/src/editor/AddConditionDialog.tsx +86 -0
- package/src/editor/AddTriggerDialog.tsx +97 -0
- package/src/editor/AutomationDefinitionEditor.tsx +41 -2
- package/src/editor/ConditionEditor.tsx +359 -70
- package/src/editor/ConditionsEditor.tsx +113 -44
- package/src/editor/ItemSheet.tsx +51 -0
- package/src/editor/RunReplayPicker.tsx +97 -0
- package/src/editor/ScriptServicesBooter.tsx +53 -0
- package/src/editor/ScriptTestRenderer.tsx +150 -0
- package/src/editor/SystemEntityPicker.test.ts +37 -0
- package/src/editor/SystemEntityPicker.tsx +109 -0
- package/src/editor/TriggersEditor.tsx +345 -137
- package/src/editor/action-helpers.test.ts +107 -0
- package/src/editor/action-helpers.ts +72 -0
- package/src/editor/action-leaf-cards.tsx +98 -1
- package/src/editor/condition-kind.test.ts +126 -0
- package/src/editor/condition-kind.ts +130 -0
- package/src/editor/item-summary.test.ts +171 -0
- package/src/editor/item-summary.ts +210 -0
- package/src/editor/picker-dialog.tsx +156 -0
- package/src/editor/registry-context.tsx +9 -2
- package/src/editor/script-actions.test.ts +184 -0
- package/src/editor/script-actions.ts +146 -0
- package/src/editor/system-entity-picker.logic.ts +23 -0
- package/src/editor/template-completion.test.ts +22 -3
- package/src/editor/template-completion.ts +16 -8
- package/src/editor/template-helpers.ts +4 -0
- package/src/editor/trigger-helpers.test.ts +28 -0
- package/src/editor/trigger-helpers.ts +17 -0
- package/src/editor/useScriptDiagnostics.ts +108 -0
- package/src/index.tsx +2 -0
- package/src/pages/AutomationEditPage.tsx +95 -47
- package/src/pages/AutomationListPage.tsx +172 -123
- package/src/pages/automation-grouping.test.ts +86 -0
- package/src/pages/automation-grouping.ts +65 -0
- package/src/script-context.test.ts +142 -1
- package/src/script-context.ts +115 -0
- package/tsconfig.json +12 -0
|
@@ -46,40 +46,28 @@ import {
|
|
|
46
46
|
TabPanel,
|
|
47
47
|
} from "@checkstack/ui";
|
|
48
48
|
import { extractErrorMessage, resolveRoute } from "@checkstack/common";
|
|
49
|
+
import {
|
|
50
|
+
GitOpsLockBanner,
|
|
51
|
+
useProvenanceLock,
|
|
52
|
+
} from "@checkstack/gitops-frontend";
|
|
49
53
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
50
54
|
import { AutomationDefinitionEditor } from "../editor/AutomationDefinitionEditor";
|
|
51
55
|
import { assignDefaultIds } from "../editor/action-helpers";
|
|
52
56
|
import { assignDefaultTriggerIds } from "../editor/trigger-helpers";
|
|
53
57
|
import { computeYamlMarkers } from "../editor/yaml-markers";
|
|
54
|
-
import {
|
|
55
|
-
|
|
56
|
-
partitionIssues,
|
|
57
|
-
} from "../editor/editor-validation";
|
|
58
|
+
import { partitionIssues } from "../editor/editor-validation";
|
|
59
|
+
import { AutomationGroupCombobox } from "../components/AutomationGroupCombobox";
|
|
58
60
|
|
|
59
61
|
const STARTER_DEFINITION: AutomationDefinition = {
|
|
60
62
|
name: "New Automation",
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
63
|
+
// Start empty: the operator picks a trigger and adds actions via the Add
|
|
64
|
+
// dialogs (the empty-state hints guide them), rather than starting from a
|
|
65
|
+
// pre-filled trigger + action they then have to replace.
|
|
66
|
+
triggers: [],
|
|
64
67
|
conditions: [],
|
|
65
|
-
|
|
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
|
-
),
|
|
68
|
+
actions: [],
|
|
82
69
|
mode: "single",
|
|
70
|
+
concurrency_scope: "automation",
|
|
83
71
|
max_runs: 10,
|
|
84
72
|
};
|
|
85
73
|
|
|
@@ -119,7 +107,21 @@ const AutomationEditContent: React.FC = () => {
|
|
|
119
107
|
const { allowed: canRead, loading: accessLoading } = accessApi.useAccess(
|
|
120
108
|
automationAccess.read,
|
|
121
109
|
);
|
|
122
|
-
const { allowed:
|
|
110
|
+
const { allowed: hasManageAccess } = accessApi.useAccess(
|
|
111
|
+
automationAccess.manage,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// GitOps provenance lock: when this automation is declaratively managed,
|
|
115
|
+
// disable manual edits + show a banner. `entityId` is the automation id
|
|
116
|
+
// (the GitOps reconciler stores it in provenance). New (unsaved)
|
|
117
|
+
// automations are never locked.
|
|
118
|
+
const { isLocked, provenance } = useProvenanceLock({
|
|
119
|
+
kind: "Automation",
|
|
120
|
+
entityId: isNew ? undefined : automationId,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Effective edit permission: manage access AND not GitOps-locked.
|
|
124
|
+
const canManage = hasManageAccess && !isLocked;
|
|
123
125
|
|
|
124
126
|
const loadQuery = client.getAutomation.useQuery(
|
|
125
127
|
{ id: automationId ?? "" },
|
|
@@ -129,7 +131,13 @@ const AutomationEditContent: React.FC = () => {
|
|
|
129
131
|
// Top-level form state.
|
|
130
132
|
const [name, setName] = React.useState("");
|
|
131
133
|
const [description, setDescription] = React.useState("");
|
|
134
|
+
// Empty string means "Ungrouped" — sent as `null` on save to clear it.
|
|
135
|
+
const [group, setGroup] = React.useState("");
|
|
132
136
|
const [statusEnabled, setStatusEnabled] = React.useState(true);
|
|
137
|
+
|
|
138
|
+
// Existing group values for the picker's "pick existing" suggestions.
|
|
139
|
+
const groupsQuery = client.listAutomationGroups.useQuery();
|
|
140
|
+
const groupSuggestions = groupsQuery.data?.groups ?? [];
|
|
133
141
|
const [definition, setDefinition] =
|
|
134
142
|
React.useState<AutomationDefinition>(STARTER_DEFINITION);
|
|
135
143
|
const [yamlText, setYamlText] = React.useState<string>(() =>
|
|
@@ -150,11 +158,23 @@ const AutomationEditContent: React.FC = () => {
|
|
|
150
158
|
loadQuery.isFetchedAfterMount ? loadQuery.data : undefined,
|
|
151
159
|
loadQuery.data?.id,
|
|
152
160
|
(a) => {
|
|
161
|
+
// Stored definitions (seeded defaults, GitOps, hand-written YAML) may
|
|
162
|
+
// carry triggers/actions without an `id`. The runtime derives those ids
|
|
163
|
+
// on the fly, but the editor must materialize them eagerly so they show
|
|
164
|
+
// immediately rather than appearing blank until the field is focused.
|
|
165
|
+
// Both helpers preserve existing ids and only fill blanks, so this is
|
|
166
|
+
// idempotent and matches how STARTER_DEFINITION is seeded.
|
|
167
|
+
const normalized: AutomationDefinition = {
|
|
168
|
+
...a.definition,
|
|
169
|
+
triggers: assignDefaultTriggerIds(a.definition.triggers),
|
|
170
|
+
actions: assignDefaultIds(a.definition.actions, new Set()),
|
|
171
|
+
};
|
|
153
172
|
setName(a.name);
|
|
154
173
|
setDescription(a.description ?? "");
|
|
174
|
+
setGroup(a.group ?? "");
|
|
155
175
|
setStatusEnabled(a.status === "enabled");
|
|
156
|
-
setDefinition(
|
|
157
|
-
setYamlText(stringifyYaml(
|
|
176
|
+
setDefinition(normalized);
|
|
177
|
+
setYamlText(stringifyYaml(normalized));
|
|
158
178
|
},
|
|
159
179
|
);
|
|
160
180
|
|
|
@@ -308,10 +328,15 @@ const AutomationEditContent: React.FC = () => {
|
|
|
308
328
|
});
|
|
309
329
|
if (!validation.valid) return;
|
|
310
330
|
|
|
331
|
+
// Empty input = Ungrouped. On create we omit it; on update we send `null`
|
|
332
|
+
// so an explicit clear round-trips (undefined would leave it unchanged).
|
|
333
|
+
const trimmedGroup = group.trim();
|
|
334
|
+
|
|
311
335
|
if (isNew) {
|
|
312
336
|
createMutation.mutate({
|
|
313
337
|
name,
|
|
314
338
|
description: description || undefined,
|
|
339
|
+
group: trimmedGroup || undefined,
|
|
315
340
|
status: statusEnabled ? "enabled" : "disabled",
|
|
316
341
|
definition: merged,
|
|
317
342
|
});
|
|
@@ -320,6 +345,7 @@ const AutomationEditContent: React.FC = () => {
|
|
|
320
345
|
id: automationId,
|
|
321
346
|
name,
|
|
322
347
|
description: description || undefined,
|
|
348
|
+
group: trimmedGroup || null,
|
|
323
349
|
status: statusEnabled ? "enabled" : "disabled",
|
|
324
350
|
definition: merged,
|
|
325
351
|
});
|
|
@@ -431,7 +457,11 @@ const AutomationEditContent: React.FC = () => {
|
|
|
431
457
|
) : !isNew && loadQuery.isLoading ? (
|
|
432
458
|
<LoadingSpinner />
|
|
433
459
|
) : (
|
|
434
|
-
<div className="
|
|
460
|
+
<div className="space-y-4">
|
|
461
|
+
{isLocked && provenance && (
|
|
462
|
+
<GitOpsLockBanner provenance={provenance} />
|
|
463
|
+
)}
|
|
464
|
+
<div className="grid gap-4 lg:grid-cols-[1fr_2fr]">
|
|
435
465
|
<Card>
|
|
436
466
|
<CardHeader className="border-b">
|
|
437
467
|
<CardTitle className="text-base">Metadata</CardTitle>
|
|
@@ -462,6 +492,19 @@ const AutomationEditContent: React.FC = () => {
|
|
|
462
492
|
placeholder="Optional"
|
|
463
493
|
/>
|
|
464
494
|
</div>
|
|
495
|
+
<div className="space-y-1">
|
|
496
|
+
<Label htmlFor="group">Group</Label>
|
|
497
|
+
<AutomationGroupCombobox
|
|
498
|
+
id="group"
|
|
499
|
+
value={group}
|
|
500
|
+
onValueChange={setGroup}
|
|
501
|
+
suggestions={groupSuggestions}
|
|
502
|
+
disabled={!canManage}
|
|
503
|
+
/>
|
|
504
|
+
<p className="text-xs text-muted-foreground">
|
|
505
|
+
Optional. Organises the automations list into sections.
|
|
506
|
+
</p>
|
|
507
|
+
</div>
|
|
465
508
|
<div className="flex items-center justify-between">
|
|
466
509
|
<Label htmlFor="enabled">Enabled</Label>
|
|
467
510
|
<Toggle
|
|
@@ -523,25 +566,29 @@ const AutomationEditContent: React.FC = () => {
|
|
|
523
566
|
/>
|
|
524
567
|
</div>
|
|
525
568
|
<TabPanel id="visual" activeTab={tab}>
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
<
|
|
529
|
-
|
|
530
|
-
<
|
|
531
|
-
|
|
532
|
-
{
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
569
|
+
{unattributedIssues.length > 0 && (
|
|
570
|
+
<Alert variant="error" className="mb-2">
|
|
571
|
+
<AlertTitle>Definition issues</AlertTitle>
|
|
572
|
+
<AlertDescription>
|
|
573
|
+
<ul className="space-y-1 text-xs font-mono">
|
|
574
|
+
{unattributedIssues.map((issue, index) => (
|
|
575
|
+
<li key={index}>{issue}</li>
|
|
576
|
+
))}
|
|
577
|
+
</ul>
|
|
578
|
+
</AlertDescription>
|
|
579
|
+
</Alert>
|
|
580
|
+
)}
|
|
581
|
+
{/* `AutomationDefinitionEditor` owns the `ValidationProvider`
|
|
582
|
+
(inside its registry provider) so it can merge these
|
|
583
|
+
structural issues with the live inline-script type issues it
|
|
584
|
+
computes. */}
|
|
585
|
+
<AutomationDefinitionEditor
|
|
586
|
+
value={definition}
|
|
587
|
+
onChange={setDefinition}
|
|
588
|
+
disabled={!canManage}
|
|
589
|
+
automationId={isNew ? undefined : automationId}
|
|
590
|
+
structuralIssues={validationErrors}
|
|
591
|
+
/>
|
|
545
592
|
</TabPanel>
|
|
546
593
|
<TabPanel id="yaml" activeTab={tab}>
|
|
547
594
|
<Card>
|
|
@@ -558,6 +605,7 @@ const AutomationEditContent: React.FC = () => {
|
|
|
558
605
|
</Card>
|
|
559
606
|
</TabPanel>
|
|
560
607
|
</div>
|
|
608
|
+
</div>
|
|
561
609
|
</div>
|
|
562
610
|
)}
|
|
563
611
|
</PageLayout>
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
AutomationApi,
|
|
12
12
|
automationAccess,
|
|
13
13
|
automationRoutes,
|
|
14
|
+
type Automation,
|
|
14
15
|
} from "@checkstack/automation-common";
|
|
15
16
|
import {
|
|
16
17
|
PageLayout,
|
|
@@ -31,10 +32,15 @@ import {
|
|
|
31
32
|
QueryErrorState,
|
|
32
33
|
EmptyState,
|
|
33
34
|
ConfirmationModal,
|
|
35
|
+
Accordion,
|
|
36
|
+
AccordionItem,
|
|
37
|
+
AccordionTrigger,
|
|
38
|
+
AccordionContent,
|
|
34
39
|
useToast,
|
|
35
40
|
} from "@checkstack/ui";
|
|
36
41
|
import { extractErrorMessage, resolveRoute } from "@checkstack/common";
|
|
37
42
|
import { formatDistanceToNow } from "date-fns";
|
|
43
|
+
import { groupAutomations } from "./automation-grouping";
|
|
38
44
|
|
|
39
45
|
/**
|
|
40
46
|
* Lists every automation the operator can see, with quick enable / disable
|
|
@@ -85,9 +91,123 @@ const AutomationListContent: React.FC = () => {
|
|
|
85
91
|
onError: (error) => toast.error(extractErrorMessage(error)),
|
|
86
92
|
});
|
|
87
93
|
|
|
88
|
-
const
|
|
94
|
+
const items = query.data?.items;
|
|
95
|
+
const automations = React.useMemo(() => items ?? [], [items]);
|
|
89
96
|
const isEmpty = !query.isLoading && automations.length === 0;
|
|
90
97
|
|
|
98
|
+
// Collapsible sections, sorted alphabetically with "Ungrouped" last.
|
|
99
|
+
const groups = React.useMemo(
|
|
100
|
+
() => groupAutomations({ automations }),
|
|
101
|
+
[automations],
|
|
102
|
+
);
|
|
103
|
+
// All sections expanded by default so nothing is hidden on first load.
|
|
104
|
+
const allGroupKeys = React.useMemo(() => groups.map((g) => g.key), [groups]);
|
|
105
|
+
|
|
106
|
+
const renderRow = (automation: Automation) => (
|
|
107
|
+
<TableRow
|
|
108
|
+
key={automation.id}
|
|
109
|
+
className="cursor-pointer hover:bg-accent/40"
|
|
110
|
+
onClick={() =>
|
|
111
|
+
navigate(
|
|
112
|
+
resolveRoute(automationRoutes.routes.edit, {
|
|
113
|
+
automationId: automation.id,
|
|
114
|
+
}),
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
>
|
|
118
|
+
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
119
|
+
{canManage ? (
|
|
120
|
+
<Toggle
|
|
121
|
+
checked={automation.status === "enabled"}
|
|
122
|
+
onCheckedChange={(enabled) =>
|
|
123
|
+
toggleMutation.mutate({
|
|
124
|
+
id: automation.id,
|
|
125
|
+
enabled,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
aria-label={
|
|
129
|
+
automation.status === "enabled"
|
|
130
|
+
? "Disable automation"
|
|
131
|
+
: "Enable automation"
|
|
132
|
+
}
|
|
133
|
+
/>
|
|
134
|
+
) : (
|
|
135
|
+
<Badge
|
|
136
|
+
variant={
|
|
137
|
+
automation.status === "enabled" ? "success" : "outline"
|
|
138
|
+
}
|
|
139
|
+
>
|
|
140
|
+
{automation.status}
|
|
141
|
+
</Badge>
|
|
142
|
+
)}
|
|
143
|
+
</TableCell>
|
|
144
|
+
<TableCell>
|
|
145
|
+
<div className="flex flex-col">
|
|
146
|
+
<span className="font-medium">{automation.name}</span>
|
|
147
|
+
{automation.description && (
|
|
148
|
+
<span className="text-xs text-muted-foreground">
|
|
149
|
+
{automation.description}
|
|
150
|
+
</span>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
</TableCell>
|
|
154
|
+
<TableCell>
|
|
155
|
+
<div className="flex flex-wrap gap-1">
|
|
156
|
+
{automation.definition.triggers.slice(0, 3).map((trigger, index) => (
|
|
157
|
+
<Badge
|
|
158
|
+
key={`${trigger.event}-${index}`}
|
|
159
|
+
variant="outline"
|
|
160
|
+
className="text-[10px] font-mono"
|
|
161
|
+
>
|
|
162
|
+
{trigger.event}
|
|
163
|
+
</Badge>
|
|
164
|
+
))}
|
|
165
|
+
{automation.definition.triggers.length > 3 && (
|
|
166
|
+
<Badge variant="outline" className="text-[10px]">
|
|
167
|
+
+{automation.definition.triggers.length - 3}
|
|
168
|
+
</Badge>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
</TableCell>
|
|
172
|
+
<TableCell>
|
|
173
|
+
<Badge variant="secondary" className="text-[10px]">
|
|
174
|
+
{automation.definition.mode}
|
|
175
|
+
</Badge>
|
|
176
|
+
</TableCell>
|
|
177
|
+
<TableCell>
|
|
178
|
+
<span className="text-xs text-muted-foreground">
|
|
179
|
+
{formatDistanceToNow(new Date(automation.updatedAt), {
|
|
180
|
+
addSuffix: true,
|
|
181
|
+
})}
|
|
182
|
+
</span>
|
|
183
|
+
</TableCell>
|
|
184
|
+
<TableCell onClick={(e) => e.stopPropagation()} className="text-right">
|
|
185
|
+
<div className="flex justify-end gap-1">
|
|
186
|
+
<Link
|
|
187
|
+
to={resolveRoute(automationRoutes.routes.runs, {
|
|
188
|
+
automationId: automation.id,
|
|
189
|
+
})}
|
|
190
|
+
>
|
|
191
|
+
<Button variant="ghost" size="sm">
|
|
192
|
+
Runs
|
|
193
|
+
</Button>
|
|
194
|
+
</Link>
|
|
195
|
+
{canManage && (
|
|
196
|
+
<Button
|
|
197
|
+
variant="ghost"
|
|
198
|
+
size="icon"
|
|
199
|
+
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
|
200
|
+
onClick={() => setDeleteId(automation.id)}
|
|
201
|
+
aria-label="Delete automation"
|
|
202
|
+
>
|
|
203
|
+
<Trash2 className="h-4 w-4" />
|
|
204
|
+
</Button>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
</TableCell>
|
|
208
|
+
</TableRow>
|
|
209
|
+
);
|
|
210
|
+
|
|
91
211
|
return (
|
|
92
212
|
<PageLayout
|
|
93
213
|
title="Automations"
|
|
@@ -154,131 +274,60 @@ const AutomationListContent: React.FC = () => {
|
|
|
154
274
|
}
|
|
155
275
|
/>
|
|
156
276
|
) : (
|
|
157
|
-
<
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
className="
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
{canManage ? (
|
|
183
|
-
<Toggle
|
|
184
|
-
checked={automation.status === "enabled"}
|
|
185
|
-
onCheckedChange={(enabled) =>
|
|
186
|
-
toggleMutation.mutate({
|
|
187
|
-
id: automation.id,
|
|
188
|
-
enabled,
|
|
189
|
-
})
|
|
190
|
-
}
|
|
191
|
-
aria-label={
|
|
192
|
-
automation.status === "enabled"
|
|
193
|
-
? "Disable automation"
|
|
194
|
-
: "Enable automation"
|
|
195
|
-
}
|
|
196
|
-
/>
|
|
197
|
-
) : (
|
|
198
|
-
<Badge
|
|
199
|
-
variant={
|
|
200
|
-
automation.status === "enabled"
|
|
201
|
-
? "success"
|
|
202
|
-
: "outline"
|
|
203
|
-
}
|
|
204
|
-
>
|
|
205
|
-
{automation.status}
|
|
206
|
-
</Badge>
|
|
207
|
-
)}
|
|
208
|
-
</TableCell>
|
|
209
|
-
<TableCell>
|
|
210
|
-
<div className="flex flex-col">
|
|
211
|
-
<span className="font-medium">{automation.name}</span>
|
|
212
|
-
{automation.description && (
|
|
213
|
-
<span className="text-xs text-muted-foreground">
|
|
214
|
-
{automation.description}
|
|
215
|
-
</span>
|
|
216
|
-
)}
|
|
217
|
-
</div>
|
|
218
|
-
</TableCell>
|
|
219
|
-
<TableCell>
|
|
220
|
-
<div className="flex flex-wrap gap-1">
|
|
221
|
-
{automation.definition.triggers
|
|
222
|
-
.slice(0, 3)
|
|
223
|
-
.map((trigger, index) => (
|
|
224
|
-
<Badge
|
|
225
|
-
key={`${trigger.event}-${index}`}
|
|
226
|
-
variant="outline"
|
|
227
|
-
className="text-[10px] font-mono"
|
|
228
|
-
>
|
|
229
|
-
{trigger.event}
|
|
230
|
-
</Badge>
|
|
231
|
-
))}
|
|
232
|
-
{automation.definition.triggers.length > 3 && (
|
|
233
|
-
<Badge variant="outline" className="text-[10px]">
|
|
234
|
-
+{automation.definition.triggers.length - 3}
|
|
235
|
-
</Badge>
|
|
236
|
-
)}
|
|
237
|
-
</div>
|
|
238
|
-
</TableCell>
|
|
239
|
-
<TableCell>
|
|
277
|
+
<Accordion
|
|
278
|
+
// Re-key on the group set so newly-appearing sections (after a
|
|
279
|
+
// status-filter change) start expanded rather than collapsed.
|
|
280
|
+
key={allGroupKeys.join("|")}
|
|
281
|
+
type="multiple"
|
|
282
|
+
defaultValue={allGroupKeys}
|
|
283
|
+
className="w-full"
|
|
284
|
+
>
|
|
285
|
+
{groups.map((group) => (
|
|
286
|
+
<AccordionItem
|
|
287
|
+
key={group.key}
|
|
288
|
+
value={group.key}
|
|
289
|
+
className="last:border-b-0"
|
|
290
|
+
>
|
|
291
|
+
<AccordionTrigger className="px-4">
|
|
292
|
+
<span className="flex items-center gap-2">
|
|
293
|
+
<span
|
|
294
|
+
className={
|
|
295
|
+
group.ungrouped
|
|
296
|
+
? "text-muted-foreground"
|
|
297
|
+
: undefined
|
|
298
|
+
}
|
|
299
|
+
>
|
|
300
|
+
{group.label}
|
|
301
|
+
</span>
|
|
240
302
|
<Badge variant="secondary" className="text-[10px]">
|
|
241
|
-
{
|
|
303
|
+
{group.items.length}
|
|
242
304
|
</Badge>
|
|
243
|
-
</
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
Runs
|
|
263
|
-
</Button>
|
|
264
|
-
</Link>
|
|
265
|
-
{canManage && (
|
|
266
|
-
<Button
|
|
267
|
-
variant="ghost"
|
|
268
|
-
size="icon"
|
|
269
|
-
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
|
270
|
-
onClick={() => setDeleteId(automation.id)}
|
|
271
|
-
aria-label="Delete automation"
|
|
272
|
-
>
|
|
273
|
-
<Trash2 className="h-4 w-4" />
|
|
274
|
-
</Button>
|
|
305
|
+
</span>
|
|
306
|
+
</AccordionTrigger>
|
|
307
|
+
<AccordionContent className="px-0 pb-0">
|
|
308
|
+
<Table>
|
|
309
|
+
<TableHeader>
|
|
310
|
+
<TableRow>
|
|
311
|
+
<TableHead className="w-8" />
|
|
312
|
+
<TableHead>Name</TableHead>
|
|
313
|
+
<TableHead>Triggers</TableHead>
|
|
314
|
+
<TableHead>Mode</TableHead>
|
|
315
|
+
<TableHead>Updated</TableHead>
|
|
316
|
+
<TableHead className="w-24 text-right">
|
|
317
|
+
Actions
|
|
318
|
+
</TableHead>
|
|
319
|
+
</TableRow>
|
|
320
|
+
</TableHeader>
|
|
321
|
+
<TableBody>
|
|
322
|
+
{group.items.map((automation) =>
|
|
323
|
+
renderRow(automation),
|
|
275
324
|
)}
|
|
276
|
-
</
|
|
277
|
-
</
|
|
278
|
-
</
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
</
|
|
325
|
+
</TableBody>
|
|
326
|
+
</Table>
|
|
327
|
+
</AccordionContent>
|
|
328
|
+
</AccordionItem>
|
|
329
|
+
))}
|
|
330
|
+
</Accordion>
|
|
282
331
|
)}
|
|
283
332
|
</CardContent>
|
|
284
333
|
</Card>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { Automation, AutomationDefinition } from "@checkstack/automation-common";
|
|
3
|
+
import {
|
|
4
|
+
groupAutomations,
|
|
5
|
+
UNGROUPED_LABEL,
|
|
6
|
+
} from "./automation-grouping";
|
|
7
|
+
|
|
8
|
+
const DEFINITION: AutomationDefinition = {
|
|
9
|
+
name: "n",
|
|
10
|
+
triggers: [{ event: "incident.incident.created" }],
|
|
11
|
+
conditions: [],
|
|
12
|
+
actions: [],
|
|
13
|
+
mode: "single",
|
|
14
|
+
concurrency_scope: "automation",
|
|
15
|
+
max_runs: 10,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function make({
|
|
19
|
+
id,
|
|
20
|
+
group,
|
|
21
|
+
}: {
|
|
22
|
+
id: string;
|
|
23
|
+
group?: string;
|
|
24
|
+
}): Automation {
|
|
25
|
+
return {
|
|
26
|
+
id,
|
|
27
|
+
name: `Automation ${id}`,
|
|
28
|
+
group,
|
|
29
|
+
status: "enabled",
|
|
30
|
+
definition: DEFINITION,
|
|
31
|
+
createdAt: new Date("2026-01-01T00:00:00Z"),
|
|
32
|
+
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("groupAutomations", () => {
|
|
37
|
+
it("returns an empty list for no automations", () => {
|
|
38
|
+
expect(groupAutomations({ automations: [] })).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("puts automations with no group into the Ungrouped bucket", () => {
|
|
42
|
+
const groups = groupAutomations({
|
|
43
|
+
automations: [make({ id: "1" }), make({ id: "2", group: " " })],
|
|
44
|
+
});
|
|
45
|
+
expect(groups).toHaveLength(1);
|
|
46
|
+
expect(groups[0]?.label).toBe(UNGROUPED_LABEL);
|
|
47
|
+
expect(groups[0]?.ungrouped).toBe(true);
|
|
48
|
+
expect(groups[0]?.items.map((a) => a.id)).toEqual(["1", "2"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("groups by group value and sorts named groups alphabetically", () => {
|
|
52
|
+
const groups = groupAutomations({
|
|
53
|
+
automations: [
|
|
54
|
+
make({ id: "1", group: "Zeta" }),
|
|
55
|
+
make({ id: "2", group: "alpha" }),
|
|
56
|
+
make({ id: "3", group: "Zeta" }),
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
expect(groups.map((g) => g.label)).toEqual(["alpha", "Zeta"]);
|
|
60
|
+
expect(groups[1]?.items.map((a) => a.id)).toEqual(["1", "3"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("always places the Ungrouped bucket last", () => {
|
|
64
|
+
const groups = groupAutomations({
|
|
65
|
+
automations: [
|
|
66
|
+
make({ id: "1" }),
|
|
67
|
+
make({ id: "2", group: "Networking" }),
|
|
68
|
+
make({ id: "3", group: "Alerting" }),
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
expect(groups.map((g) => g.label)).toEqual([
|
|
72
|
+
"Alerting",
|
|
73
|
+
"Networking",
|
|
74
|
+
UNGROUPED_LABEL,
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("never emits empty groups", () => {
|
|
79
|
+
const groups = groupAutomations({
|
|
80
|
+
automations: [make({ id: "1", group: "Only" })],
|
|
81
|
+
});
|
|
82
|
+
expect(groups).toHaveLength(1);
|
|
83
|
+
expect(groups[0]?.label).toBe("Only");
|
|
84
|
+
expect(groups.some((g) => g.items.length === 0)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|