@sentry/junior-dashboard 0.234.0 → 0.235.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 (37) hide show
  1. package/dist/app.js +91 -16
  2. package/dist/client.js +43 -43
  3. package/dist/index.js +91 -16
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +3 -3
  6. package/src/assets/junior-avatar.png +0 -0
  7. package/src/assets/junior-favicon.png +0 -0
  8. package/src/assets.ts +1 -0
  9. package/src/client/App.tsx +13 -0
  10. package/src/client/api.ts +43 -0
  11. package/src/client/components/README.md +8 -4
  12. package/src/client/components/SegmentedNav.tsx +167 -0
  13. package/src/client/components/SegmentedTabs.tsx +24 -5
  14. package/src/client/components/layout/SecondaryNavigation.tsx +40 -83
  15. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +14 -21
  16. package/src/client/pages/automations/AutomationEditPage.tsx +3 -4
  17. package/src/client/pages/automations/AutomationEditor.tsx +1 -1
  18. package/src/client/pages/automations/AutomationFilters.tsx +22 -14
  19. package/src/client/pages/automations/AutomationRow.tsx +172 -187
  20. package/src/client/pages/automations/AutomationsPage.tsx +40 -42
  21. package/src/client/pages/automations/AutomationsPageLayout.tsx +6 -5
  22. package/src/client/pages/automations/automationFormat.ts +43 -0
  23. package/src/client/pages/code/CodeChangeRow.tsx +41 -0
  24. package/src/client/pages/code/CodePage.tsx +18 -93
  25. package/src/client/pages/code/CodeSummaryCards.tsx +53 -0
  26. package/src/client/pages/code/RepositoryPage.tsx +227 -0
  27. package/src/client/pages/code/RepositorySections.tsx +108 -0
  28. package/src/client/pages/code/RepositoryTabs.tsx +146 -0
  29. package/src/client/pages/code/codeRepositoryRoutes.ts +18 -0
  30. package/src/client/pages/dev/ComponentsPage.tsx +5 -22
  31. package/src/client/pages/dev/SegmentedFixtures.tsx +58 -0
  32. package/src/client/pages/memory/MemoryPageLayout.tsx +12 -13
  33. package/src/client/pages/system/SystemNavigation.tsx +6 -2
  34. package/src/client/pages/system/SystemPageLayout.tsx +4 -6
  35. package/src/mock-reporting/code-repository.ts +77 -0
  36. package/src/mock-reporting/routes.ts +25 -8
  37. package/src/shell.ts +21 -5
@@ -6,38 +6,24 @@ import {
6
6
  Trash2,
7
7
  Zap,
8
8
  } from "lucide-react";
9
- import { useEffect, useId, useRef, useState } from "react";
9
+ import { type ReactNode, useEffect, useId, useRef, useState } from "react";
10
10
  import { Link, useLocation } from "react-router";
11
11
  import { ActorAvatar } from "../../components/ActorAvatar";
12
12
  import { SelectableRow } from "../../components/SelectableRow";
13
13
  import { StatusChip } from "../../components/StatusChip";
14
- import { formatRelativeTime, formatTime, peoplePath } from "../../format";
15
- import { cn } from "../../styles";
16
-
17
- const desktopColumns =
18
- "xl:grid-cols-[minmax(0,1fr)_9rem_16rem_10rem_7rem_2.75rem]";
19
-
20
- /** Keep the management headings aligned with the row fields. */
21
- export function AutomationListHeader() {
22
- return (
23
- <div
24
- aria-hidden="true"
25
- className={cn(
26
- "hidden items-center gap-4 border-b border-dashboard-border-subtle px-4 py-2.5 text-xs text-dashboard-text-muted xl:grid",
27
- desktopColumns,
28
- )}
29
- >
30
- <span>Automation</span>
31
- <span>Creator</span>
32
- <span>Trigger</span>
33
- <span>Destination</span>
34
- <span>Last run</span>
35
- <span className="sr-only">Actions</span>
36
- </div>
37
- );
38
- }
14
+ import { formatRelativeTime, peoplePath } from "../../format";
15
+ import {
16
+ automationEventConditions,
17
+ automationEventNames,
18
+ automationScheduleLabel,
19
+ formatAutomationRunTime,
20
+ } from "./automationFormat";
39
21
 
40
- /** Show purpose, ownership, and trigger state without opening the details. */
22
+ /**
23
+ * Show one automation as a compact card: what it is, where it posts, who owns
24
+ * it, and when it runs. Instructions, timezones, and event conditions stay in
25
+ * the details drawer.
26
+ */
41
27
  export function AutomationRow(props: {
42
28
  deleting: boolean;
43
29
  onDelete(): void;
@@ -47,6 +33,7 @@ export function AutomationRow(props: {
47
33
  }) {
48
34
  const { automation } = props;
49
35
  const location = useLocation();
36
+ const status = automationStatus(automation);
50
37
  const creator = (
51
38
  <>
52
39
  <ActorAvatar
@@ -58,197 +45,195 @@ export function AutomationRow(props: {
58
45
  </>
59
46
  );
60
47
  return (
61
- <article role="listitem">
48
+ <article
49
+ className="min-w-0 rounded-lg border border-dashboard-border-subtle bg-dashboard-fill-faint"
50
+ role="listitem"
51
+ >
62
52
  <SelectableRow
63
- className={cn(
64
- "relative grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center gap-x-3 gap-y-2 px-4 py-3 xl:gap-x-4",
65
- desktopColumns,
66
- )}
53
+ className="flex items-start gap-3 rounded-lg px-4 py-3.5"
67
54
  onSelect={props.onSelect}
68
55
  selected={props.selected}
69
56
  >
70
- <button
71
- aria-expanded={props.selected}
72
- aria-label={`View automation details: ${automation.title}`}
73
- className="col-span-2 pr-11 xl:pr-0 min-w-0 cursor-pointer rounded border-0 bg-transparent p-0 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus xl:col-span-1"
74
- onClick={props.onSelect}
75
- type="button"
76
- >
77
- <span
78
- className="block truncate font-display text-base font-medium text-dashboard-text"
79
- title={automation.title}
80
- >
81
- {automation.title}
82
- </span>
83
- <span
84
- className="mt-1 block truncate text-xs text-dashboard-text-muted"
85
- title={automation.instruction}
57
+ <div className="min-w-0 flex-1">
58
+ <button
59
+ aria-expanded={props.selected}
60
+ aria-label={`View automation details: ${automation.title}`}
61
+ className="block w-full min-w-0 cursor-pointer rounded border-0 bg-transparent p-0 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
62
+ onClick={props.onSelect}
63
+ type="button"
86
64
  >
87
- {automation.instruction}
88
- </span>
89
- </button>
90
- <div className="row-start-2 min-w-0 xl:row-auto">
91
- {automation.createdByEmail ? (
92
- <Link
93
- className="inline-flex max-w-full items-center gap-2 rounded text-sm text-dashboard-text no-underline hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
94
- title={automation.createdBy}
95
- to={peoplePath(automation.createdByEmail)}
96
- >
97
- {creator}
98
- </Link>
99
- ) : (
100
- <span
101
- className="inline-flex max-w-full items-center gap-2 text-sm text-dashboard-text"
102
- title={automation.createdBy}
103
- >
104
- {creator}
65
+ <span className="flex min-w-0 items-center gap-2">
66
+ <span
67
+ className="truncate font-display text-base font-medium text-dashboard-text"
68
+ title={automation.title}
69
+ >
70
+ {automation.title}
71
+ </span>
72
+ {status ? (
73
+ <StatusChip
74
+ className="shrink-0"
75
+ size="compact"
76
+ tone={
77
+ status === "completed" || status === "paused"
78
+ ? "neutral"
79
+ : "warning"
80
+ }
81
+ >
82
+ {status}
83
+ </StatusChip>
84
+ ) : null}
105
85
  </span>
106
- )}
107
- </div>
108
- <div className="col-span-2 row-start-3 min-w-0 xl:col-span-1 xl:row-auto">
109
- <AutomationTrigger automation={automation} />
110
- </div>
111
- <div className="col-start-2 row-start-2 min-w-0 text-right xl:col-auto xl:row-auto xl:text-left">
112
- <span className="inline-flex max-w-full items-center gap-1 text-xs text-dashboard-text-muted xl:text-sm xl:text-dashboard-text">
113
- {automation.destination.visibility === "private" ? (
114
- <LockKeyhole
115
- aria-label="Private destination"
116
- role="img"
117
- size={12}
118
- className="shrink-0"
119
- />
120
- ) : null}
121
- <span className="truncate">{automation.destination.label}</span>
122
- </span>
123
- </div>
124
- <div className="col-span-2 min-w-0 xl:col-span-1">
125
- <span className="text-xs text-dashboard-text-muted xl:hidden">
126
- Last run{" "}
127
- </span>
128
- <span
129
- className="text-xs text-dashboard-text-muted xl:text-sm xl:text-dashboard-text"
130
- title={
131
- automation.lastRunAt
132
- ? formatTime(automation.lastRunAt, {
133
- dateStyle: "medium",
134
- timeStyle: "short",
135
- })
136
- : undefined
137
- }
138
- >
139
- {automation.lastRunAt
140
- ? formatRelativeTime(automation.lastRunAt)
141
- : "Never"}
142
- </span>
143
- {automation.lastRunStatus === "failed" ||
144
- automation.lastRunStatus === "blocked" ? (
145
- <Link
146
- className="block text-xs text-amber-300 underline"
147
- to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
148
- >
149
- Last run {automation.lastRunStatus}
150
- </Link>
151
- ) : null}
86
+ <AutomationSummaryLine automation={automation} />
87
+ </button>
88
+ <div className="mt-2.5 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-dashboard-text-muted">
89
+ {automation.createdByEmail ? (
90
+ <Link
91
+ className="inline-flex min-w-0 max-w-full items-center gap-2 rounded text-dashboard-text no-underline hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
92
+ title={automation.createdBy}
93
+ to={peoplePath(automation.createdByEmail)}
94
+ >
95
+ {creator}
96
+ </Link>
97
+ ) : (
98
+ <span
99
+ className="inline-flex min-w-0 max-w-full items-center gap-2 text-dashboard-text"
100
+ title={automation.createdBy}
101
+ >
102
+ {creator}
103
+ </span>
104
+ )}
105
+ <AutomationTiming automation={automation} />
106
+ </div>
152
107
  </div>
153
- <div className="absolute right-3 top-3 xl:static">
154
- {automation.ownedByViewer ? (
108
+ {automation.ownedByViewer ? (
109
+ <div className="-mr-2 -mt-1.5 shrink-0">
155
110
  <AutomationActions
156
111
  editPath={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/edit${location.search}`}
157
112
  deleting={props.deleting}
158
113
  onDelete={props.onDelete}
159
114
  title={automation.title}
160
115
  />
161
- ) : null}
162
- </div>
116
+ </div>
117
+ ) : null}
163
118
  </SelectableRow>
164
119
  </article>
165
120
  );
166
121
  }
167
122
 
168
- function AutomationTrigger({ automation }: { automation: AutomationSummary }) {
123
+ /** Return the one state worth flagging in the list, if the automation is not simply active. */
124
+ function automationStatus(automation: AutomationSummary): string | undefined {
125
+ if (automation.kind === "scheduled") {
126
+ return automation.status === "active" ? undefined : automation.status;
127
+ }
128
+ if (automation.status === "paused") return "paused";
129
+ return automation.triggerAvailable ? undefined : "Trigger unavailable";
130
+ }
131
+
132
+ /** Combine the trigger and Destination into one muted line under the title. */
133
+ function AutomationSummaryLine({
134
+ automation,
135
+ }: {
136
+ automation: AutomationSummary;
137
+ }) {
169
138
  const scheduled = automation.kind === "scheduled";
170
139
  const Icon = scheduled ? CalendarClock : Zap;
171
- let summary: string;
172
- let detail: string;
173
- let status: string | undefined;
174
- let conditions: string | undefined;
175
-
176
- if (automation.kind === "scheduled") {
177
- summary = automation.schedule.includes(automation.timezone)
140
+ const trigger =
141
+ automation.kind === "scheduled"
178
142
  ? automation.schedule
179
- : `${automation.schedule} (${automation.timezone})`;
180
- status = automation.status === "active" ? undefined : automation.status;
181
- detail = "No next run";
182
- if (automation.status === "blocked") {
183
- detail = "Future runs are blocked";
184
- } else if (automation.status === "paused") {
185
- detail = "Future triggers will not start work";
186
- } else if (automation.status === "active" && automation.nextRunAt) {
187
- const nextRun = new Date(automation.nextRunAt).toLocaleString(undefined, {
188
- month: "short",
189
- day: "numeric",
190
- hour: "numeric",
191
- minute: "2-digit",
192
- timeZone: automation.timezone,
193
- timeZoneName: "short",
194
- });
195
- detail = `Next ${nextRun}`;
196
- }
197
- } else {
198
- summary = automation.resource;
199
- detail = automation.events
200
- .map((event) => event.replaceAll(/[._]/g, " "))
201
- .join(", ");
202
- status =
203
- automation.status === "paused"
204
- ? "paused"
205
- : automation.triggerAvailable
206
- ? undefined
207
- : "Trigger unavailable";
208
- conditions = Object.entries(automation.match ?? {})
209
- .map(
210
- ([field, value]) =>
211
- `${field}: ${Array.isArray(value) ? value.join(" or ") : String(value)}`,
212
- )
213
- .join(" · ");
214
- }
143
+ : `${automation.resource} · ${automationEventNames(automation)}`;
144
+ const conditions =
145
+ automation.kind === "event" ? automationEventConditions(automation) : "";
146
+ const triggerDetail =
147
+ automation.kind === "scheduled"
148
+ ? automationScheduleLabel(automation)
149
+ : conditions
150
+ ? `${trigger} · ${conditions}`
151
+ : trigger;
215
152
  return (
216
- <div className="flex items-start gap-2">
153
+ <span className="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-dashboard-text-muted">
217
154
  <Icon
218
155
  aria-label={
219
156
  scheduled
220
157
  ? "Scheduled automation"
221
158
  : `${automation.source} event automation`
222
159
  }
223
- className="mt-0.5 shrink-0 text-dashboard-text-muted"
160
+ className="shrink-0"
224
161
  role="img"
225
- size={16}
162
+ size={13}
226
163
  />
227
- <div className="min-w-0">
228
- <div className="break-words text-sm text-dashboard-text">{summary}</div>
229
- <div className="mt-1 break-words text-xs text-dashboard-text-muted">
230
- {detail}
231
- </div>
232
- {conditions ? (
233
- <div className="mt-1 break-words text-xs text-dashboard-text-muted">
234
- {conditions}
235
- </div>
236
- ) : null}
237
- {status ? (
238
- <StatusChip
239
- className="mt-1 xl:mt-2"
240
- size="compact"
241
- tone={
242
- status === "completed" || status === "paused"
243
- ? "neutral"
244
- : "warning"
245
- }
164
+ <span className="min-w-0 truncate" title={triggerDetail}>
165
+ {trigger}
166
+ </span>
167
+ <span aria-hidden="true" className="shrink-0 opacity-45">
168
+ ·
169
+ </span>
170
+ {automation.destination.visibility === "private" ? (
171
+ <LockKeyhole
172
+ aria-label="Private destination"
173
+ className="shrink-0"
174
+ role="img"
175
+ size={12}
176
+ />
177
+ ) : null}
178
+ <span
179
+ className="min-w-0 max-w-[45%] truncate"
180
+ title={automation.destination.label}
181
+ >
182
+ {automation.destination.label}
183
+ </span>
184
+ </span>
185
+ );
186
+ }
187
+
188
+ /** Show next and last run as short relative times after the creator. */
189
+ function AutomationTiming({ automation }: { automation: AutomationSummary }) {
190
+ const nextRunAt =
191
+ automation.kind === "scheduled" && automation.status === "active"
192
+ ? automation.nextRunAt
193
+ : undefined;
194
+ const lastRunTitle = automation.lastRunAt
195
+ ? formatAutomationRunTime(automation.lastRunAt)
196
+ : undefined;
197
+ const lastRunFailed =
198
+ automation.lastRunStatus === "failed" ||
199
+ automation.lastRunStatus === "blocked";
200
+ return (
201
+ <>
202
+ {nextRunAt ? (
203
+ <MetaItem title={formatAutomationRunTime(nextRunAt)}>
204
+ Next run {formatRelativeTime(nextRunAt)}
205
+ </MetaItem>
206
+ ) : null}
207
+ <MetaItem title={lastRunTitle}>
208
+ {lastRunFailed ? (
209
+ <Link
210
+ className="text-amber-300 underline"
211
+ to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
246
212
  >
247
- {status}
248
- </StatusChip>
249
- ) : null}
250
- </div>
251
- </div>
213
+ Last run {automation.lastRunStatus}
214
+ {automation.lastRunAt
215
+ ? ` ${formatRelativeTime(automation.lastRunAt)}`
216
+ : null}
217
+ </Link>
218
+ ) : automation.lastRunAt ? (
219
+ `Last run ${formatRelativeTime(automation.lastRunAt)}`
220
+ ) : (
221
+ "Never run"
222
+ )}
223
+ </MetaItem>
224
+ </>
225
+ );
226
+ }
227
+
228
+ /** Keep each separator with the item after it so wrapped lines never end in a dot. */
229
+ function MetaItem(props: { children: ReactNode; title?: string }) {
230
+ return (
231
+ <span className="whitespace-nowrap" title={props.title}>
232
+ <span aria-hidden="true" className="mr-2 opacity-45">
233
+ ·
234
+ </span>
235
+ {props.children}
236
+ </span>
252
237
  );
253
238
  }
254
239
 
@@ -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
 
@@ -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
+ }