@sentry/junior-dashboard 0.234.0 → 0.236.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 +100 -18
- package/dist/client.js +47 -46
- package/dist/index.d.ts +3 -1
- package/dist/index.js +100 -18
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/app.ts +7 -1
- package/src/assets/junior-avatar.png +0 -0
- package/src/assets/junior-favicon.png +0 -0
- package/src/assets.ts +1 -0
- package/src/client/App.tsx +35 -0
- package/src/client/api.ts +63 -0
- package/src/client/components/README.md +8 -4
- package/src/client/components/SegmentedNav.tsx +167 -0
- package/src/client/components/SegmentedTabs.tsx +24 -5
- package/src/client/components/layout/SecondaryNavigation.tsx +40 -83
- package/src/client/conversations/ConversationEventLog.tsx +45 -5
- package/src/client/conversations/ConversationForkLinks.tsx +51 -0
- package/src/client/conversations/ConversationHeader.tsx +12 -1
- package/src/client/conversations/ConversationPage.tsx +72 -2
- package/src/client/conversations/ForkConversationButton.tsx +125 -0
- package/src/client/conversations/TranscriptMessageView.tsx +28 -7
- package/src/client/conversations/TranscriptView.tsx +4 -0
- package/src/client/conversations/conversationRoutes.ts +10 -0
- package/src/client/conversations/queries.ts +45 -0
- package/src/client/pages/automations/AutomationDetailsDrawer.tsx +28 -27
- package/src/client/pages/automations/AutomationEditPage.tsx +23 -24
- package/src/client/pages/automations/AutomationEditor.tsx +66 -13
- package/src/client/pages/automations/AutomationFilters.tsx +22 -14
- package/src/client/pages/automations/AutomationLifecycle.tsx +6 -4
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +18 -0
- package/src/client/pages/automations/AutomationRow.tsx +172 -187
- package/src/client/pages/automations/AutomationVersionsPage.tsx +356 -0
- package/src/client/pages/automations/AutomationsPage.tsx +40 -42
- package/src/client/pages/automations/AutomationsPageLayout.tsx +6 -5
- package/src/client/pages/automations/README.md +14 -3
- package/src/client/pages/automations/automationFormat.ts +43 -0
- package/src/client/pages/code/CodeChangeRow.tsx +41 -0
- package/src/client/pages/code/CodePage.tsx +18 -93
- package/src/client/pages/code/CodeSummaryCards.tsx +53 -0
- package/src/client/pages/code/RepositoryPage.tsx +227 -0
- package/src/client/pages/code/RepositorySections.tsx +108 -0
- package/src/client/pages/code/RepositoryTabs.tsx +146 -0
- package/src/client/pages/code/codeRepositoryRoutes.ts +18 -0
- package/src/client/pages/dev/ComponentsPage.tsx +5 -22
- package/src/client/pages/dev/SegmentedFixtures.tsx +58 -0
- package/src/client/pages/memory/MemoryPageLayout.tsx +12 -13
- package/src/client/pages/system/SystemNavigation.tsx +6 -2
- package/src/client/pages/system/SystemPageLayout.tsx +4 -6
- package/src/mock-reporting/code-repository.ts +77 -0
- package/src/mock-reporting/fixtures.ts +8 -2
- package/src/mock-reporting/routes.ts +25 -8
- package/src/shell.ts +21 -5
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/** Version history for one Automation. Making a version active saves it again. */
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { ArrowLeft } from "lucide-react";
|
|
4
|
+
import { Link, Navigate, useParams, useSearchParams } from "react-router";
|
|
5
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
6
|
+
import {
|
|
7
|
+
automationEditSchema,
|
|
8
|
+
type AutomationSummary,
|
|
9
|
+
type AutomationVersion,
|
|
10
|
+
type AutomationVersionList,
|
|
11
|
+
} from "@sentry/junior/api/schema";
|
|
12
|
+
import { useAutomationData, useAutomationVersionsData } from "../../api";
|
|
13
|
+
import { Button } from "../../components/Button";
|
|
14
|
+
import { Detail, DetailList } from "../../components/DetailList";
|
|
15
|
+
import { FormNotice } from "../../components/FormNotice";
|
|
16
|
+
import { InlineError } from "../../components/InlineError";
|
|
17
|
+
import { PageContentSkeleton } from "../../components/PageContentSkeleton";
|
|
18
|
+
import { StatusChip } from "../../components/StatusChip";
|
|
19
|
+
import { Card } from "../../components/layout/Card";
|
|
20
|
+
import { PageHeader } from "../../components/layout/PageHeader";
|
|
21
|
+
import { automationPath } from "../../format";
|
|
22
|
+
import { DashboardApiError, fetchDashboardJson, post } from "../../http";
|
|
23
|
+
import { pathWithSearch } from "../../searchParams";
|
|
24
|
+
import { formatAutomationRunTime } from "./automationFormat";
|
|
25
|
+
import { automationOutcomeLabel } from "./automationOutcomes";
|
|
26
|
+
|
|
27
|
+
const DESCRIPTION = "Saved settings for this automation, newest first.";
|
|
28
|
+
|
|
29
|
+
/** Load one Automation's versions with its summary for labels and access. */
|
|
30
|
+
export function AutomationVersionsPage(props: { enabled: boolean }) {
|
|
31
|
+
const { automationId, kind } = useParams();
|
|
32
|
+
const [searchParams] = useSearchParams();
|
|
33
|
+
const taskKind = kind === "scheduled" || kind === "event" ? kind : undefined;
|
|
34
|
+
const enabled = props.enabled && Boolean(automationId && taskKind);
|
|
35
|
+
const summary = useAutomationData(enabled, automationId);
|
|
36
|
+
const versions = useAutomationVersionsData(enabled, taskKind, automationId);
|
|
37
|
+
if (!automationId || !taskKind) {
|
|
38
|
+
return <Navigate replace to="/automations" />;
|
|
39
|
+
}
|
|
40
|
+
const backTo = pathWithSearch(automationPath(automationId), searchParams);
|
|
41
|
+
const error = summary.error ?? versions.error;
|
|
42
|
+
const notFound =
|
|
43
|
+
(error instanceof DashboardApiError && error.status === 404) ||
|
|
44
|
+
(summary.data && summary.data.kind !== taskKind);
|
|
45
|
+
if (error || notFound) {
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
<PageHeader description={DESCRIPTION} title="Version history" />
|
|
49
|
+
<Card padding="md">
|
|
50
|
+
<InlineError>
|
|
51
|
+
{notFound
|
|
52
|
+
? "This automation was not found or is not visible to you."
|
|
53
|
+
: "Version history could not be loaded. Try again."}
|
|
54
|
+
</InlineError>
|
|
55
|
+
<BackLink to={backTo} />
|
|
56
|
+
</Card>
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (!summary.data || !versions.data) {
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<PageHeader description={DESCRIPTION} title="Version history" />
|
|
64
|
+
<PageContentSkeleton label="Loading version history" variant="list" />
|
|
65
|
+
</>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return (
|
|
69
|
+
<AutomationVersionsView
|
|
70
|
+
automation={summary.data}
|
|
71
|
+
backTo={backTo}
|
|
72
|
+
data={versions.data}
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function BackLink(props: { to: string }) {
|
|
78
|
+
return (
|
|
79
|
+
<Link
|
|
80
|
+
className="mb-3 inline-flex items-center gap-2 font-mono text-xs text-dashboard-text-muted no-underline hover:text-dashboard-text"
|
|
81
|
+
to={props.to}
|
|
82
|
+
>
|
|
83
|
+
<ArrowLeft aria-hidden="true" size={14} />
|
|
84
|
+
Back to automation
|
|
85
|
+
</Link>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function AutomationVersionsView(props: {
|
|
90
|
+
automation: AutomationSummary;
|
|
91
|
+
backTo: string;
|
|
92
|
+
data: AutomationVersionList;
|
|
93
|
+
}) {
|
|
94
|
+
const { automation, data } = props;
|
|
95
|
+
const queryClient = useQueryClient();
|
|
96
|
+
const [activated, setActivated] = useState<number>();
|
|
97
|
+
const canActivate = !(
|
|
98
|
+
automation.kind === "scheduled" && automation.status === "completed"
|
|
99
|
+
);
|
|
100
|
+
const path = `/api/automations/${automation.kind}/${encodeURIComponent(automation.id)}`;
|
|
101
|
+
const activate = useMutation({
|
|
102
|
+
mutationFn: async (version: number) => {
|
|
103
|
+
// The user chose an exact saved version, so the latest revision is safe here.
|
|
104
|
+
const current = await fetchDashboardJson(
|
|
105
|
+
automationEditSchema,
|
|
106
|
+
`${path}/edit`,
|
|
107
|
+
);
|
|
108
|
+
return post(
|
|
109
|
+
automationEditSchema,
|
|
110
|
+
`${path}/versions/${version}/activate`,
|
|
111
|
+
{ revision: current.revision },
|
|
112
|
+
);
|
|
113
|
+
},
|
|
114
|
+
onMutate: () => setActivated(undefined),
|
|
115
|
+
onSuccess: async (_, version) => {
|
|
116
|
+
setActivated(version);
|
|
117
|
+
await queryClient.invalidateQueries({
|
|
118
|
+
queryKey: ["dashboard", "automations"],
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
<div>
|
|
125
|
+
<BackLink to={props.backTo} />
|
|
126
|
+
<PageHeader
|
|
127
|
+
description={`${automation.kind} automation · ${automation.destination.label} · ${data.versions.length} ${data.versions.length === 1 ? "version" : "versions"}`}
|
|
128
|
+
title={automation.title}
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
<p className="m-0 max-w-2xl text-sm leading-relaxed text-dashboard-text-muted">
|
|
132
|
+
{canActivate
|
|
133
|
+
? "Making a version active saves its settings as a new version. Earlier versions stay in this list. It does not run the automation."
|
|
134
|
+
: "This automation has completed. Its versions are read-only."}
|
|
135
|
+
</p>
|
|
136
|
+
{activated !== undefined ? (
|
|
137
|
+
<p role="status" className="m-0 text-sm text-emerald-300">
|
|
138
|
+
Version {activated} is active again. It was saved as a new version.
|
|
139
|
+
</p>
|
|
140
|
+
) : null}
|
|
141
|
+
{data.versions.length === 0 ? (
|
|
142
|
+
<Card padding="md">
|
|
143
|
+
<p className="m-0 text-sm text-dashboard-text-muted">
|
|
144
|
+
No versions are saved yet. Junior saves a version each time the
|
|
145
|
+
settings change.
|
|
146
|
+
</p>
|
|
147
|
+
</Card>
|
|
148
|
+
) : (
|
|
149
|
+
<Card>
|
|
150
|
+
<ol className="m-0 list-none divide-y divide-dashboard-border-subtle p-0">
|
|
151
|
+
{data.versions.map((version, index) => (
|
|
152
|
+
<VersionRow
|
|
153
|
+
key={version.version}
|
|
154
|
+
active={version.version === data.activeVersion}
|
|
155
|
+
automation={automation}
|
|
156
|
+
canActivate={canActivate}
|
|
157
|
+
error={
|
|
158
|
+
activate.variables === version.version ? activate.error : null
|
|
159
|
+
}
|
|
160
|
+
onActivate={() => {
|
|
161
|
+
const creatorOnly = automation.ownedByViewer
|
|
162
|
+
? ""
|
|
163
|
+
: ` Only ${automation.createdBy} can turn on their connected accounts or change where results go.`;
|
|
164
|
+
if (
|
|
165
|
+
window.confirm(
|
|
166
|
+
`Make version ${version.version} active? Junior saves these settings as a new version. Future runs use them.${creatorOnly}`,
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
activate.mutate(version.version);
|
|
170
|
+
}}
|
|
171
|
+
pending={
|
|
172
|
+
activate.isPending && activate.variables === version.version
|
|
173
|
+
}
|
|
174
|
+
previous={data.versions[index + 1]}
|
|
175
|
+
truncated={data.truncated}
|
|
176
|
+
version={version}
|
|
177
|
+
/>
|
|
178
|
+
))}
|
|
179
|
+
</ol>
|
|
180
|
+
</Card>
|
|
181
|
+
)}
|
|
182
|
+
{data.truncated ? (
|
|
183
|
+
<p className="m-0 text-center text-xs text-dashboard-text-muted">
|
|
184
|
+
Showing the 100 most recent versions.
|
|
185
|
+
</p>
|
|
186
|
+
) : null}
|
|
187
|
+
</>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function VersionRow(props: {
|
|
192
|
+
active: boolean;
|
|
193
|
+
automation: AutomationSummary;
|
|
194
|
+
canActivate: boolean;
|
|
195
|
+
error: Error | null;
|
|
196
|
+
onActivate(): void;
|
|
197
|
+
pending: boolean;
|
|
198
|
+
previous: AutomationVersion | undefined;
|
|
199
|
+
truncated: boolean;
|
|
200
|
+
version: AutomationVersion;
|
|
201
|
+
}) {
|
|
202
|
+
const { automation, version } = props;
|
|
203
|
+
const editor = version.editedBy
|
|
204
|
+
? (version.editedBy.fullName ??
|
|
205
|
+
version.editedBy.userName ??
|
|
206
|
+
version.editedBy.slackUserId)
|
|
207
|
+
: "Editor unknown";
|
|
208
|
+
const changes = props.previous
|
|
209
|
+
? changedFields(props.previous, version)
|
|
210
|
+
: undefined;
|
|
211
|
+
const changeText = changes
|
|
212
|
+
? changes.length
|
|
213
|
+
? `Changed ${changes.join(", ")}`
|
|
214
|
+
: "No setting changes"
|
|
215
|
+
: version.version === 1 || !props.truncated
|
|
216
|
+
? "First saved version"
|
|
217
|
+
: undefined;
|
|
218
|
+
const titleId = `automation-version-${version.version}`;
|
|
219
|
+
return (
|
|
220
|
+
<li aria-labelledby={titleId} className="grid gap-3 px-4 py-4 sm:px-5">
|
|
221
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
222
|
+
<div className="min-w-0">
|
|
223
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
224
|
+
<h3 id={titleId} className="m-0 text-sm font-medium">
|
|
225
|
+
Version {version.version}
|
|
226
|
+
</h3>
|
|
227
|
+
{props.active ? (
|
|
228
|
+
<StatusChip size="compact" tone="success">
|
|
229
|
+
Active
|
|
230
|
+
</StatusChip>
|
|
231
|
+
) : null}
|
|
232
|
+
</div>
|
|
233
|
+
<p className="mt-1 mb-0 text-xs text-dashboard-text-muted">
|
|
234
|
+
{editor} · {formatAutomationRunTime(version.createdAt)}
|
|
235
|
+
</p>
|
|
236
|
+
{changeText ? (
|
|
237
|
+
<p className="mt-1 mb-0 text-xs text-dashboard-text-muted">
|
|
238
|
+
{changeText}
|
|
239
|
+
</p>
|
|
240
|
+
) : null}
|
|
241
|
+
</div>
|
|
242
|
+
{props.canActivate && !props.active ? (
|
|
243
|
+
<Button
|
|
244
|
+
aria-label={`Make version ${version.version} active`}
|
|
245
|
+
disabled={props.pending}
|
|
246
|
+
onClick={props.onActivate}
|
|
247
|
+
>
|
|
248
|
+
{props.pending ? "Saving…" : "Make active"}
|
|
249
|
+
</Button>
|
|
250
|
+
) : null}
|
|
251
|
+
</div>
|
|
252
|
+
{props.error ? (
|
|
253
|
+
<FormNotice tone="error" title="This version could not be made active.">
|
|
254
|
+
{props.error instanceof DashboardApiError
|
|
255
|
+
? props.error.code === "conflict"
|
|
256
|
+
? "The automation changed. Try again."
|
|
257
|
+
: (props.error.apiError ?? "Try again.")
|
|
258
|
+
: "Try again."}
|
|
259
|
+
</FormNotice>
|
|
260
|
+
) : null}
|
|
261
|
+
<details>
|
|
262
|
+
<summary className="w-fit cursor-pointer py-1 text-xs text-dashboard-text-muted hover:text-dashboard-text focus-visible:outline focus-visible:outline-dashboard-focus">
|
|
263
|
+
Show settings
|
|
264
|
+
</summary>
|
|
265
|
+
<VersionSettings automation={automation} version={version} />
|
|
266
|
+
</details>
|
|
267
|
+
</li>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function VersionSettings(props: {
|
|
272
|
+
automation: AutomationSummary;
|
|
273
|
+
version: AutomationVersion;
|
|
274
|
+
}) {
|
|
275
|
+
const { automation, version } = props;
|
|
276
|
+
const { definition } = version;
|
|
277
|
+
const destination =
|
|
278
|
+
definition.destination.channelId === automation.destination.channelId &&
|
|
279
|
+
definition.destination.teamId === automation.destination.teamId
|
|
280
|
+
? automation.destination.label
|
|
281
|
+
: `Another destination · ${definition.destination.channelId}`;
|
|
282
|
+
return (
|
|
283
|
+
<DetailList className="mt-2">
|
|
284
|
+
<Detail label="Title">
|
|
285
|
+
{definition.title ?? "First line of the instruction"}
|
|
286
|
+
</Detail>
|
|
287
|
+
<Detail label="What to do" valueClassName="whitespace-pre-wrap">
|
|
288
|
+
{definition.instruction}
|
|
289
|
+
</Detail>
|
|
290
|
+
<Detail label="When to run">
|
|
291
|
+
{version.kind === "scheduled" ? (
|
|
292
|
+
version.definition.schedule.description.includes(
|
|
293
|
+
version.definition.schedule.timezone,
|
|
294
|
+
) ? (
|
|
295
|
+
version.definition.schedule.description
|
|
296
|
+
) : (
|
|
297
|
+
`${version.definition.schedule.description} (${version.definition.schedule.timezone})`
|
|
298
|
+
)
|
|
299
|
+
) : (
|
|
300
|
+
<>
|
|
301
|
+
{version.definition.trigger.label} ·{" "}
|
|
302
|
+
{version.definition.trigger.identifier}
|
|
303
|
+
<br />
|
|
304
|
+
{version.definition.trigger.events.join(", ")}
|
|
305
|
+
{Object.keys(version.definition.trigger.match ?? {}).length ? (
|
|
306
|
+
<pre className="mt-2 mb-0 whitespace-pre-wrap break-words text-xs">
|
|
307
|
+
{JSON.stringify(version.definition.trigger.match, null, 2)}
|
|
308
|
+
</pre>
|
|
309
|
+
) : null}
|
|
310
|
+
</>
|
|
311
|
+
)}
|
|
312
|
+
</Detail>
|
|
313
|
+
<Detail label="Destination">{destination}</Detail>
|
|
314
|
+
<Detail label="On success">
|
|
315
|
+
{definition.outcomes.length ? (
|
|
316
|
+
<ol className="m-0 grid gap-1 p-0">
|
|
317
|
+
{definition.outcomes.map((outcome, index) => (
|
|
318
|
+
<li key={index}>
|
|
319
|
+
{automationOutcomeLabel(outcome, automation.destination)}
|
|
320
|
+
</li>
|
|
321
|
+
))}
|
|
322
|
+
</ol>
|
|
323
|
+
) : (
|
|
324
|
+
"No message on success"
|
|
325
|
+
)}
|
|
326
|
+
</Detail>
|
|
327
|
+
<Detail label="Connected accounts">
|
|
328
|
+
{definition.credentialMode === "creator"
|
|
329
|
+
? `Uses accounts connected by ${automation.ownedByViewer ? "you" : automation.createdBy}.`
|
|
330
|
+
: "System credentials only"}
|
|
331
|
+
</Detail>
|
|
332
|
+
</DetailList>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Name the settings that differ from the previous version. */
|
|
337
|
+
function changedFields(
|
|
338
|
+
previous: AutomationVersion,
|
|
339
|
+
version: AutomationVersion,
|
|
340
|
+
): string[] {
|
|
341
|
+
const before: Record<string, unknown> = previous.definition;
|
|
342
|
+
const after: Record<string, unknown> = version.definition;
|
|
343
|
+
return (
|
|
344
|
+
[
|
|
345
|
+
["title", "title"],
|
|
346
|
+
["instruction", "instruction"],
|
|
347
|
+
["schedule", "schedule"],
|
|
348
|
+
["trigger", "trigger"],
|
|
349
|
+
["destination", "destination"],
|
|
350
|
+
["outcomes", "success messages"],
|
|
351
|
+
["credentialMode", "connected accounts"],
|
|
352
|
+
] as const
|
|
353
|
+
).flatMap(([key, label]) =>
|
|
354
|
+
JSON.stringify(before[key]) === JSON.stringify(after[key]) ? [] : [label],
|
|
355
|
+
);
|
|
356
|
+
}
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { Globe2, ListChecks, LockKeyhole, UserRound } from "lucide-react";
|
|
14
14
|
import { useAutomationsData, useAutomationData } from "../../api";
|
|
15
15
|
import { Button } from "../../components/Button";
|
|
16
|
-
import { AutomationFilters } from "./AutomationFilters";
|
|
16
|
+
import { AutomationFilters, AutomationSort } from "./AutomationFilters";
|
|
17
17
|
import { InlineError } from "../../components/InlineError";
|
|
18
18
|
import { PageContentSkeleton } from "../../components/PageContentSkeleton";
|
|
19
19
|
import { pageCount, PagePagination } from "../../components/Pagination";
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
useDebouncedSearchParam,
|
|
33
33
|
useSearchParamEnum,
|
|
34
34
|
} from "../../searchParams";
|
|
35
|
-
import { AutomationRow
|
|
35
|
+
import { AutomationRow } from "./AutomationRow";
|
|
36
36
|
import { AutomationCostChart } from "./AutomationCostChart";
|
|
37
37
|
import { AutomationDetailsDrawer } from "./AutomationDetailsDrawer";
|
|
38
38
|
import { AutomationExecutionChart } from "./AutomationExecutionChart";
|
|
@@ -274,16 +274,20 @@ export function AutomationsPage(props: {
|
|
|
274
274
|
) : null}
|
|
275
275
|
{query.data && props.view === "list" ? (
|
|
276
276
|
<>
|
|
277
|
-
<div className="flex flex-wrap items-
|
|
277
|
+
<div className="flex flex-wrap items-center justify-between gap-x-5 gap-y-2">
|
|
278
278
|
<p className="m-0 text-xs text-dashboard-text-muted">
|
|
279
279
|
{visibleTaskCount}{" "}
|
|
280
280
|
{visibleTaskCount === 1 ? "automation" : "automations"}
|
|
281
|
-
</p>
|
|
282
|
-
<p className="m-0 text-xs text-dashboard-text-muted">
|
|
283
281
|
{query.isFetching ? (
|
|
284
|
-
<span role="status">
|
|
282
|
+
<span className="ml-3" role="status">
|
|
283
|
+
Updating results…
|
|
284
|
+
</span>
|
|
285
285
|
) : null}
|
|
286
286
|
</p>
|
|
287
|
+
<AutomationSort
|
|
288
|
+
value={filters.sort}
|
|
289
|
+
onChange={(value) => setFilter("sort", value)}
|
|
290
|
+
/>
|
|
287
291
|
</div>
|
|
288
292
|
{!query.error && visibleTaskCount === 0 ? (
|
|
289
293
|
<Card padding="md">
|
|
@@ -292,44 +296,38 @@ export function AutomationsPage(props: {
|
|
|
292
296
|
</p>
|
|
293
297
|
</Card>
|
|
294
298
|
) : (
|
|
295
|
-
<
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
key={key}
|
|
310
|
-
onDelete={() => {
|
|
311
|
-
if (
|
|
312
|
-
window.confirm(
|
|
313
|
-
`Delete this ${automation.kind} automation?`,
|
|
314
|
-
)
|
|
315
|
-
) {
|
|
316
|
-
deletion.mutate(automation);
|
|
317
|
-
}
|
|
318
|
-
}}
|
|
319
|
-
onSelect={() =>
|
|
320
|
-
navigate(
|
|
321
|
-
automationId === automation.id
|
|
322
|
-
? tasksPath(listPath)
|
|
323
|
-
: selectedTaskPath(automation.id),
|
|
299
|
+
<div className="grid grid-cols-[minmax(0,1fr)] gap-2" role="list">
|
|
300
|
+
{automations.map((automation) => {
|
|
301
|
+
const key = `${automation.kind}:${automation.id}`;
|
|
302
|
+
return (
|
|
303
|
+
<AutomationRow
|
|
304
|
+
deleting={
|
|
305
|
+
deletion.isPending &&
|
|
306
|
+
deletion.variables?.id === automation.id
|
|
307
|
+
}
|
|
308
|
+
key={key}
|
|
309
|
+
onDelete={() => {
|
|
310
|
+
if (
|
|
311
|
+
window.confirm(
|
|
312
|
+
`Delete this ${automation.kind} automation?`,
|
|
324
313
|
)
|
|
314
|
+
) {
|
|
315
|
+
deletion.mutate(automation);
|
|
325
316
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
317
|
+
}}
|
|
318
|
+
onSelect={() =>
|
|
319
|
+
navigate(
|
|
320
|
+
automationId === automation.id
|
|
321
|
+
? tasksPath(listPath)
|
|
322
|
+
: selectedTaskPath(automation.id),
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
selected={automationId === automation.id}
|
|
326
|
+
automation={automation}
|
|
327
|
+
/>
|
|
328
|
+
);
|
|
329
|
+
})}
|
|
330
|
+
</div>
|
|
333
331
|
)}
|
|
334
332
|
<PagePagination
|
|
335
333
|
onPageChange={setPage}
|
|
@@ -27,22 +27,23 @@ const taskNavigationItems = [
|
|
|
27
27
|
];
|
|
28
28
|
|
|
29
29
|
/** Keep Automation navigation consistent across lists, details, and editing. */
|
|
30
|
-
export function AutomationsNavigation() {
|
|
30
|
+
export function AutomationsNavigation(props: { children: ReactNode }) {
|
|
31
31
|
return (
|
|
32
32
|
<SecondaryNavigation
|
|
33
33
|
ariaLabel="Automations navigation"
|
|
34
34
|
items={taskNavigationItems}
|
|
35
|
-
|
|
35
|
+
>
|
|
36
|
+
{props.children}
|
|
37
|
+
</SecondaryNavigation>
|
|
36
38
|
);
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/** Place the shared secondary navigation above one Automations page. */
|
|
40
42
|
export function AutomationsPageLayout(props: { children: ReactNode }) {
|
|
41
43
|
return (
|
|
42
|
-
|
|
43
|
-
<AutomationsNavigation />
|
|
44
|
+
<AutomationsNavigation>
|
|
44
45
|
<PageLayout>{props.children}</PageLayout>
|
|
45
|
-
|
|
46
|
+
</AutomationsNavigation>
|
|
46
47
|
);
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
# Automation editor
|
|
2
2
|
|
|
3
|
-
Core owns validation,
|
|
4
|
-
the Event catalog. Preview requests cannot save or run
|
|
5
|
-
Automations remain read-only.
|
|
3
|
+
Core owns validation, edit access, creator-only rules, revision checks,
|
|
4
|
+
Schedule previews, and the Event catalog. Preview requests cannot save or run
|
|
5
|
+
work. Completed scheduled Automations remain read-only.
|
|
6
|
+
|
|
7
|
+
Owners and public Destination readers can edit. For a public reader, the
|
|
8
|
+
editor shows outcomes as read-only and blocks turning on creator credential
|
|
9
|
+
use. It warns that an instruction or event selector change switches to system
|
|
10
|
+
credentials. Pause and resume stay creator-only.
|
|
6
11
|
|
|
7
12
|
The editor sends only changed fields. Keep unsupported triggers and stored
|
|
8
13
|
Destinations intact unless the user replaces them. Outcomes keep their order.
|
|
@@ -15,5 +20,11 @@ come from that version; core checks its revision again on save.
|
|
|
15
20
|
The data router blocks navigation with unsaved changes. Browser unload uses the
|
|
16
21
|
native prompt. Save and Cancel keep the list query string.
|
|
17
22
|
|
|
23
|
+
Version history lists saved versions, newest first, and marks the active
|
|
24
|
+
version. "Make active" uses the native confirm dialog, like delete. It then
|
|
25
|
+
reads the latest revision and asks core to save that version again as a new
|
|
26
|
+
version. Core applies the same edit rules, so the page
|
|
27
|
+
shows its error when a public reader cannot make a version active.
|
|
28
|
+
|
|
18
29
|
Browser behavior lives in `e2e/automation-editor.spec.ts`. API and persistence
|
|
19
30
|
coverage lives in core's `tests/integration/api/automation-edits.test.ts`.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AutomationSummary } from "@sentry/junior/api/schema";
|
|
2
|
+
import { formatTime } from "../../format";
|
|
3
|
+
|
|
4
|
+
type ScheduledAutomation = Extract<AutomationSummary, { kind: "scheduled" }>;
|
|
5
|
+
type EventAutomation = Extract<AutomationSummary, { kind: "event" }>;
|
|
6
|
+
|
|
7
|
+
/** Show the schedule with its timezone unless the schedule text already names it. */
|
|
8
|
+
export function automationScheduleLabel(
|
|
9
|
+
automation: ScheduledAutomation,
|
|
10
|
+
): string {
|
|
11
|
+
return automation.schedule.includes(automation.timezone)
|
|
12
|
+
? automation.schedule
|
|
13
|
+
: `${automation.schedule} (${automation.timezone})`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Name event triggers in readable words. */
|
|
17
|
+
export function automationEventNames(automation: EventAutomation): string {
|
|
18
|
+
return automation.events
|
|
19
|
+
.map((event) => event.replaceAll(/[._]/g, " "))
|
|
20
|
+
.join(", ");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Summarize event match conditions as one readable line. */
|
|
24
|
+
export function automationEventConditions(automation: EventAutomation): string {
|
|
25
|
+
return Object.entries(automation.match ?? {})
|
|
26
|
+
.map(
|
|
27
|
+
([field, value]) =>
|
|
28
|
+
`${field}: ${Array.isArray(value) ? value.join(" or ") : String(value)}`,
|
|
29
|
+
)
|
|
30
|
+
.join(" · ");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Format a run time with date, time, and zone for details and tooltips. */
|
|
34
|
+
export function formatAutomationRunTime(value: string): string {
|
|
35
|
+
return formatTime(value, {
|
|
36
|
+
day: "numeric",
|
|
37
|
+
hour: "numeric",
|
|
38
|
+
minute: "2-digit",
|
|
39
|
+
month: "short",
|
|
40
|
+
timeZoneName: "short",
|
|
41
|
+
year: "numeric",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { CodeChangeSummaryReport } from "@sentry/junior/api/schema";
|
|
2
|
+
|
|
3
|
+
import { StatusChip } from "../../components/StatusChip";
|
|
4
|
+
|
|
5
|
+
function stateTone(state: CodeChangeSummaryReport["state"]) {
|
|
6
|
+
if (state === "merged") return "success" as const;
|
|
7
|
+
if (state === "open") return "info" as const;
|
|
8
|
+
return "neutral" as const;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Show one code change with its title, repository reference, and state. */
|
|
12
|
+
export function CodeChangeRow(props: { change: CodeChangeSummaryReport }) {
|
|
13
|
+
const change = props.change;
|
|
14
|
+
const title = change.title ?? `${change.repository} #${change.number}`;
|
|
15
|
+
return (
|
|
16
|
+
<div className="flex min-w-0 items-center justify-between gap-4 border-b border-dashboard-border-subtle px-4 py-3 last:border-b-0">
|
|
17
|
+
<div className="min-w-0">
|
|
18
|
+
<div className="truncate font-display text-sm text-dashboard-text">
|
|
19
|
+
{change.url ? (
|
|
20
|
+
<a
|
|
21
|
+
className="text-inherit no-underline hover:text-cyan-100"
|
|
22
|
+
href={change.url}
|
|
23
|
+
rel="noreferrer"
|
|
24
|
+
target="_blank"
|
|
25
|
+
>
|
|
26
|
+
{title}
|
|
27
|
+
</a>
|
|
28
|
+
) : (
|
|
29
|
+
title
|
|
30
|
+
)}
|
|
31
|
+
</div>
|
|
32
|
+
<div className="mt-1 truncate font-mono text-xs text-dashboard-text-muted">
|
|
33
|
+
{change.repository} #{change.number}
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
<StatusChip size="compact" tone={stateTone(change.state)}>
|
|
37
|
+
{change.state}
|
|
38
|
+
</StatusChip>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|