@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.
- package/dist/app.js +89 -15
- package/dist/client.js +55 -52
- package/dist/index.js +89 -15
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/client/App.tsx +359 -345
- package/src/client/api.ts +17 -1
- package/src/client/components/ActorAvatar.tsx +37 -0
- package/src/client/components/Button.tsx +12 -10
- package/src/client/components/Field.tsx +14 -1
- package/src/client/components/FormNotice.tsx +40 -0
- package/src/client/components/ParticipantAvatarStack.tsx +6 -14
- package/src/client/components/README.md +11 -2
- package/src/client/components/Select.tsx +18 -0
- package/src/client/components/StatusChip.tsx +15 -12
- package/src/client/components/TextInput.tsx +25 -5
- package/src/client/http.ts +31 -3
- package/src/client/pages/automations/AutomationDetailsDrawer.tsx +71 -54
- package/src/client/pages/automations/AutomationEditPage.tsx +172 -0
- package/src/client/pages/automations/AutomationEditor.tsx +397 -0
- package/src/client/pages/automations/AutomationEditorLoading.tsx +18 -0
- package/src/client/pages/automations/AutomationEventFields.tsx +264 -0
- package/src/client/pages/automations/AutomationFilters.tsx +171 -0
- package/src/client/pages/automations/AutomationFormSection.tsx +22 -0
- package/src/client/pages/automations/AutomationLifecycle.tsx +146 -0
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +177 -0
- package/src/client/pages/automations/AutomationRow.tsx +358 -0
- package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
- package/src/client/pages/automations/AutomationsPage.tsx +157 -408
- package/src/client/pages/automations/AutomationsPageLayout.tsx +16 -5
- package/src/client/pages/automations/README.md +19 -0
- package/src/client/pages/automations/automationDraft.ts +110 -0
- package/src/client/pages/automations/automationOutcomes.ts +23 -0
- package/src/client/pages/dev/ComponentsPage.tsx +50 -4
- package/src/client/searchParams.ts +4 -2
- package/src/client.tsx +7 -4
- package/src/mock-reporting/automation-collection.ts +101 -0
- package/src/mock-reporting/fixtures.ts +9 -3
- 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
|
+
}
|