@sentry/junior-dashboard 0.233.0 → 0.234.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 (39) hide show
  1. package/dist/app.js +89 -15
  2. package/dist/client.js +55 -52
  3. package/dist/index.js +89 -15
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +3 -3
  6. package/src/client/App.tsx +359 -345
  7. package/src/client/api.ts +17 -1
  8. package/src/client/components/ActorAvatar.tsx +37 -0
  9. package/src/client/components/Button.tsx +12 -10
  10. package/src/client/components/Field.tsx +14 -1
  11. package/src/client/components/FormNotice.tsx +40 -0
  12. package/src/client/components/ParticipantAvatarStack.tsx +6 -14
  13. package/src/client/components/README.md +11 -2
  14. package/src/client/components/Select.tsx +18 -0
  15. package/src/client/components/StatusChip.tsx +15 -12
  16. package/src/client/components/TextInput.tsx +25 -5
  17. package/src/client/http.ts +31 -3
  18. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +71 -54
  19. package/src/client/pages/automations/AutomationEditPage.tsx +172 -0
  20. package/src/client/pages/automations/AutomationEditor.tsx +397 -0
  21. package/src/client/pages/automations/AutomationEditorLoading.tsx +18 -0
  22. package/src/client/pages/automations/AutomationEventFields.tsx +264 -0
  23. package/src/client/pages/automations/AutomationFilters.tsx +171 -0
  24. package/src/client/pages/automations/AutomationFormSection.tsx +22 -0
  25. package/src/client/pages/automations/AutomationLifecycle.tsx +146 -0
  26. package/src/client/pages/automations/AutomationOutcomeFields.tsx +177 -0
  27. package/src/client/pages/automations/AutomationRow.tsx +358 -0
  28. package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
  29. package/src/client/pages/automations/AutomationsPage.tsx +157 -408
  30. package/src/client/pages/automations/AutomationsPageLayout.tsx +16 -5
  31. package/src/client/pages/automations/README.md +19 -0
  32. package/src/client/pages/automations/automationDraft.ts +110 -0
  33. package/src/client/pages/automations/automationOutcomes.ts +23 -0
  34. package/src/client/pages/dev/ComponentsPage.tsx +50 -4
  35. package/src/client/searchParams.ts +4 -2
  36. package/src/client.tsx +7 -4
  37. package/src/mock-reporting/automation-collection.ts +101 -0
  38. package/src/mock-reporting/fixtures.ts +9 -3
  39. package/src/mock-reporting/routes.ts +14 -9
@@ -0,0 +1,264 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import {
3
+ automationEventCatalogSchema,
4
+ type AutomationEdit,
5
+ } from "@sentry/junior/api/schema";
6
+ import { Field } from "../../components/Field";
7
+ import { Select } from "../../components/Select";
8
+ import { TextInput } from "../../components/TextInput";
9
+ import { Button } from "../../components/Button";
10
+ import { FormNotice } from "../../components/FormNotice";
11
+ import { fetchDashboardJson } from "../../http";
12
+
13
+ type Trigger = Extract<AutomationEdit, { kind: "event" }>["trigger"];
14
+
15
+ /** Render registered Event choices without discarding unsupported saved selectors. */
16
+ export function AutomationEventFields(props: {
17
+ value: Trigger;
18
+ original: Trigger;
19
+ onChange(value: Trigger): void;
20
+ }) {
21
+ const catalog = useQuery({
22
+ queryKey: ["dashboard", "automation-event-catalog"],
23
+ queryFn: ({ signal }) =>
24
+ fetchDashboardJson(
25
+ automationEventCatalogSchema,
26
+ "/api/automations/event-catalog",
27
+ signal,
28
+ ),
29
+ retry: false,
30
+ });
31
+ const resource = catalog.data?.find(
32
+ (item) =>
33
+ item.namespace === props.value.namespace &&
34
+ item.type === props.value.resourceType,
35
+ );
36
+ const unsupported =
37
+ !resource ||
38
+ props.value.events.some(
39
+ (event) => !resource.supportedEvents.includes(event),
40
+ ) ||
41
+ Object.entries(props.value.match ?? {}).some(([key, value]) => {
42
+ const field = resource.matchFields?.[key];
43
+ if (!field) return true;
44
+ return (Array.isArray(value) ? value : [value]).some(
45
+ (item) =>
46
+ typeof item !== field.kind ||
47
+ (field.enum && !field.enum.includes(String(item))),
48
+ );
49
+ });
50
+ const setMatch = (
51
+ key: string,
52
+ value: NonNullable<Trigger["match"]>[string] | undefined,
53
+ ) => {
54
+ const match = { ...props.value.match };
55
+ if (value === undefined) delete match[key];
56
+ else match[key] = value;
57
+ props.onChange({ ...props.value, match });
58
+ };
59
+ return (
60
+ <>
61
+ {catalog.error ? (
62
+ <FormNotice title="Event choices could not be loaded.">
63
+ Saved values are kept.{" "}
64
+ <Button onClick={() => void catalog.refetch()}>Try again</Button>
65
+ </FormNotice>
66
+ ) : catalog.isPending ? (
67
+ <p role="status">Loading event choices…</p>
68
+ ) : null}
69
+ {unsupported && catalog.data ? (
70
+ <FormNotice title="Saved trigger · Kept unchanged">
71
+ Some saved values are not in the current catalog. You can edit other
72
+ fields, or select a new resource type to replace the trigger.
73
+ <pre className="whitespace-pre-wrap break-all text-xs">
74
+ {JSON.stringify(props.value, null, 2)}
75
+ </pre>
76
+ </FormNotice>
77
+ ) : null}
78
+ <Field
79
+ label={unsupported ? "Replace trigger" : "Resource type"}
80
+ htmlFor="trigger-resource"
81
+ >
82
+ <Select
83
+ id="trigger-resource"
84
+ value={
85
+ unsupported
86
+ ? ""
87
+ : `${props.value.namespace}:${props.value.resourceType}`
88
+ }
89
+ onChange={(e) => {
90
+ const next = catalog.data?.find(
91
+ (item) => `${item.namespace}:${item.type}` === e.target.value,
92
+ );
93
+ if (!next) return;
94
+ if (
95
+ !window.confirm(
96
+ "Replace the saved trigger? Its resource, events, and conditions will be cleared.",
97
+ )
98
+ )
99
+ return;
100
+ props.onChange({
101
+ namespace: next.namespace,
102
+ resourceType: next.type,
103
+ identifier: "",
104
+ label: "",
105
+ events: [],
106
+ });
107
+ }}
108
+ >
109
+ <option value="" disabled>
110
+ Keep saved trigger
111
+ </option>
112
+ {catalog.data?.map((item) => (
113
+ <option
114
+ key={`${item.namespace}:${item.type}`}
115
+ value={`${item.namespace}:${item.type}`}
116
+ >
117
+ {item.namespace} · {item.type.replaceAll("_", " ")}
118
+ </option>
119
+ ))}
120
+ </Select>
121
+ </Field>
122
+ {!unsupported && resource ? (
123
+ <>
124
+ <Field label="Resource name" htmlFor="trigger-label">
125
+ <TextInput
126
+ size="comfortable"
127
+ id="trigger-label"
128
+ value={props.value.label}
129
+ onChange={(e) =>
130
+ props.onChange({ ...props.value, label: e.target.value })
131
+ }
132
+ />
133
+ </Field>
134
+ <Field
135
+ label="Resource identifier"
136
+ htmlFor="trigger-identifier"
137
+ help="Use the exact provider resource identifier, not its display name."
138
+ >
139
+ <TextInput
140
+ size="comfortable"
141
+ id="trigger-identifier"
142
+ value={props.value.identifier}
143
+ onChange={(e) =>
144
+ props.onChange({ ...props.value, identifier: e.target.value })
145
+ }
146
+ />
147
+ </Field>
148
+ <fieldset>
149
+ <legend className="mb-2 text-sm font-semibold">
150
+ Run on these events
151
+ </legend>
152
+ <div className="divide-y divide-dashboard-border-subtle rounded-md border border-dashboard-border-emphasis">
153
+ {resource.supportedEvents.map((event) => (
154
+ <label
155
+ key={event}
156
+ className="flex min-h-11 cursor-pointer items-start gap-3 px-3 py-3 text-sm"
157
+ >
158
+ <input
159
+ type="checkbox"
160
+ className="mt-0.5 size-4 shrink-0 accent-dashboard-focus"
161
+ checked={props.value.events.includes(event)}
162
+ onChange={() =>
163
+ props.onChange({
164
+ ...props.value,
165
+ events: props.value.events.includes(event)
166
+ ? props.value.events.filter((v) => v !== event)
167
+ : [...props.value.events, event],
168
+ })
169
+ }
170
+ />
171
+ <span className="min-w-0 break-words">
172
+ {event.replaceAll("_", " ").replaceAll(".", " · ")}
173
+ {resource.guidance?.[event] ? (
174
+ <span className="mt-1 block text-xs leading-relaxed text-dashboard-text-muted">
175
+ {resource.guidance[event]}
176
+ </span>
177
+ ) : null}
178
+ </span>
179
+ </label>
180
+ ))}
181
+ </div>
182
+ </fieldset>
183
+ {Object.keys(resource.matchFields ?? {}).length ? (
184
+ <fieldset className="grid gap-4">
185
+ <legend className="mb-3 text-sm font-semibold">Only when</legend>
186
+ <p className="m-0 text-xs leading-relaxed text-dashboard-text-muted">
187
+ All conditions must match. Leave a field blank to match any
188
+ value. Existing lists match any one listed value.
189
+ </p>
190
+ {Object.entries(resource.matchFields ?? {}).map(
191
+ ([key, field]) => {
192
+ const value = props.value.match?.[key];
193
+ return (
194
+ <Field
195
+ key={key}
196
+ label={key}
197
+ htmlFor={`match-${key}`}
198
+ help={field.description}
199
+ >
200
+ {Array.isArray(value) ? (
201
+ <div className="text-sm">
202
+ <span className="break-all">{value.join(", ")}</span>{" "}
203
+ <Button onClick={() => setMatch(key, undefined)}>
204
+ Remove condition
205
+ </Button>
206
+ </div>
207
+ ) : field.kind === "boolean" || field.enum ? (
208
+ <Select
209
+ id={`match-${key}`}
210
+ value={value === undefined ? "" : String(value)}
211
+ onChange={(e) =>
212
+ setMatch(
213
+ key,
214
+ e.target.value === ""
215
+ ? undefined
216
+ : field.kind === "boolean"
217
+ ? e.target.value === "true"
218
+ : e.target.value,
219
+ )
220
+ }
221
+ >
222
+ <option value="">Any value</option>
223
+ {(field.enum ?? ["true", "false"]).map((v) => (
224
+ <option key={v}>{v}</option>
225
+ ))}
226
+ </Select>
227
+ ) : (
228
+ <TextInput
229
+ size="comfortable"
230
+ id={`match-${key}`}
231
+ type={field.kind === "number" ? "number" : "text"}
232
+ step="any"
233
+ value={value === undefined ? "" : String(value)}
234
+ onChange={(e) =>
235
+ setMatch(
236
+ key,
237
+ e.target.value === ""
238
+ ? undefined
239
+ : field.kind === "number"
240
+ ? e.target.valueAsNumber
241
+ : e.target.value,
242
+ )
243
+ }
244
+ />
245
+ )}
246
+ </Field>
247
+ );
248
+ },
249
+ )}
250
+ </fieldset>
251
+ ) : null}
252
+ </>
253
+ ) : null}
254
+ {JSON.stringify(props.value) !== JSON.stringify(props.original) ? (
255
+ <Button
256
+ className="w-fit"
257
+ onClick={() => props.onChange(props.original)}
258
+ >
259
+ Keep saved trigger
260
+ </Button>
261
+ ) : null}
262
+ </>
263
+ );
264
+ }
@@ -0,0 +1,171 @@
1
+ import { useId, useState } from "react";
2
+ import { SlidersHorizontal } from "lucide-react";
3
+ import { cn } from "../../styles";
4
+ import type {
5
+ AutomationList,
6
+ AutomationListQuery,
7
+ } from "@sentry/junior/api/schema";
8
+ import { Button, ToggleButton } from "../../components/Button";
9
+ import { FilterGroup } from "../../components/FilterBar";
10
+ import { SearchInput } from "../../components/SearchInput";
11
+
12
+ /** Keep collection controls visible while a filtered page loads. */
13
+ export function AutomationFilters(props: {
14
+ filters: AutomationListQuery;
15
+ data: AutomationList | undefined;
16
+ onChange(key: string, value: string): void;
17
+ searchText: string;
18
+ onSearch(value: string): void;
19
+ }) {
20
+ const { filters, data, onChange } = props;
21
+ const [expanded, setExpanded] = useState(false);
22
+ const controlsId = useId();
23
+ const activeCount = [
24
+ filters.type !== "all",
25
+ Boolean(filters.creator),
26
+ Boolean(filters.destination),
27
+ filters.state !== "all",
28
+ filters.sort !== "newest",
29
+ ].filter(Boolean).length;
30
+ return (
31
+ <div className="grid gap-3">
32
+ <div className="grid gap-2 lg:grid-cols-[auto_minmax(16rem,1fr)] lg:items-center lg:gap-4">
33
+ <div aria-label="Scope" role="group" className="flex flex-wrap gap-2">
34
+ {(
35
+ [
36
+ ["all", "All"],
37
+ ["mine", "Mine"],
38
+ ["public", "Public"],
39
+ ["attention", "Needs attention"],
40
+ ] as const
41
+ ).map(([value, label]) => (
42
+ <ToggleButton
43
+ className="min-h-11 font-sans! normal-case! lg:min-h-9"
44
+ key={value}
45
+ onClick={() => onChange("scope", value)}
46
+ pressed={filters.scope === value}
47
+ variant="pill"
48
+ >
49
+ {label}
50
+ {data && value !== "attention" ? (
51
+ <span className="ml-1.5 opacity-65">{data.counts[value]}</span>
52
+ ) : null}
53
+ </ToggleButton>
54
+ ))}
55
+ </div>
56
+ <div className="flex items-center gap-2">
57
+ <SearchInput
58
+ className="flex-1"
59
+ size="default"
60
+ label="Search automations"
61
+ placeholder="Search automations"
62
+ value={props.searchText}
63
+ onChange={props.onSearch}
64
+ />
65
+ <Button
66
+ className="h-11 shrink-0"
67
+ aria-expanded={expanded}
68
+ aria-controls={controlsId}
69
+ onClick={() => setExpanded(!expanded)}
70
+ >
71
+ <SlidersHorizontal aria-hidden="true" size={14} /> Filters
72
+ {activeCount ? ` (${activeCount})` : ""}
73
+ </Button>
74
+ </div>
75
+ </div>
76
+ <div
77
+ id={controlsId}
78
+ className={cn(
79
+ "grid-cols-2 items-end gap-3 lg:grid-cols-4 xl:grid-cols-[auto_repeat(4,minmax(0,1fr))]",
80
+ expanded ? "grid" : "hidden",
81
+ )}
82
+ >
83
+ <FilterGroup
84
+ className="col-span-2 lg:col-span-4 xl:col-span-1"
85
+ label="Type"
86
+ >
87
+ {(["all", "scheduled", "event"] as const).map((value) => (
88
+ <ToggleButton
89
+ className="min-h-9"
90
+ key={value}
91
+ onClick={() => onChange("type", value)}
92
+ pressed={filters.type === value}
93
+ variant="pill"
94
+ >
95
+ {value}
96
+ </ToggleButton>
97
+ ))}
98
+ </FilterGroup>
99
+ <AutomationFilterSelect
100
+ label="Creator"
101
+ emptyLabel="All"
102
+ value={filters.creator ?? ""}
103
+ options={data?.creators ?? []}
104
+ onChange={(value) => onChange("creator", value)}
105
+ />
106
+ <AutomationFilterSelect
107
+ label="Destination"
108
+ emptyLabel="All"
109
+ value={filters.destination ?? ""}
110
+ options={data?.destinations ?? []}
111
+ onChange={(value) => onChange("destination", value)}
112
+ />
113
+ <AutomationFilterSelect
114
+ label="State"
115
+ emptyLabel="All"
116
+ value={filters.state === "all" ? "" : filters.state}
117
+ options={[
118
+ { value: "active", label: "Active" },
119
+ { value: "blocked", label: "Blocked" },
120
+ { value: "paused", label: "Paused" },
121
+ { value: "unavailable", label: "Trigger unavailable" },
122
+ { value: "completed", label: "Completed" },
123
+ ]}
124
+ onChange={(value) => onChange("state", value)}
125
+ />
126
+ <AutomationFilterSelect
127
+ label="Sort"
128
+ ariaLabel="Sort automations"
129
+ value={filters.sort}
130
+ onChange={(value) => onChange("sort", value)}
131
+ options={[
132
+ { value: "newest", label: "Newest first" },
133
+ { value: "oldest", label: "Oldest first" },
134
+ { value: "title", label: "Title" },
135
+ ]}
136
+ />
137
+ </div>
138
+ </div>
139
+ );
140
+ }
141
+
142
+ function AutomationFilterSelect(props: {
143
+ label: string;
144
+ ariaLabel?: string;
145
+ emptyLabel?: string;
146
+ value: string;
147
+ options: Array<{ value: string; label: string }>;
148
+ onChange(value: string): void;
149
+ }) {
150
+ return (
151
+ <FilterGroup label={props.label}>
152
+ <select
153
+ aria-label={props.ariaLabel ?? `Filter by ${props.label.toLowerCase()}`}
154
+ className="h-9 w-full min-w-0 rounded-lg border border-dashboard-border-emphasis bg-dashboard-control px-2 text-sm text-dashboard-text transition-colors hover:border-dashboard-border-interactive focus:border-dashboard-focus focus:outline-none focus:ring-1 focus:ring-dashboard-focus/25 [color-scheme:var(--dashboard-color-scheme,dark)]"
155
+ value={props.value}
156
+ onChange={(event) => props.onChange(event.currentTarget.value)}
157
+ >
158
+ {props.emptyLabel ? <option value="">{props.emptyLabel}</option> : null}
159
+ {props.value &&
160
+ !props.options.some((option) => option.value === props.value) ? (
161
+ <option value={props.value}>{props.value}</option>
162
+ ) : null}
163
+ {props.options.map((option) => (
164
+ <option key={option.value} value={option.value}>
165
+ {option.label}
166
+ </option>
167
+ ))}
168
+ </select>
169
+ </FilterGroup>
170
+ );
171
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /** Keep section headings and fields aligned without nested form cards. */
4
+ export function AutomationFormSection(props: {
5
+ title: string;
6
+ detail?: string;
7
+ children: ReactNode;
8
+ }) {
9
+ return (
10
+ <section className="grid min-w-0 gap-5 border-t border-dashboard-border-emphasis py-8 md:grid-cols-[12rem_minmax(0,1fr)] md:gap-10">
11
+ <div>
12
+ <h2 className="m-0 font-display text-lg font-medium">{props.title}</h2>
13
+ {props.detail ? (
14
+ <p className="mt-2 mb-0 text-sm leading-relaxed text-dashboard-text-muted">
15
+ {props.detail}
16
+ </p>
17
+ ) : null}
18
+ </div>
19
+ <div className="grid min-w-0 content-start gap-5">{props.children}</div>
20
+ </section>
21
+ );
22
+ }
@@ -0,0 +1,146 @@
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import {
3
+ automationEditSchema,
4
+ type AutomationSummary,
5
+ } from "@sentry/junior/api/schema";
6
+ import { Link } from "react-router";
7
+ import { Button, ButtonLink } from "../../components/Button";
8
+ import { StatusChip } from "../../components/StatusChip";
9
+ import { FormNotice } from "../../components/FormNotice";
10
+ import { DashboardApiError, fetchDashboardJson, post } from "../../http";
11
+
12
+ /** Explain lifecycle separately from the last execution and offer creator-only actions. */
13
+ export function AutomationLifecycle({
14
+ automation,
15
+ editPath,
16
+ }: {
17
+ automation: AutomationSummary;
18
+ editPath: string;
19
+ }) {
20
+ const queryClient = useQueryClient();
21
+ const completed = automation.status === "completed";
22
+ const paused = automation.status === "paused";
23
+ const blocked = automation.status === "blocked";
24
+ const unavailable =
25
+ automation.kind === "event" && !automation.triggerAvailable;
26
+ const canEdit = automation.ownedByViewer && !completed;
27
+ const blockReason =
28
+ automation.kind === "scheduled" ? automation.statusReason : undefined;
29
+ const action = paused || blocked ? "resume" : "pause";
30
+ const mutation = useMutation({
31
+ mutationFn: async () => {
32
+ const path = `/api/automations/${automation.kind}/${encodeURIComponent(automation.id)}`;
33
+ const current = await fetchDashboardJson(
34
+ automationEditSchema,
35
+ `${path}/edit`,
36
+ );
37
+ // Do not apply the opposite action if another surface changed lifecycle.
38
+ if (current.status !== automation.status) {
39
+ throw new Error(
40
+ "The Automation changed. Close and reopen its details before trying again.",
41
+ );
42
+ }
43
+ return post(automationEditSchema, `${path}/lifecycle`, {
44
+ action,
45
+ revision: current.revision,
46
+ });
47
+ },
48
+ onSuccess: async () => {
49
+ await queryClient.invalidateQueries({
50
+ queryKey: ["dashboard", "automations"],
51
+ });
52
+ },
53
+ });
54
+ const status =
55
+ automation.status === "active" && unavailable
56
+ ? "Trigger unavailable"
57
+ : automation.status;
58
+ const failed =
59
+ automation.lastRunStatus === "failed" ||
60
+ automation.lastRunStatus === "blocked";
61
+ return (
62
+ <div className="grid gap-3 text-sm">
63
+ <div className="flex flex-wrap items-center justify-between gap-3">
64
+ <div role="status" aria-live="polite">
65
+ <StatusChip
66
+ tone={
67
+ paused || completed
68
+ ? "neutral"
69
+ : blocked || unavailable
70
+ ? "warning"
71
+ : "success"
72
+ }
73
+ >
74
+ {status}
75
+ </StatusChip>
76
+ </div>
77
+ <div className="flex items-center gap-2">
78
+ <ButtonLink className="min-h-11" to={editPath}>
79
+ {canEdit ? "Edit automation" : "View settings"}
80
+ </ButtonLink>
81
+ {canEdit ? (
82
+ <Button
83
+ className="min-h-11"
84
+ disabled={mutation.isPending}
85
+ onClick={() => mutation.mutate()}
86
+ >
87
+ {mutation.isPending
88
+ ? "Updating…"
89
+ : action === "pause"
90
+ ? "Pause"
91
+ : "Resume"}
92
+ </Button>
93
+ ) : null}
94
+ </div>
95
+ </div>
96
+ {blocked || blockReason ? (
97
+ <p className="m-0 break-words text-dashboard-text-muted">
98
+ {blockReason ?? "Check the last run to see what needs fixing."}{" "}
99
+ {paused ? "Fix this before resuming." : "Fix this, then resume."}
100
+ </p>
101
+ ) : null}
102
+ {unavailable ? (
103
+ <p className="m-0 text-dashboard-text-muted">
104
+ This event source is unavailable. Enable its plugin or choose another
105
+ event.
106
+ </p>
107
+ ) : null}
108
+ {failed ? (
109
+ <p className="m-0 text-dashboard-text-muted">
110
+ Last run {automation.lastRunStatus}.
111
+ {automation.status === "active" && !unavailable
112
+ ? " Future runs are still on."
113
+ : ""}{" "}
114
+ <Link
115
+ className="text-dashboard-text underline underline-offset-2"
116
+ to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
117
+ >
118
+ View run history
119
+ </Link>
120
+ </p>
121
+ ) : null}
122
+ {canEdit ? (
123
+ <details className="text-dashboard-text-muted">
124
+ <summary className="w-fit cursor-pointer py-2 text-xs hover:text-dashboard-text focus-visible:outline focus-visible:outline-dashboard-focus">
125
+ About pausing
126
+ </summary>
127
+ <p className="mt-1 mb-0 leading-relaxed">
128
+ Pausing stops new runs. Work already queued or running may finish.
129
+ Resuming skips missed runs and waits for the next scheduled time or
130
+ new event.
131
+ {paused && blockReason
132
+ ? " Removing the pause keeps the block until you fix it and resume again."
133
+ : ""}
134
+ </p>
135
+ </details>
136
+ ) : null}
137
+ {mutation.error ? (
138
+ <FormNotice tone="error" title="Could not update this automation.">
139
+ {mutation.error instanceof DashboardApiError
140
+ ? (mutation.error.apiError ?? "Try again.")
141
+ : mutation.error.message}
142
+ </FormNotice>
143
+ ) : null}
144
+ </div>
145
+ );
146
+ }