@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.
Files changed (53) hide show
  1. package/dist/app.js +100 -18
  2. package/dist/client.js +47 -46
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +100 -18
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +3 -3
  7. package/src/app.ts +7 -1
  8. package/src/assets/junior-avatar.png +0 -0
  9. package/src/assets/junior-favicon.png +0 -0
  10. package/src/assets.ts +1 -0
  11. package/src/client/App.tsx +35 -0
  12. package/src/client/api.ts +63 -0
  13. package/src/client/components/README.md +8 -4
  14. package/src/client/components/SegmentedNav.tsx +167 -0
  15. package/src/client/components/SegmentedTabs.tsx +24 -5
  16. package/src/client/components/layout/SecondaryNavigation.tsx +40 -83
  17. package/src/client/conversations/ConversationEventLog.tsx +45 -5
  18. package/src/client/conversations/ConversationForkLinks.tsx +51 -0
  19. package/src/client/conversations/ConversationHeader.tsx +12 -1
  20. package/src/client/conversations/ConversationPage.tsx +72 -2
  21. package/src/client/conversations/ForkConversationButton.tsx +125 -0
  22. package/src/client/conversations/TranscriptMessageView.tsx +28 -7
  23. package/src/client/conversations/TranscriptView.tsx +4 -0
  24. package/src/client/conversations/conversationRoutes.ts +10 -0
  25. package/src/client/conversations/queries.ts +45 -0
  26. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +28 -27
  27. package/src/client/pages/automations/AutomationEditPage.tsx +23 -24
  28. package/src/client/pages/automations/AutomationEditor.tsx +66 -13
  29. package/src/client/pages/automations/AutomationFilters.tsx +22 -14
  30. package/src/client/pages/automations/AutomationLifecycle.tsx +6 -4
  31. package/src/client/pages/automations/AutomationOutcomeFields.tsx +18 -0
  32. package/src/client/pages/automations/AutomationRow.tsx +172 -187
  33. package/src/client/pages/automations/AutomationVersionsPage.tsx +356 -0
  34. package/src/client/pages/automations/AutomationsPage.tsx +40 -42
  35. package/src/client/pages/automations/AutomationsPageLayout.tsx +6 -5
  36. package/src/client/pages/automations/README.md +14 -3
  37. package/src/client/pages/automations/automationFormat.ts +43 -0
  38. package/src/client/pages/code/CodeChangeRow.tsx +41 -0
  39. package/src/client/pages/code/CodePage.tsx +18 -93
  40. package/src/client/pages/code/CodeSummaryCards.tsx +53 -0
  41. package/src/client/pages/code/RepositoryPage.tsx +227 -0
  42. package/src/client/pages/code/RepositorySections.tsx +108 -0
  43. package/src/client/pages/code/RepositoryTabs.tsx +146 -0
  44. package/src/client/pages/code/codeRepositoryRoutes.ts +18 -0
  45. package/src/client/pages/dev/ComponentsPage.tsx +5 -22
  46. package/src/client/pages/dev/SegmentedFixtures.tsx +58 -0
  47. package/src/client/pages/memory/MemoryPageLayout.tsx +12 -13
  48. package/src/client/pages/system/SystemNavigation.tsx +6 -2
  49. package/src/client/pages/system/SystemPageLayout.tsx +4 -6
  50. package/src/mock-reporting/code-repository.ts +77 -0
  51. package/src/mock-reporting/fixtures.ts +8 -2
  52. package/src/mock-reporting/routes.ts +25 -8
  53. 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, AutomationListHeader } from "./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-baseline justify-between gap-x-5 gap-y-1">
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">Updating results…</span>
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
- <Card className="overflow-visible">
296
- <AutomationListHeader />
297
- <div
298
- className="divide-y divide-dashboard-border-subtle"
299
- role="list"
300
- >
301
- {automations.map((automation) => {
302
- const key = `${automation.kind}:${automation.id}`;
303
- return (
304
- <AutomationRow
305
- deleting={
306
- deletion.isPending &&
307
- deletion.variables?.id === automation.id
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
- selected={automationId === automation.id}
327
- automation={automation}
328
- />
329
- );
330
- })}
331
- </div>
332
- </Card>
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, creator access, revision checks, Schedule previews, and
4
- the Event catalog. Preview requests cannot save or run work. Completed scheduled
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
+ }