@sentry/junior-dashboard 0.266.0 → 0.267.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 +49 -12
- package/dist/client.js +94 -63
- package/dist/index.js +49 -12
- package/dist/tailwind.css +1 -1
- package/package.json +11 -5
- package/src/app.ts +0 -2
- package/src/auth.ts +4 -7
- package/src/client/App.tsx +35 -53
- package/src/client/components/DetailsPanel.tsx +12 -13
- package/src/client/components/Drawer.tsx +46 -23
- package/src/client/components/ImageAttachment.tsx +1 -1
- package/src/client/components/ParticipantAvatarStack.tsx +4 -1
- package/src/client/components/README.md +11 -2
- package/src/client/components/ui/alert.tsx +1 -1
- package/src/client/components/ui/button-group.tsx +59 -0
- package/src/client/components/ui/dialog.tsx +29 -35
- package/src/client/components/ui/drawer.tsx +140 -0
- package/src/client/components/ui/empty.tsx +30 -1
- package/src/client/components/ui/separator.tsx +22 -0
- package/src/client/components/ui/useSheet.ts +5 -1
- package/src/client/conversations/ComposerImages.tsx +66 -14
- package/src/client/conversations/ConversationComposer.tsx +84 -12
- package/src/client/conversations/ConversationDetails.tsx +3 -27
- package/src/client/conversations/ConversationEventDetails.tsx +26 -10
- package/src/client/conversations/ConversationPage.tsx +156 -2
- package/src/client/conversations/ConversationTranscript.tsx +48 -16
- package/src/client/conversations/DetailsList.tsx +33 -0
- package/src/client/conversations/EventDetails.tsx +46 -19
- package/src/client/conversations/ForkConversationButton.tsx +79 -73
- package/src/client/conversations/PendingMailboxStack.tsx +310 -112
- package/src/client/conversations/README.md +30 -13
- package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
- package/src/client/conversations/TranscriptActivityGroup.tsx +22 -19
- package/src/client/conversations/TranscriptAttachment.tsx +39 -4
- package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
- package/src/client/conversations/TranscriptFailureView.tsx +10 -13
- package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
- package/src/client/conversations/TranscriptMessageView.tsx +7 -1
- package/src/client/conversations/TranscriptSummary.tsx +5 -31
- package/src/client/conversations/TranscriptTurnContextView.tsx +33 -18
- package/src/client/conversations/TranscriptView.tsx +7 -3
- package/src/client/conversations/UserInputRequestCard.tsx +244 -0
- package/src/client/conversations/conversationOutbox.ts +46 -0
- package/src/client/conversations/eventTime.ts +13 -1
- package/src/client/conversations/eventTranscript.ts +12 -5
- package/src/client/conversations/queries.ts +127 -26
- package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
- package/src/client/conversations/userInputRequest.ts +56 -0
- package/src/client/format.ts +10 -3
- package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
- package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
- package/src/client/pages/automations/AutomationEditor.tsx +182 -156
- package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
- package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
- package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
- package/src/client/pages/automations/AutomationOverview.tsx +238 -0
- package/src/client/pages/automations/AutomationPage.tsx +343 -0
- package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
- package/src/client/pages/automations/AutomationRow.tsx +11 -13
- package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
- package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
- package/src/client/pages/automations/AutomationStatus.tsx +164 -108
- package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
- package/src/client/pages/automations/AutomationsPage.tsx +10 -98
- package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
- package/src/client/pages/automations/README.md +56 -46
- package/src/client/types.ts +3 -0
- package/src/mock-reporting/fixtures.ts +44 -2
- package/src/client/conversations/transcriptStyles.ts +0 -10
- package/src/client/pages/automations/AutomationDetails.tsx +0 -193
- package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
- package/src/client/pages/automations/AutomationVersionsPage.tsx +0 -390
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version history for one Automation. The list selects a version, and the
|
|
3
|
+
* compare view shows what it changed from the version before it. Making a
|
|
4
|
+
* version active saves it again as a new version.
|
|
5
|
+
*/
|
|
6
|
+
import { useState, type ReactNode } from "react";
|
|
7
|
+
import { useSearchParams } from "react-router";
|
|
8
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
9
|
+
import {
|
|
10
|
+
automationEditSchema,
|
|
11
|
+
type AutomationSummary,
|
|
12
|
+
type AutomationVersionList,
|
|
13
|
+
type ListedAutomationVersion,
|
|
14
|
+
} from "@sentry/junior/api/schema";
|
|
15
|
+
|
|
16
|
+
import { useAutomationVersionsData } from "../../api";
|
|
17
|
+
import {
|
|
18
|
+
AlertDialog,
|
|
19
|
+
AlertDialogAction,
|
|
20
|
+
AlertDialogCancel,
|
|
21
|
+
AlertDialogContent,
|
|
22
|
+
AlertDialogDescription,
|
|
23
|
+
AlertDialogFooter,
|
|
24
|
+
AlertDialogTitle,
|
|
25
|
+
} from "../../components/ui/alert-dialog";
|
|
26
|
+
import { Button } from "../../components/ui/button";
|
|
27
|
+
import { FormNotice } from "../../components/FormNotice";
|
|
28
|
+
import { InlineError } from "../../components/InlineError";
|
|
29
|
+
import { PageContentSkeleton } from "../../components/PageContentSkeleton";
|
|
30
|
+
import { StatusChip } from "../../components/StatusChip";
|
|
31
|
+
import { Card } from "../../components/layout/Card";
|
|
32
|
+
import { formatRelativeTime } from "../../format";
|
|
33
|
+
import { DashboardApiError, fetchDashboardJson, post } from "../../http";
|
|
34
|
+
import { cn } from "../../styles";
|
|
35
|
+
import { formatAutomationRunTime } from "./automationFormat";
|
|
36
|
+
import { automationOutcomeLabel } from "./automationOutcomes";
|
|
37
|
+
import { TriggerFilters } from "./TriggerFilters";
|
|
38
|
+
|
|
39
|
+
/** Search param that selects the version to compare. */
|
|
40
|
+
export const AUTOMATION_VERSION_PARAM = "version";
|
|
41
|
+
|
|
42
|
+
/** Load one Automation's versions and compare the selected one. */
|
|
43
|
+
export function AutomationVersionsTab(props: {
|
|
44
|
+
automation: AutomationSummary;
|
|
45
|
+
}) {
|
|
46
|
+
const versions = useAutomationVersionsData(true, props.automation.id);
|
|
47
|
+
if (versions.error) {
|
|
48
|
+
return (
|
|
49
|
+
<InlineError>
|
|
50
|
+
Version history could not be loaded.{" "}
|
|
51
|
+
<Button onClick={() => void versions.refetch()}>Try again</Button>
|
|
52
|
+
</InlineError>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (!versions.data) {
|
|
56
|
+
return (
|
|
57
|
+
<PageContentSkeleton label="Loading version history" variant="list" />
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return (
|
|
61
|
+
<AutomationVersionsView
|
|
62
|
+
automation={props.automation}
|
|
63
|
+
data={versions.data}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function AutomationVersionsView(props: {
|
|
69
|
+
automation: AutomationSummary;
|
|
70
|
+
data: AutomationVersionList;
|
|
71
|
+
}) {
|
|
72
|
+
const { automation, data } = props;
|
|
73
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
74
|
+
const queryClient = useQueryClient();
|
|
75
|
+
const [activated, setActivated] = useState<number>();
|
|
76
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
77
|
+
const canActivate = automation.status !== "completed";
|
|
78
|
+
const requested = Number(searchParams.get(AUTOMATION_VERSION_PARAM));
|
|
79
|
+
const selectedIndex = Math.max(
|
|
80
|
+
0,
|
|
81
|
+
data.versions.findIndex((version) => version.version === requested),
|
|
82
|
+
);
|
|
83
|
+
const selected = data.versions[selectedIndex];
|
|
84
|
+
const previous = data.versions[selectedIndex + 1];
|
|
85
|
+
const select = (version: number) =>
|
|
86
|
+
setSearchParams(
|
|
87
|
+
(current) => {
|
|
88
|
+
const next = new URLSearchParams(current);
|
|
89
|
+
next.set(AUTOMATION_VERSION_PARAM, String(version));
|
|
90
|
+
return next;
|
|
91
|
+
},
|
|
92
|
+
{ replace: true },
|
|
93
|
+
);
|
|
94
|
+
const path = `/api/automations/${encodeURIComponent(automation.id)}`;
|
|
95
|
+
const activate = useMutation({
|
|
96
|
+
mutationFn: async (version: number) => {
|
|
97
|
+
// The user chose an exact saved version, so the latest revision is safe here.
|
|
98
|
+
const current = await fetchDashboardJson(
|
|
99
|
+
automationEditSchema,
|
|
100
|
+
`${path}/edit`,
|
|
101
|
+
);
|
|
102
|
+
return post(
|
|
103
|
+
automationEditSchema,
|
|
104
|
+
`${path}/versions/${version}/activate`,
|
|
105
|
+
{ revision: current.revision },
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
onMutate: () => setActivated(undefined),
|
|
109
|
+
onSuccess: async (_, version) => {
|
|
110
|
+
setActivated(version);
|
|
111
|
+
await queryClient.invalidateQueries({
|
|
112
|
+
queryKey: ["dashboard", "automations"],
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
if (!selected) {
|
|
117
|
+
return (
|
|
118
|
+
<Card padding="md">
|
|
119
|
+
<p className="m-0 text-sm text-dashboard-text-muted">
|
|
120
|
+
No versions are saved yet. Junior saves a version each time the
|
|
121
|
+
settings change.
|
|
122
|
+
</p>
|
|
123
|
+
</Card>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const active = selected.version === data.activeVersion;
|
|
127
|
+
return (
|
|
128
|
+
<>
|
|
129
|
+
<p className="m-0 max-w-2xl text-sm leading-relaxed text-dashboard-text-muted">
|
|
130
|
+
{canActivate
|
|
131
|
+
? "Each save creates a version. Making a version active saves its settings as a new version. It does not run the automation."
|
|
132
|
+
: "This automation has completed. Its versions are read-only."}
|
|
133
|
+
</p>
|
|
134
|
+
{activated !== undefined ? (
|
|
135
|
+
<p role="status" className="m-0 text-sm text-emerald-300">
|
|
136
|
+
Version {activated} is active again. It was saved as a new version.
|
|
137
|
+
</p>
|
|
138
|
+
) : null}
|
|
139
|
+
<div className="grid min-w-0 gap-4 lg:grid-cols-[17rem_minmax(0,1fr)] lg:gap-6">
|
|
140
|
+
<nav aria-label="Versions" className="min-w-0">
|
|
141
|
+
<ol className="m-0 grid list-none gap-1 p-0 max-lg:max-h-64 max-lg:overflow-y-auto">
|
|
142
|
+
{data.versions.map((version, index) => (
|
|
143
|
+
<li key={version.version}>
|
|
144
|
+
<VersionListItem
|
|
145
|
+
active={version.version === data.activeVersion}
|
|
146
|
+
changes={versionChangeText(
|
|
147
|
+
version,
|
|
148
|
+
data.versions[index + 1],
|
|
149
|
+
data.truncated,
|
|
150
|
+
)}
|
|
151
|
+
onSelect={() => select(version.version)}
|
|
152
|
+
selected={version.version === selected.version}
|
|
153
|
+
version={version}
|
|
154
|
+
/>
|
|
155
|
+
</li>
|
|
156
|
+
))}
|
|
157
|
+
</ol>
|
|
158
|
+
{data.truncated ? (
|
|
159
|
+
<p className="m-0 mt-2 px-3 text-xs text-dashboard-text-muted">
|
|
160
|
+
Showing the 100 most recent versions.
|
|
161
|
+
</p>
|
|
162
|
+
) : null}
|
|
163
|
+
</nav>
|
|
164
|
+
<Card as="section" className="min-w-0" padding="none" variant="default">
|
|
165
|
+
<header className="flex flex-wrap items-start justify-between gap-3 border-b border-dashboard-border-subtle px-4 py-4 sm:px-5">
|
|
166
|
+
<div className="min-w-0">
|
|
167
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
168
|
+
<h2 className="m-0 font-display text-lg font-medium">
|
|
169
|
+
Version {selected.version}
|
|
170
|
+
</h2>
|
|
171
|
+
{active ? (
|
|
172
|
+
<StatusChip size="compact" tone="success">
|
|
173
|
+
Active
|
|
174
|
+
</StatusChip>
|
|
175
|
+
) : null}
|
|
176
|
+
</div>
|
|
177
|
+
<p className="mt-1 mb-0 text-xs text-dashboard-text-muted">
|
|
178
|
+
{versionEditor(selected)} ·{" "}
|
|
179
|
+
{formatAutomationRunTime(selected.createdAt)}
|
|
180
|
+
{previous ? ` · Compared with version ${previous.version}` : ""}
|
|
181
|
+
</p>
|
|
182
|
+
</div>
|
|
183
|
+
{canActivate && !active ? (
|
|
184
|
+
<Button
|
|
185
|
+
aria-label={`Make version ${selected.version} active`}
|
|
186
|
+
disabled={activate.isPending}
|
|
187
|
+
onClick={() => setConfirmOpen(true)}
|
|
188
|
+
>
|
|
189
|
+
{activate.isPending && activate.variables === selected.version
|
|
190
|
+
? "Saving…"
|
|
191
|
+
: "Make active"}
|
|
192
|
+
</Button>
|
|
193
|
+
) : null}
|
|
194
|
+
</header>
|
|
195
|
+
{activate.error && activate.variables === selected.version ? (
|
|
196
|
+
<div className="px-4 pt-4 sm:px-5">
|
|
197
|
+
<FormNotice
|
|
198
|
+
tone="error"
|
|
199
|
+
title="This version could not be made active."
|
|
200
|
+
>
|
|
201
|
+
{activate.error instanceof DashboardApiError
|
|
202
|
+
? activate.error.code === "conflict"
|
|
203
|
+
? "The automation changed. Try again."
|
|
204
|
+
: (activate.error.apiError ?? "Try again.")
|
|
205
|
+
: "Try again."}
|
|
206
|
+
</FormNotice>
|
|
207
|
+
</div>
|
|
208
|
+
) : null}
|
|
209
|
+
<VersionCompare
|
|
210
|
+
automation={automation}
|
|
211
|
+
previous={previous}
|
|
212
|
+
version={selected}
|
|
213
|
+
/>
|
|
214
|
+
</Card>
|
|
215
|
+
</div>
|
|
216
|
+
<AlertDialog onOpenChange={setConfirmOpen} open={confirmOpen}>
|
|
217
|
+
<AlertDialogContent>
|
|
218
|
+
<AlertDialogTitle>
|
|
219
|
+
Make version {selected.version} active?
|
|
220
|
+
</AlertDialogTitle>
|
|
221
|
+
<AlertDialogDescription>
|
|
222
|
+
Junior saves these settings as a new version. Future runs use them.
|
|
223
|
+
{automation.ownedByViewer
|
|
224
|
+
? null
|
|
225
|
+
: ` Only ${automation.createdBy} can turn on their connected accounts or change where results go.`}
|
|
226
|
+
</AlertDialogDescription>
|
|
227
|
+
<AlertDialogFooter>
|
|
228
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
229
|
+
<AlertDialogAction
|
|
230
|
+
onClick={() => {
|
|
231
|
+
activate.mutate(selected.version);
|
|
232
|
+
setConfirmOpen(false);
|
|
233
|
+
}}
|
|
234
|
+
variant="primary"
|
|
235
|
+
>
|
|
236
|
+
Make active
|
|
237
|
+
</AlertDialogAction>
|
|
238
|
+
</AlertDialogFooter>
|
|
239
|
+
</AlertDialogContent>
|
|
240
|
+
</AlertDialog>
|
|
241
|
+
</>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function VersionListItem(props: {
|
|
246
|
+
active: boolean;
|
|
247
|
+
changes: string | undefined;
|
|
248
|
+
onSelect(): void;
|
|
249
|
+
selected: boolean;
|
|
250
|
+
version: ListedAutomationVersion;
|
|
251
|
+
}) {
|
|
252
|
+
const { version } = props;
|
|
253
|
+
return (
|
|
254
|
+
<button
|
|
255
|
+
aria-current={props.selected ? "true" : undefined}
|
|
256
|
+
className={cn(
|
|
257
|
+
"grid w-full min-w-0 cursor-pointer gap-0.5 rounded-md border-0 px-3 py-2.5 text-left transition-colors focus-visible:outline-2 focus-visible:outline-dashboard-focus",
|
|
258
|
+
props.selected
|
|
259
|
+
? "bg-dashboard-accent/[0.08] text-dashboard-text"
|
|
260
|
+
: "bg-transparent text-dashboard-text hover:bg-dashboard-fill-soft",
|
|
261
|
+
)}
|
|
262
|
+
onClick={props.onSelect}
|
|
263
|
+
type="button"
|
|
264
|
+
>
|
|
265
|
+
<span className="flex items-center gap-2 text-sm font-medium">
|
|
266
|
+
Version {version.version}
|
|
267
|
+
{props.active ? (
|
|
268
|
+
<StatusChip size="compact" tone="success">
|
|
269
|
+
Active
|
|
270
|
+
</StatusChip>
|
|
271
|
+
) : null}
|
|
272
|
+
</span>
|
|
273
|
+
<span className="truncate text-xs text-dashboard-text-muted">
|
|
274
|
+
{versionEditor(version)} ·{" "}
|
|
275
|
+
<time dateTime={version.createdAt}>
|
|
276
|
+
{formatRelativeTime(version.createdAt)}
|
|
277
|
+
</time>
|
|
278
|
+
</span>
|
|
279
|
+
{props.changes ? (
|
|
280
|
+
<span className="truncate text-xs text-dashboard-text-muted">
|
|
281
|
+
{props.changes}
|
|
282
|
+
</span>
|
|
283
|
+
) : null}
|
|
284
|
+
</button>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const VERSION_FIELDS = [
|
|
289
|
+
{ key: "title", label: "Title" },
|
|
290
|
+
{ key: "instruction", label: "What to do" },
|
|
291
|
+
{ key: "when", label: "When to run" },
|
|
292
|
+
{ key: "destination", label: "Destination" },
|
|
293
|
+
{ key: "outcomes", label: "Where results go" },
|
|
294
|
+
{ key: "credentialMode", label: "Credentials" },
|
|
295
|
+
] as const;
|
|
296
|
+
|
|
297
|
+
type VersionField = (typeof VERSION_FIELDS)[number]["key"];
|
|
298
|
+
|
|
299
|
+
/** Show every saved field. Changed fields show the previous value too. */
|
|
300
|
+
function VersionCompare(props: {
|
|
301
|
+
automation: AutomationSummary;
|
|
302
|
+
previous: ListedAutomationVersion | undefined;
|
|
303
|
+
version: ListedAutomationVersion;
|
|
304
|
+
}) {
|
|
305
|
+
const { automation, previous, version } = props;
|
|
306
|
+
const changed = previous ? changedFields(previous, version) : [];
|
|
307
|
+
return (
|
|
308
|
+
<dl className="m-0 divide-y divide-dashboard-border-subtle">
|
|
309
|
+
{VERSION_FIELDS.map(({ key, label }) => {
|
|
310
|
+
const isChanged = changed.includes(key);
|
|
311
|
+
return (
|
|
312
|
+
<div
|
|
313
|
+
className={cn(
|
|
314
|
+
"grid min-w-0 gap-2 px-4 py-4 sm:grid-cols-[9rem_minmax(0,1fr)] sm:gap-6 sm:px-5",
|
|
315
|
+
isChanged &&
|
|
316
|
+
"shadow-[inset_2px_0_0_var(--color-dashboard-accent)]",
|
|
317
|
+
)}
|
|
318
|
+
key={key}
|
|
319
|
+
>
|
|
320
|
+
<dt className="text-xs font-medium text-dashboard-text-muted">
|
|
321
|
+
{label}
|
|
322
|
+
{isChanged ? (
|
|
323
|
+
<span className="mt-1 block font-normal text-dashboard-accent-text">
|
|
324
|
+
Changed
|
|
325
|
+
</span>
|
|
326
|
+
) : null}
|
|
327
|
+
</dt>
|
|
328
|
+
<dd className="m-0 min-w-0 text-sm leading-relaxed break-words">
|
|
329
|
+
{isChanged && previous && key === "instruction" ? (
|
|
330
|
+
<LineDiff
|
|
331
|
+
after={version.definition.instruction}
|
|
332
|
+
before={previous.definition.instruction}
|
|
333
|
+
/>
|
|
334
|
+
) : isChanged && previous ? (
|
|
335
|
+
<div className="grid gap-2">
|
|
336
|
+
<div className="text-dashboard-text-muted line-through decoration-dashboard-text-muted/50">
|
|
337
|
+
<span className="sr-only">Before: </span>
|
|
338
|
+
{fieldValue(key, automation, previous)}
|
|
339
|
+
</div>
|
|
340
|
+
<div>
|
|
341
|
+
<span className="sr-only">After: </span>
|
|
342
|
+
{fieldValue(key, automation, version)}
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
) : (
|
|
346
|
+
fieldValue(key, automation, version)
|
|
347
|
+
)}
|
|
348
|
+
</dd>
|
|
349
|
+
</div>
|
|
350
|
+
);
|
|
351
|
+
})}
|
|
352
|
+
</dl>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function fieldValue(
|
|
357
|
+
key: VersionField,
|
|
358
|
+
automation: AutomationSummary,
|
|
359
|
+
version: ListedAutomationVersion,
|
|
360
|
+
): ReactNode {
|
|
361
|
+
const { definition } = version;
|
|
362
|
+
switch (key) {
|
|
363
|
+
case "title":
|
|
364
|
+
return definition.title ?? "First line of the instruction";
|
|
365
|
+
case "instruction":
|
|
366
|
+
return (
|
|
367
|
+
<span className="whitespace-pre-wrap">{definition.instruction}</span>
|
|
368
|
+
);
|
|
369
|
+
case "when":
|
|
370
|
+
if (version.kind === "scheduled") {
|
|
371
|
+
const { description, timezone } = version.definition.schedule;
|
|
372
|
+
return description.includes(timezone)
|
|
373
|
+
? description
|
|
374
|
+
: `${description} (${timezone})`;
|
|
375
|
+
}
|
|
376
|
+
return (
|
|
377
|
+
<>
|
|
378
|
+
{version.definition.trigger.label}
|
|
379
|
+
{version.definition.trigger.identifier
|
|
380
|
+
? ` · ${version.definition.trigger.identifier}`
|
|
381
|
+
: null}
|
|
382
|
+
<br />
|
|
383
|
+
{version.trigger.events.join(", ")}
|
|
384
|
+
<TriggerFilters filters={version.trigger.filters} />
|
|
385
|
+
</>
|
|
386
|
+
);
|
|
387
|
+
case "destination":
|
|
388
|
+
return definition.destination.channelId ===
|
|
389
|
+
automation.destination.channelId &&
|
|
390
|
+
definition.destination.teamId === automation.destination.teamId
|
|
391
|
+
? automation.destination.label
|
|
392
|
+
: `Another destination · ${definition.destination.channelId}`;
|
|
393
|
+
case "outcomes":
|
|
394
|
+
return definition.outcomes.length ? (
|
|
395
|
+
<ol className="m-0 grid list-decimal gap-1 pl-5">
|
|
396
|
+
{definition.outcomes.map((outcome, index) => (
|
|
397
|
+
<li key={index}>
|
|
398
|
+
{automationOutcomeLabel(outcome, automation.destination)}
|
|
399
|
+
</li>
|
|
400
|
+
))}
|
|
401
|
+
</ol>
|
|
402
|
+
) : (
|
|
403
|
+
"No message on success"
|
|
404
|
+
);
|
|
405
|
+
case "credentialMode":
|
|
406
|
+
return definition.credentialMode === "creator"
|
|
407
|
+
? `Uses accounts connected by ${automation.ownedByViewer ? "you" : automation.createdBy}.`
|
|
408
|
+
: "System credentials only";
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Show removed and added instruction lines in place. */
|
|
413
|
+
function LineDiff(props: { after: string; before: string }) {
|
|
414
|
+
return (
|
|
415
|
+
<div className="grid overflow-hidden rounded-md border border-dashboard-border-subtle font-mono text-xs leading-relaxed">
|
|
416
|
+
{diffLines(props.before, props.after).map((line, index) => (
|
|
417
|
+
<div
|
|
418
|
+
className={cn(
|
|
419
|
+
"grid grid-cols-[1.5rem_minmax(0,1fr)] whitespace-pre-wrap px-2 py-0.5",
|
|
420
|
+
line.kind === "added" && "bg-emerald-500/10 text-emerald-200",
|
|
421
|
+
line.kind === "removed" && "bg-rose-500/10 text-rose-200",
|
|
422
|
+
)}
|
|
423
|
+
key={index}
|
|
424
|
+
>
|
|
425
|
+
<span aria-hidden="true" className="select-none opacity-60">
|
|
426
|
+
{line.kind === "added" ? "+" : line.kind === "removed" ? "−" : ""}
|
|
427
|
+
</span>
|
|
428
|
+
<span>
|
|
429
|
+
{line.kind === "same" ? null : (
|
|
430
|
+
<span className="sr-only">
|
|
431
|
+
{line.kind === "added" ? "Added: " : "Removed: "}
|
|
432
|
+
</span>
|
|
433
|
+
)}
|
|
434
|
+
{line.text || " "}
|
|
435
|
+
</span>
|
|
436
|
+
</div>
|
|
437
|
+
))}
|
|
438
|
+
</div>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
type DiffLine = { kind: "added" | "removed" | "same"; text: string };
|
|
443
|
+
|
|
444
|
+
/** Line diff from the longest common subsequence. Instructions stay short. */
|
|
445
|
+
function diffLines(before: string, after: string): DiffLine[] {
|
|
446
|
+
const a = before.split("\n");
|
|
447
|
+
const b = after.split("\n");
|
|
448
|
+
const lengths = Array.from({ length: a.length + 1 }, () =>
|
|
449
|
+
Array.from({ length: b.length + 1 }, () => 0),
|
|
450
|
+
);
|
|
451
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
452
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
453
|
+
lengths[i]![j] =
|
|
454
|
+
a[i] === b[j]
|
|
455
|
+
? lengths[i + 1]![j + 1]! + 1
|
|
456
|
+
: Math.max(lengths[i + 1]![j]!, lengths[i]![j + 1]!);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const lines: DiffLine[] = [];
|
|
460
|
+
let i = 0;
|
|
461
|
+
let j = 0;
|
|
462
|
+
while (i < a.length && j < b.length) {
|
|
463
|
+
if (a[i] === b[j]) {
|
|
464
|
+
lines.push({ kind: "same", text: a[i]! });
|
|
465
|
+
i++;
|
|
466
|
+
j++;
|
|
467
|
+
} else if (lengths[i + 1]![j]! >= lengths[i]![j + 1]!) {
|
|
468
|
+
lines.push({ kind: "removed", text: a[i++]! });
|
|
469
|
+
} else {
|
|
470
|
+
lines.push({ kind: "added", text: b[j++]! });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
while (i < a.length) lines.push({ kind: "removed", text: a[i++]! });
|
|
474
|
+
while (j < b.length) lines.push({ kind: "added", text: b[j++]! });
|
|
475
|
+
return lines;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function versionEditor(version: ListedAutomationVersion): string {
|
|
479
|
+
return version.editedBy
|
|
480
|
+
? (version.editedBy.fullName ??
|
|
481
|
+
version.editedBy.userName ??
|
|
482
|
+
version.editedBy.slackUserId)
|
|
483
|
+
: "Editor unknown";
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function versionChangeText(
|
|
487
|
+
version: ListedAutomationVersion,
|
|
488
|
+
previous: ListedAutomationVersion | undefined,
|
|
489
|
+
truncated: boolean,
|
|
490
|
+
): string | undefined {
|
|
491
|
+
if (!previous) {
|
|
492
|
+
return version.version === 1 || !truncated
|
|
493
|
+
? "First saved version"
|
|
494
|
+
: undefined;
|
|
495
|
+
}
|
|
496
|
+
const changes = changedFields(previous, version);
|
|
497
|
+
if (changes.length === 0) return "No setting changes";
|
|
498
|
+
const labels = VERSION_FIELDS.filter(({ key }) => changes.includes(key)).map(
|
|
499
|
+
({ label }) => label.toLowerCase(),
|
|
500
|
+
);
|
|
501
|
+
return `Changed ${labels.join(", ")}`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Name the fields that differ from the previous version. */
|
|
505
|
+
function changedFields(
|
|
506
|
+
previous: ListedAutomationVersion,
|
|
507
|
+
version: ListedAutomationVersion,
|
|
508
|
+
): VersionField[] {
|
|
509
|
+
const before: Record<string, unknown> = previous.definition;
|
|
510
|
+
const after: Record<string, unknown> = version.definition;
|
|
511
|
+
const differs = (key: string) =>
|
|
512
|
+
JSON.stringify(before[key]) !== JSON.stringify(after[key]);
|
|
513
|
+
return VERSION_FIELDS.flatMap(({ key }) =>
|
|
514
|
+
(key === "when" ? differs("schedule") || differs("trigger") : differs(key))
|
|
515
|
+
? [key]
|
|
516
|
+
: [],
|
|
517
|
+
);
|
|
518
|
+
}
|
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
import { useEffect
|
|
2
|
-
import {
|
|
3
|
-
useLocation,
|
|
4
|
-
useNavigate,
|
|
5
|
-
useParams,
|
|
6
|
-
useSearchParams,
|
|
7
|
-
} from "react-router";
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
|
8
3
|
import {
|
|
9
4
|
automationListQuerySchema,
|
|
10
5
|
type AutomationSummary,
|
|
11
6
|
} from "@sentry/junior/api/schema";
|
|
12
7
|
import { Globe2, ListChecks, LockKeyhole, UserRound } from "lucide-react";
|
|
13
|
-
import { useAutomationsData
|
|
8
|
+
import { useAutomationsData } from "../../api";
|
|
14
9
|
import { Button } from "../../components/ui/button";
|
|
15
10
|
import { AutomationFilters, AutomationSort } from "./AutomationFilters";
|
|
16
11
|
import { InlineError } from "../../components/InlineError";
|
|
@@ -31,12 +26,8 @@ import {
|
|
|
31
26
|
useDebouncedSearchParam,
|
|
32
27
|
useSearchParamEnum,
|
|
33
28
|
} from "../../searchParams";
|
|
34
|
-
import { AutomationDeleteDialog } from "./AutomationDeleteDialog";
|
|
35
29
|
import { AutomationRow } from "./AutomationRow";
|
|
36
30
|
import { AutomationCostChart } from "./AutomationCostChart";
|
|
37
|
-
import { AutomationDetails } from "./AutomationDetails";
|
|
38
|
-
import { PageLayout } from "../../components/layout/PageLayout";
|
|
39
|
-
import { cn } from "../../styles";
|
|
40
31
|
import { AutomationExecutionChart } from "./AutomationExecutionChart";
|
|
41
32
|
|
|
42
33
|
const AUTOMATION_RANGE_OPTIONS = ["1", "7", "30", "90"] as const;
|
|
@@ -55,12 +46,8 @@ export function AutomationsPage(props: {
|
|
|
55
46
|
enabled: boolean;
|
|
56
47
|
view: "list" | "overview";
|
|
57
48
|
}) {
|
|
58
|
-
const [deleteTarget, setDeleteTarget] = useState<AutomationSummary>();
|
|
59
|
-
const deleteSource = useRef<HTMLButtonElement | null>(null);
|
|
60
|
-
const listRef = useRef<HTMLDivElement>(null);
|
|
61
49
|
const navigate = useNavigate();
|
|
62
50
|
const location = useLocation();
|
|
63
|
-
const { automationId } = useParams();
|
|
64
51
|
const [rangeParam, setRangeParam] = useSearchParamEnum(
|
|
65
52
|
"range",
|
|
66
53
|
"30",
|
|
@@ -95,7 +82,6 @@ export function AutomationsPage(props: {
|
|
|
95
82
|
props.enabled,
|
|
96
83
|
props.view === "list" ? request.toString() : "",
|
|
97
84
|
);
|
|
98
|
-
const detail = useAutomationData(props.enabled, automationId);
|
|
99
85
|
const setFilter = (key: string, value: string) => {
|
|
100
86
|
// Consecutive control events can precede the router's next render.
|
|
101
87
|
const next = new URLSearchParams(window.location.search);
|
|
@@ -111,10 +97,9 @@ export function AutomationsPage(props: {
|
|
|
111
97
|
else next.set("page", String(page));
|
|
112
98
|
return next;
|
|
113
99
|
});
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
pathWithSearch(
|
|
117
|
-
const selectedTaskPath = (id: string) => tasksPath(automationPath(id));
|
|
100
|
+
// The Automation page keeps the list query, so its back link restores it.
|
|
101
|
+
const automationPagePath = (id: string) =>
|
|
102
|
+
pathWithSearch(automationPath(id), location.search);
|
|
118
103
|
const automations = query.data?.automations ?? EMPTY_TASKS;
|
|
119
104
|
const mineCount = query.data?.counts.mine ?? 0;
|
|
120
105
|
const publicCount = query.data?.counts.public ?? 0;
|
|
@@ -122,22 +107,8 @@ export function AutomationsPage(props: {
|
|
|
122
107
|
const visibleTaskCount = query.data?.total ?? 0;
|
|
123
108
|
const totalPages = pageCount(visibleTaskCount, filters.pageSize);
|
|
124
109
|
const page = query.data?.page ?? filters.page;
|
|
125
|
-
const selectedTask = detail.data;
|
|
126
|
-
const closeDetails = () => {
|
|
127
|
-
const trigger = automationId
|
|
128
|
-
? listRef.current?.querySelector<HTMLButtonElement>(
|
|
129
|
-
`[data-automation-id="${CSS.escape(automationId)}"] button`,
|
|
130
|
-
)
|
|
131
|
-
: undefined;
|
|
132
|
-
navigate(tasksPath(listPath));
|
|
133
|
-
(trigger ?? listRef.current)?.focus();
|
|
134
|
-
};
|
|
135
110
|
// Overview names the tab through PageHeader; the list has a plain heading.
|
|
136
|
-
useDocumentTitle(
|
|
137
|
-
props.view === "list"
|
|
138
|
-
? (selectedTask?.title ?? "All automations")
|
|
139
|
-
: undefined,
|
|
140
|
-
);
|
|
111
|
+
useDocumentTitle(props.view === "list" ? "All automations" : undefined);
|
|
141
112
|
// A deletion or a shared URL can leave the requested page past the last page.
|
|
142
113
|
useEffect(() => {
|
|
143
114
|
if (
|
|
@@ -182,7 +153,7 @@ export function AutomationsPage(props: {
|
|
|
182
153
|
: [];
|
|
183
154
|
const showExecutionCharts = executionSeries.length > 0;
|
|
184
155
|
|
|
185
|
-
|
|
156
|
+
return (
|
|
186
157
|
<>
|
|
187
158
|
{props.view === "overview" ? (
|
|
188
159
|
<PageHeader
|
|
@@ -291,15 +262,9 @@ export function AutomationsPage(props: {
|
|
|
291
262
|
return (
|
|
292
263
|
<AutomationRow
|
|
293
264
|
key={key}
|
|
294
|
-
onSelect={() =>
|
|
295
|
-
navigate(
|
|
296
|
-
automationId === automation.id
|
|
297
|
-
? tasksPath(listPath)
|
|
298
|
-
: selectedTaskPath(automation.id),
|
|
299
|
-
)
|
|
300
|
-
}
|
|
301
|
-
selected={automationId === automation.id}
|
|
265
|
+
onSelect={() => navigate(automationPagePath(automation.id))}
|
|
302
266
|
automation={automation}
|
|
267
|
+
to={automationPagePath(automation.id)}
|
|
303
268
|
/>
|
|
304
269
|
);
|
|
305
270
|
})}
|
|
@@ -316,57 +281,4 @@ export function AutomationsPage(props: {
|
|
|
316
281
|
) : null}
|
|
317
282
|
</>
|
|
318
283
|
);
|
|
319
|
-
if (props.view === "overview") return content;
|
|
320
|
-
|
|
321
|
-
return (
|
|
322
|
-
<div
|
|
323
|
-
className={cn(
|
|
324
|
-
"grid h-full min-h-0 min-w-0",
|
|
325
|
-
automationId && "xl:grid-cols-[minmax(0,1fr)_25rem]",
|
|
326
|
-
)}
|
|
327
|
-
>
|
|
328
|
-
<div
|
|
329
|
-
aria-label="Automations list"
|
|
330
|
-
className="min-h-0 overflow-y-auto overscroll-contain outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus"
|
|
331
|
-
ref={listRef}
|
|
332
|
-
role="region"
|
|
333
|
-
tabIndex={0}
|
|
334
|
-
>
|
|
335
|
-
<PageLayout>{content}</PageLayout>
|
|
336
|
-
</div>
|
|
337
|
-
{automationId ? (
|
|
338
|
-
<AutomationDetails
|
|
339
|
-
key={automationId}
|
|
340
|
-
onClose={closeDetails}
|
|
341
|
-
onDelete={(source) => {
|
|
342
|
-
if (!selectedTask?.ownedByViewer) return;
|
|
343
|
-
deleteSource.current = source;
|
|
344
|
-
setDeleteTarget(selectedTask);
|
|
345
|
-
}}
|
|
346
|
-
onRetry={() => void detail.refetch()}
|
|
347
|
-
error={Boolean(detail.error)}
|
|
348
|
-
range={range}
|
|
349
|
-
automation={selectedTask}
|
|
350
|
-
/>
|
|
351
|
-
) : null}
|
|
352
|
-
{deleteTarget ? (
|
|
353
|
-
<AutomationDeleteDialog
|
|
354
|
-
automation={deleteTarget}
|
|
355
|
-
onClose={() => setDeleteTarget(undefined)}
|
|
356
|
-
onDeleted={() => {
|
|
357
|
-
if (automationId !== deleteTarget.id) return;
|
|
358
|
-
// The details unmount after the route change, which can finish
|
|
359
|
-
// after the dialog closes. Do not return focus to their button.
|
|
360
|
-
deleteSource.current = null;
|
|
361
|
-
navigate(tasksPath(listPath));
|
|
362
|
-
}}
|
|
363
|
-
returnFocus={() =>
|
|
364
|
-
deleteSource.current?.isConnected
|
|
365
|
-
? deleteSource.current
|
|
366
|
-
: (listRef.current ?? false)
|
|
367
|
-
}
|
|
368
|
-
/>
|
|
369
|
-
) : null}
|
|
370
|
-
</div>
|
|
371
|
-
);
|
|
372
284
|
}
|