@checkstack/automation-frontend 0.2.0 → 0.4.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 +496 -0
- package/package.json +18 -13
- package/src/components/AutomationGroupCombobox.tsx +133 -0
- package/src/components/RunAsServiceAccountPicker.tsx +170 -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 +57 -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 +105 -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 +26 -24
- package/src/pages/AutomationEditPage.tsx +116 -48
- package/src/pages/AutomationListPage.tsx +172 -123
- package/src/pages/automation-grouping.test.ts +87 -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 +15 -0
- package/src/components/AutomationMenuItems.tsx +0 -37
|
@@ -46,40 +46,29 @@ 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
|
-
|
|
57
|
-
} from "../editor/editor-validation";
|
|
58
|
+
import { partitionIssues } from "../editor/editor-validation";
|
|
59
|
+
import { AutomationGroupCombobox } from "../components/AutomationGroupCombobox";
|
|
60
|
+
import { RunAsServiceAccountPicker } from "../components/RunAsServiceAccountPicker";
|
|
58
61
|
|
|
59
62
|
const STARTER_DEFINITION: AutomationDefinition = {
|
|
60
63
|
name: "New Automation",
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
+
// Start empty: the operator picks a trigger and adds actions via the Add
|
|
65
|
+
// dialogs (the empty-state hints guide them), rather than starting from a
|
|
66
|
+
// pre-filled trigger + action they then have to replace.
|
|
67
|
+
triggers: [],
|
|
64
68
|
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
|
-
),
|
|
69
|
+
actions: [],
|
|
82
70
|
mode: "single",
|
|
71
|
+
concurrency_scope: "automation",
|
|
83
72
|
max_runs: 10,
|
|
84
73
|
};
|
|
85
74
|
|
|
@@ -119,7 +108,21 @@ const AutomationEditContent: React.FC = () => {
|
|
|
119
108
|
const { allowed: canRead, loading: accessLoading } = accessApi.useAccess(
|
|
120
109
|
automationAccess.read,
|
|
121
110
|
);
|
|
122
|
-
const { allowed:
|
|
111
|
+
const { allowed: hasManageAccess } = accessApi.useAccess(
|
|
112
|
+
automationAccess.manage,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// GitOps provenance lock: when this automation is declaratively managed,
|
|
116
|
+
// disable manual edits + show a banner. `entityId` is the automation id
|
|
117
|
+
// (the GitOps reconciler stores it in provenance). New (unsaved)
|
|
118
|
+
// automations are never locked.
|
|
119
|
+
const { isLocked, provenance } = useProvenanceLock({
|
|
120
|
+
kind: "Automation",
|
|
121
|
+
entityId: isNew ? undefined : automationId,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Effective edit permission: manage access AND not GitOps-locked.
|
|
125
|
+
const canManage = hasManageAccess && !isLocked;
|
|
123
126
|
|
|
124
127
|
const loadQuery = client.getAutomation.useQuery(
|
|
125
128
|
{ id: automationId ?? "" },
|
|
@@ -129,7 +132,16 @@ const AutomationEditContent: React.FC = () => {
|
|
|
129
132
|
// Top-level form state.
|
|
130
133
|
const [name, setName] = React.useState("");
|
|
131
134
|
const [description, setDescription] = React.useState("");
|
|
135
|
+
// Empty string means "Ungrouped" — sent as `null` on save to clear it.
|
|
136
|
+
const [group, setGroup] = React.useState("");
|
|
137
|
+
// Service account (Application id) the automation runs as. Empty means
|
|
138
|
+
// "not chosen yet" — save is blocked until one is selected.
|
|
139
|
+
const [runAsApplicationId, setRunAsApplicationId] = React.useState("");
|
|
132
140
|
const [statusEnabled, setStatusEnabled] = React.useState(true);
|
|
141
|
+
|
|
142
|
+
// Existing group values for the picker's "pick existing" suggestions.
|
|
143
|
+
const groupsQuery = client.listAutomationGroups.useQuery();
|
|
144
|
+
const groupSuggestions = groupsQuery.data?.groups ?? [];
|
|
133
145
|
const [definition, setDefinition] =
|
|
134
146
|
React.useState<AutomationDefinition>(STARTER_DEFINITION);
|
|
135
147
|
const [yamlText, setYamlText] = React.useState<string>(() =>
|
|
@@ -150,11 +162,24 @@ const AutomationEditContent: React.FC = () => {
|
|
|
150
162
|
loadQuery.isFetchedAfterMount ? loadQuery.data : undefined,
|
|
151
163
|
loadQuery.data?.id,
|
|
152
164
|
(a) => {
|
|
165
|
+
// Stored definitions (seeded defaults, GitOps, hand-written YAML) may
|
|
166
|
+
// carry triggers/actions without an `id`. The runtime derives those ids
|
|
167
|
+
// on the fly, but the editor must materialize them eagerly so they show
|
|
168
|
+
// immediately rather than appearing blank until the field is focused.
|
|
169
|
+
// Both helpers preserve existing ids and only fill blanks, so this is
|
|
170
|
+
// idempotent and matches how STARTER_DEFINITION is seeded.
|
|
171
|
+
const normalized: AutomationDefinition = {
|
|
172
|
+
...a.definition,
|
|
173
|
+
triggers: assignDefaultTriggerIds(a.definition.triggers),
|
|
174
|
+
actions: assignDefaultIds(a.definition.actions, new Set()),
|
|
175
|
+
};
|
|
153
176
|
setName(a.name);
|
|
154
177
|
setDescription(a.description ?? "");
|
|
178
|
+
setGroup(a.group ?? "");
|
|
179
|
+
setRunAsApplicationId(a.runAs ?? "");
|
|
155
180
|
setStatusEnabled(a.status === "enabled");
|
|
156
|
-
setDefinition(
|
|
157
|
-
setYamlText(stringifyYaml(
|
|
181
|
+
setDefinition(normalized);
|
|
182
|
+
setYamlText(stringifyYaml(normalized));
|
|
158
183
|
},
|
|
159
184
|
);
|
|
160
185
|
|
|
@@ -308,10 +333,16 @@ const AutomationEditContent: React.FC = () => {
|
|
|
308
333
|
});
|
|
309
334
|
if (!validation.valid) return;
|
|
310
335
|
|
|
336
|
+
// Empty input = Ungrouped. On create we omit it; on update we send `null`
|
|
337
|
+
// so an explicit clear round-trips (undefined would leave it unchanged).
|
|
338
|
+
const trimmedGroup = group.trim();
|
|
339
|
+
|
|
311
340
|
if (isNew) {
|
|
312
341
|
createMutation.mutate({
|
|
313
342
|
name,
|
|
314
343
|
description: description || undefined,
|
|
344
|
+
group: trimmedGroup || undefined,
|
|
345
|
+
runAs: runAsApplicationId,
|
|
315
346
|
status: statusEnabled ? "enabled" : "disabled",
|
|
316
347
|
definition: merged,
|
|
317
348
|
});
|
|
@@ -320,6 +351,8 @@ const AutomationEditContent: React.FC = () => {
|
|
|
320
351
|
id: automationId,
|
|
321
352
|
name,
|
|
322
353
|
description: description || undefined,
|
|
354
|
+
group: trimmedGroup || null,
|
|
355
|
+
runAs: runAsApplicationId,
|
|
323
356
|
status: statusEnabled ? "enabled" : "disabled",
|
|
324
357
|
definition: merged,
|
|
325
358
|
});
|
|
@@ -367,8 +400,15 @@ const AutomationEditContent: React.FC = () => {
|
|
|
367
400
|
// "Input validation failed" toast. Validate it here so the Name field can
|
|
368
401
|
// surface the error and Save can be disabled instead.
|
|
369
402
|
const nameError = name.trim().length === 0 ? "Name is required" : undefined;
|
|
403
|
+
// A service account is required: the automation runs with its permissions,
|
|
404
|
+
// so the backend rejects a save without one. Block save + hint inline.
|
|
405
|
+
const runAsError =
|
|
406
|
+
runAsApplicationId.trim().length === 0
|
|
407
|
+
? "A service account is required"
|
|
408
|
+
: undefined;
|
|
370
409
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
|
371
|
-
const canSave =
|
|
410
|
+
const canSave =
|
|
411
|
+
!nameError && !runAsError && validationErrors.length === 0 && !isSaving;
|
|
372
412
|
|
|
373
413
|
return (
|
|
374
414
|
<PageLayout
|
|
@@ -431,7 +471,11 @@ const AutomationEditContent: React.FC = () => {
|
|
|
431
471
|
) : !isNew && loadQuery.isLoading ? (
|
|
432
472
|
<LoadingSpinner />
|
|
433
473
|
) : (
|
|
434
|
-
<div className="
|
|
474
|
+
<div className="space-y-4">
|
|
475
|
+
{isLocked && provenance && (
|
|
476
|
+
<GitOpsLockBanner provenance={provenance} />
|
|
477
|
+
)}
|
|
478
|
+
<div className="grid gap-4 lg:grid-cols-[1fr_2fr]">
|
|
435
479
|
<Card>
|
|
436
480
|
<CardHeader className="border-b">
|
|
437
481
|
<CardTitle className="text-base">Metadata</CardTitle>
|
|
@@ -462,6 +506,25 @@ const AutomationEditContent: React.FC = () => {
|
|
|
462
506
|
placeholder="Optional"
|
|
463
507
|
/>
|
|
464
508
|
</div>
|
|
509
|
+
<div className="space-y-1">
|
|
510
|
+
<Label htmlFor="group">Group</Label>
|
|
511
|
+
<AutomationGroupCombobox
|
|
512
|
+
id="group"
|
|
513
|
+
value={group}
|
|
514
|
+
onValueChange={setGroup}
|
|
515
|
+
suggestions={groupSuggestions}
|
|
516
|
+
disabled={!canManage}
|
|
517
|
+
/>
|
|
518
|
+
<p className="text-xs text-muted-foreground">
|
|
519
|
+
Optional. Organises the automations list into sections.
|
|
520
|
+
</p>
|
|
521
|
+
</div>
|
|
522
|
+
<RunAsServiceAccountPicker
|
|
523
|
+
value={runAsApplicationId}
|
|
524
|
+
onValueChange={setRunAsApplicationId}
|
|
525
|
+
disabled={!canManage}
|
|
526
|
+
showError={!!runAsError}
|
|
527
|
+
/>
|
|
465
528
|
<div className="flex items-center justify-between">
|
|
466
529
|
<Label htmlFor="enabled">Enabled</Label>
|
|
467
530
|
<Toggle
|
|
@@ -523,25 +586,29 @@ const AutomationEditContent: React.FC = () => {
|
|
|
523
586
|
/>
|
|
524
587
|
</div>
|
|
525
588
|
<TabPanel id="visual" activeTab={tab}>
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
<
|
|
529
|
-
|
|
530
|
-
<
|
|
531
|
-
|
|
532
|
-
{
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
589
|
+
{unattributedIssues.length > 0 && (
|
|
590
|
+
<Alert variant="error" className="mb-2">
|
|
591
|
+
<AlertTitle>Definition issues</AlertTitle>
|
|
592
|
+
<AlertDescription>
|
|
593
|
+
<ul className="space-y-1 text-xs font-mono">
|
|
594
|
+
{unattributedIssues.map((issue, index) => (
|
|
595
|
+
<li key={index}>{issue}</li>
|
|
596
|
+
))}
|
|
597
|
+
</ul>
|
|
598
|
+
</AlertDescription>
|
|
599
|
+
</Alert>
|
|
600
|
+
)}
|
|
601
|
+
{/* `AutomationDefinitionEditor` owns the `ValidationProvider`
|
|
602
|
+
(inside its registry provider) so it can merge these
|
|
603
|
+
structural issues with the live inline-script type issues it
|
|
604
|
+
computes. */}
|
|
605
|
+
<AutomationDefinitionEditor
|
|
606
|
+
value={definition}
|
|
607
|
+
onChange={setDefinition}
|
|
608
|
+
disabled={!canManage}
|
|
609
|
+
automationId={isNew ? undefined : automationId}
|
|
610
|
+
structuralIssues={validationErrors}
|
|
611
|
+
/>
|
|
545
612
|
</TabPanel>
|
|
546
613
|
<TabPanel id="yaml" activeTab={tab}>
|
|
547
614
|
<Card>
|
|
@@ -558,6 +625,7 @@ const AutomationEditContent: React.FC = () => {
|
|
|
558
625
|
</Card>
|
|
559
626
|
</TabPanel>
|
|
560
627
|
</div>
|
|
628
|
+
</div>
|
|
561
629
|
</div>
|
|
562
630
|
)}
|
|
563
631
|
</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,87 @@
|
|
|
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
|
+
runAs: null,
|
|
30
|
+
status: "enabled",
|
|
31
|
+
definition: DEFINITION,
|
|
32
|
+
createdAt: new Date("2026-01-01T00:00:00Z"),
|
|
33
|
+
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("groupAutomations", () => {
|
|
38
|
+
it("returns an empty list for no automations", () => {
|
|
39
|
+
expect(groupAutomations({ automations: [] })).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("puts automations with no group into the Ungrouped bucket", () => {
|
|
43
|
+
const groups = groupAutomations({
|
|
44
|
+
automations: [make({ id: "1" }), make({ id: "2", group: " " })],
|
|
45
|
+
});
|
|
46
|
+
expect(groups).toHaveLength(1);
|
|
47
|
+
expect(groups[0]?.label).toBe(UNGROUPED_LABEL);
|
|
48
|
+
expect(groups[0]?.ungrouped).toBe(true);
|
|
49
|
+
expect(groups[0]?.items.map((a) => a.id)).toEqual(["1", "2"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("groups by group value and sorts named groups alphabetically", () => {
|
|
53
|
+
const groups = groupAutomations({
|
|
54
|
+
automations: [
|
|
55
|
+
make({ id: "1", group: "Zeta" }),
|
|
56
|
+
make({ id: "2", group: "alpha" }),
|
|
57
|
+
make({ id: "3", group: "Zeta" }),
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
expect(groups.map((g) => g.label)).toEqual(["alpha", "Zeta"]);
|
|
61
|
+
expect(groups[1]?.items.map((a) => a.id)).toEqual(["1", "3"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("always places the Ungrouped bucket last", () => {
|
|
65
|
+
const groups = groupAutomations({
|
|
66
|
+
automations: [
|
|
67
|
+
make({ id: "1" }),
|
|
68
|
+
make({ id: "2", group: "Networking" }),
|
|
69
|
+
make({ id: "3", group: "Alerting" }),
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
expect(groups.map((g) => g.label)).toEqual([
|
|
73
|
+
"Alerting",
|
|
74
|
+
"Networking",
|
|
75
|
+
UNGROUPED_LABEL,
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("never emits empty groups", () => {
|
|
80
|
+
const groups = groupAutomations({
|
|
81
|
+
automations: [make({ id: "1", group: "Only" })],
|
|
82
|
+
});
|
|
83
|
+
expect(groups).toHaveLength(1);
|
|
84
|
+
expect(groups[0]?.label).toBe("Only");
|
|
85
|
+
expect(groups.some((g) => g.items.length === 0)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|