@sentry/junior-dashboard 0.233.0 → 0.234.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/app.js +89 -15
  2. package/dist/client.js +55 -52
  3. package/dist/index.js +89 -15
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +3 -3
  6. package/src/client/App.tsx +359 -345
  7. package/src/client/api.ts +17 -1
  8. package/src/client/components/ActorAvatar.tsx +37 -0
  9. package/src/client/components/Button.tsx +12 -10
  10. package/src/client/components/Field.tsx +14 -1
  11. package/src/client/components/FormNotice.tsx +40 -0
  12. package/src/client/components/ParticipantAvatarStack.tsx +6 -14
  13. package/src/client/components/README.md +11 -2
  14. package/src/client/components/Select.tsx +18 -0
  15. package/src/client/components/StatusChip.tsx +15 -12
  16. package/src/client/components/TextInput.tsx +25 -5
  17. package/src/client/http.ts +31 -3
  18. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +71 -54
  19. package/src/client/pages/automations/AutomationEditPage.tsx +172 -0
  20. package/src/client/pages/automations/AutomationEditor.tsx +397 -0
  21. package/src/client/pages/automations/AutomationEditorLoading.tsx +18 -0
  22. package/src/client/pages/automations/AutomationEventFields.tsx +264 -0
  23. package/src/client/pages/automations/AutomationFilters.tsx +171 -0
  24. package/src/client/pages/automations/AutomationFormSection.tsx +22 -0
  25. package/src/client/pages/automations/AutomationLifecycle.tsx +146 -0
  26. package/src/client/pages/automations/AutomationOutcomeFields.tsx +177 -0
  27. package/src/client/pages/automations/AutomationRow.tsx +358 -0
  28. package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
  29. package/src/client/pages/automations/AutomationsPage.tsx +157 -408
  30. package/src/client/pages/automations/AutomationsPageLayout.tsx +16 -5
  31. package/src/client/pages/automations/README.md +19 -0
  32. package/src/client/pages/automations/automationDraft.ts +110 -0
  33. package/src/client/pages/automations/automationOutcomes.ts +23 -0
  34. package/src/client/pages/dev/ComponentsPage.tsx +50 -4
  35. package/src/client/searchParams.ts +4 -2
  36. package/src/client.tsx +7 -4
  37. package/src/mock-reporting/automation-collection.ts +101 -0
  38. package/src/mock-reporting/fixtures.ts +9 -3
  39. package/src/mock-reporting/routes.ts +14 -9
@@ -1,27 +1,22 @@
1
- import { useEffect, useMemo, useState } from "react";
2
- import { useLocation, useNavigate, useParams } from "react-router";
1
+ import { useEffect } from "react";
2
+ import {
3
+ useLocation,
4
+ useNavigate,
5
+ useParams,
6
+ useSearchParams,
7
+ } from "react-router";
3
8
  import { useMutation, useQueryClient } from "@tanstack/react-query";
4
- import type { AutomationSummary } from "@sentry/junior/api/schema";
5
9
  import {
6
- CalendarClock,
7
- ChevronRight,
8
- Globe2,
9
- ListChecks,
10
- LockKeyhole,
11
- Trash2,
12
- UserRound,
13
- } from "lucide-react";
14
- import { useAutomationsData } from "../../api";
15
- import { Button, ToggleButton } from "../../components/Button";
16
- import { FilterBar, FilterGroup } from "../../components/FilterBar";
10
+ automationListQuerySchema,
11
+ type AutomationSummary,
12
+ } from "@sentry/junior/api/schema";
13
+ import { Globe2, ListChecks, LockKeyhole, UserRound } from "lucide-react";
14
+ import { useAutomationsData, useAutomationData } from "../../api";
15
+ import { Button } from "../../components/Button";
16
+ import { AutomationFilters } from "./AutomationFilters";
17
17
  import { InlineError } from "../../components/InlineError";
18
18
  import { PageContentSkeleton } from "../../components/PageContentSkeleton";
19
- import {
20
- pageCount,
21
- pageItems,
22
- PagePagination,
23
- } from "../../components/Pagination";
24
- import { SelectableRow } from "../../components/SelectableRow";
19
+ import { pageCount, PagePagination } from "../../components/Pagination";
25
20
  import {
26
21
  selectTimeSeries,
27
22
  timeRangeBucketUnit,
@@ -31,33 +26,19 @@ import { Card } from "../../components/layout/Card";
31
26
  import { PageHeader } from "../../components/layout/PageHeader";
32
27
  import { StatCard } from "../../components/metrics/StatCard";
33
28
  import { deleteDashboardResource } from "../../http";
34
- import { formatRelativeTime, formatTime, automationPath } from "../../format";
29
+ import { automationPath } from "../../format";
35
30
  import {
36
31
  pathWithSearch,
37
32
  useDebouncedSearchParam,
38
33
  useSearchParamEnum,
39
34
  } from "../../searchParams";
40
- import { cn } from "../../styles";
35
+ import { AutomationRow, AutomationListHeader } from "./AutomationRow";
41
36
  import { AutomationCostChart } from "./AutomationCostChart";
42
37
  import { AutomationDetailsDrawer } from "./AutomationDetailsDrawer";
43
38
  import { AutomationExecutionChart } from "./AutomationExecutionChart";
44
39
 
45
- const AUTOMATION_PAGE_SIZE = 25;
46
40
  const AUTOMATION_RANGE_OPTIONS = ["1", "7", "30", "90"] as const;
47
41
 
48
- type AutomationFilter = "all" | AutomationSummary["kind"];
49
- type AutomationScope = "mine" | "public";
50
-
51
- const AUTOMATION_FILTERS = [
52
- "all",
53
- "scheduled",
54
- "event",
55
- ] as const satisfies readonly AutomationFilter[];
56
- const AUTOMATION_SCOPES = [
57
- "mine",
58
- "public",
59
- ] as const satisfies readonly AutomationScope[];
60
-
61
42
  function parseTaskRange(value: string): TimeRangeDays {
62
43
  const days = Number(value);
63
44
  return (
@@ -65,24 +46,6 @@ function parseTaskRange(value: string): TimeRangeDays {
65
46
  ) as TimeRangeDays;
66
47
  }
67
48
 
68
- function formatDate(value: string): string {
69
- return formatTime(value, {
70
- dateStyle: "medium",
71
- timeStyle: "short",
72
- });
73
- }
74
-
75
- function formatRunDate(value: string): string {
76
- return formatTime(value, {
77
- day: "numeric",
78
- hour: "numeric",
79
- minute: "2-digit",
80
- month: "short",
81
- timeZoneName: "short",
82
- year: "numeric",
83
- });
84
- }
85
-
86
49
  const EMPTY_TASKS: AutomationSummary[] = [];
87
50
 
88
51
  /** Render viewer-owned and public-workspace automations in one native view. */
@@ -102,71 +65,89 @@ export function AutomationsPage(props: {
102
65
  const range = parseTaskRange(rangeParam);
103
66
  const setRange = (value: TimeRangeDays) =>
104
67
  setRangeParam(String(value) as (typeof AUTOMATION_RANGE_OPTIONS)[number]);
105
- const [filter, setFilter] = useSearchParamEnum(
106
- "type",
107
- "all",
108
- AUTOMATION_FILTERS,
68
+ const [searchParams, setSearchParams] = useSearchParams();
69
+ const [searchText, setSearchText] = useDebouncedSearchParam("q", {
70
+ resetPage: true,
71
+ });
72
+ // Ignore only invalid URL values. One bad field must not reset other filters.
73
+ const filters = automationListQuerySchema.parse(
74
+ Object.fromEntries(
75
+ // Zod owns this property name; it is not a Junior domain symbol.
76
+ // oxlint-disable-next-line anti-slop/no-shape-in-symbol-names
77
+ Object.entries(automationListQuerySchema.shape).flatMap(
78
+ ([key, schema]) => {
79
+ const parsed = schema.safeParse(searchParams.get(key) ?? undefined);
80
+ return parsed.success && parsed.data !== undefined
81
+ ? [[key, parsed.data]]
82
+ : [];
83
+ },
84
+ ),
85
+ ),
109
86
  );
110
- const [scope, setScope] = useSearchParamEnum(
111
- "scope",
112
- "mine",
113
- AUTOMATION_SCOPES,
87
+ const request = new URLSearchParams(
88
+ Object.entries(filters).map(([key, value]) => [key, String(value)]),
114
89
  );
115
- const [searchText, setSearchText, searchQuery] = useDebouncedSearchParam();
116
- const query = useAutomationsData(props.enabled, searchQuery);
117
- const [page, setPage] = useState(1);
118
- const search = searchQuery.toLowerCase();
90
+ const query = useAutomationsData(
91
+ props.enabled,
92
+ props.view === "list" ? request.toString() : "",
93
+ );
94
+ const detail = useAutomationData(props.enabled, automationId);
95
+ const setFilter = (key: string, value: string) =>
96
+ setSearchParams(
97
+ (current) => {
98
+ const next = new URLSearchParams(current);
99
+ next.delete("page");
100
+ if (value) next.set(key, value);
101
+ else next.delete(key);
102
+ return next;
103
+ },
104
+ { replace: true },
105
+ );
106
+ const setPage = (page: number) =>
107
+ setSearchParams((current) => {
108
+ const next = new URLSearchParams(current);
109
+ if (page === 1) next.delete("page");
110
+ else next.set("page", String(page));
111
+ return next;
112
+ });
119
113
  const listPath = "/automations/list";
120
114
  const tasksPath = (pathname: string) =>
121
115
  pathWithSearch(pathname, location.search);
122
116
  const selectedTaskPath = (id: string) => tasksPath(automationPath(id));
123
117
  const automations = query.data?.automations ?? EMPTY_TASKS;
124
- const mineCount = automations.filter(
125
- (automation) => automation.ownedByViewer,
126
- ).length;
127
- const publicCount = automations.filter(
128
- (automation) => automation.destination.visibility === "public",
129
- ).length;
130
- const privateCount = automations.filter(
131
- (automation) => automation.destination.visibility === "private",
132
- ).length;
133
- const scopedTasks = useMemo(
134
- () =>
135
- automations.filter((automation) =>
136
- scope === "mine"
137
- ? automation.ownedByViewer
138
- : automation.destination.visibility === "public",
139
- ),
140
- [scope, automations],
141
- );
142
- const visibleTasks = useMemo(
143
- () =>
144
- scopedTasks.filter(
145
- (automation) => filter === "all" || automation.kind === filter,
146
- ),
147
- [filter, scopedTasks],
148
- );
149
- const visibleTaskCount = visibleTasks.length;
150
- const totalPages = pageCount(visibleTaskCount, AUTOMATION_PAGE_SIZE);
151
- const pagedTasks = useMemo(
152
- () =>
153
- props.view === "list"
154
- ? pageItems(visibleTasks, page, AUTOMATION_PAGE_SIZE)
155
- : visibleTasks,
156
- [page, props.view, visibleTasks],
157
- );
158
- const selectedTask = useMemo(
159
- () => automations.find((automation) => automation.id === automationId),
160
- [automationId, automations],
161
- );
162
-
163
- useEffect(() => {
164
- setPage(1);
165
- }, [filter, scope, searchQuery, props.view]);
166
-
118
+ const mineCount = query.data?.counts.mine ?? 0;
119
+ const publicCount = query.data?.counts.public ?? 0;
120
+ const privateCount = query.data?.counts.private ?? 0;
121
+ const visibleTaskCount = query.data?.total ?? 0;
122
+ const totalPages = pageCount(visibleTaskCount, filters.pageSize);
123
+ const page = query.data?.page ?? filters.page;
124
+ const selectedTask = detail.data;
125
+ // A deletion or a shared URL can leave the requested page past the last page.
167
126
  useEffect(() => {
168
- if (page > totalPages) setPage(totalPages);
169
- }, [page, totalPages]);
127
+ if (
128
+ props.view !== "list" ||
129
+ !query.data ||
130
+ query.isPlaceholderData ||
131
+ query.isFetching ||
132
+ query.data.page === filters.page
133
+ )
134
+ return;
135
+ setSearchParams(
136
+ (current) => {
137
+ const next = new URLSearchParams(current);
138
+ next.set("page", String(query.data.page));
139
+ return next;
140
+ },
141
+ { replace: true },
142
+ );
143
+ }, [
144
+ filters.page,
145
+ props.view,
146
+ query.data,
147
+ query.isFetching,
148
+ query.isPlaceholderData,
149
+ setSearchParams,
150
+ ]);
170
151
  const deletion = useMutation({
171
152
  mutationFn: async (automation: AutomationSummary) => {
172
153
  await deleteDashboardResource(
@@ -199,30 +180,62 @@ export function AutomationsPage(props: {
199
180
 
200
181
  return (
201
182
  <>
202
- <PageHeader
203
- description={
204
- props.view === "overview"
205
- ? "Scheduled and event-driven work created by users."
206
- : "Find and manage automations across your linked workspaces."
207
- }
208
- onRangeChange={setRange}
209
- range={range}
210
- title={props.view === "overview" ? "Automations" : "All automations"}
211
- />
183
+ {location.state?.automationSaved ? (
184
+ <p role="status" className="m-0 text-sm text-emerald-300">
185
+ Automation saved. Changes apply to future work.
186
+ </p>
187
+ ) : null}
188
+ {props.view === "overview" ? (
189
+ <PageHeader
190
+ description="Scheduled and event-driven work created by users."
191
+ onRangeChange={setRange}
192
+ range={range}
193
+ title="Automations"
194
+ />
195
+ ) : (
196
+ <h2 className="m-0 font-display text-2xl font-light tracking-tight text-dashboard-text sm:text-3xl">
197
+ All automations
198
+ </h2>
199
+ )}
200
+ {props.view === "list" ? (
201
+ <AutomationFilters
202
+ filters={filters}
203
+ data={query.data}
204
+ onChange={setFilter}
205
+ searchText={searchText}
206
+ onSearch={setSearchText}
207
+ />
208
+ ) : null}
212
209
  {loading ? (
213
210
  <PageContentSkeleton
214
211
  label="Loading automations"
215
212
  variant={props.view === "overview" ? "stats" : "list"}
216
213
  />
217
214
  ) : null}
218
- {!loading && props.view === "overview" ? (
215
+ {query.error ? (
216
+ <InlineError>
217
+ Automations could not be loaded.{" "}
218
+ <Button onClick={() => void query.refetch()}>Try again</Button>
219
+ </InlineError>
220
+ ) : null}
221
+ {automationId && detail.isPending ? (
222
+ <p role="status">Loading automation details…</p>
223
+ ) : null}
224
+ {automationId && detail.error ? (
225
+ <InlineError>
226
+ Automation details could not be loaded. It may be unavailable or you
227
+ may not have access.{" "}
228
+ <Button onClick={() => void detail.refetch()}>Try again</Button>
229
+ </InlineError>
230
+ ) : null}
231
+ {query.data && props.view === "overview" ? (
219
232
  <>
220
233
  <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
221
234
  <StatCard
222
235
  detail="All automations you can access"
223
236
  icon={ListChecks}
224
237
  label="Total automations"
225
- value={automations.length}
238
+ value={query.data?.counts.all ?? 0}
226
239
  />
227
240
  <StatCard
228
241
  detail="Created by you"
@@ -259,94 +272,36 @@ export function AutomationsPage(props: {
259
272
  ) : null}
260
273
  </>
261
274
  ) : null}
262
- {!loading && props.view === "list" ? (
275
+ {query.data && props.view === "list" ? (
263
276
  <>
264
- {showExecutionCharts ? (
265
- <section className="grid gap-4 xl:grid-cols-2">
266
- <AutomationExecutionChart
267
- bucketUnit={timeRangeBucketUnit(range)}
268
- days={executionSeries}
269
- range={range}
270
- />
271
- <AutomationCostChart
272
- bucketUnit={timeRangeBucketUnit(range)}
273
- days={executionSeries}
274
- range={range}
275
- />
276
- </section>
277
- ) : null}
278
- <FilterBar
279
- search={{
280
- label: "Search automations",
281
- onChange: setSearchText,
282
- placeholder: "Title",
283
- value: searchText,
284
- }}
285
- >
286
- <FilterGroup label="Scope">
287
- <ToggleButton
288
- className="inline-flex items-center gap-1.5"
289
- onClick={() => setScope("mine")}
290
- pressed={scope === "mine"}
291
- variant="pill"
292
- >
293
- <UserRound aria-hidden="true" size={13} />
294
- Mine <span className="opacity-65">{mineCount}</span>
295
- </ToggleButton>
296
- <ToggleButton
297
- className="inline-flex items-center gap-1.5"
298
- onClick={() => setScope("public")}
299
- pressed={scope === "public"}
300
- variant="pill"
301
- >
302
- <Globe2 aria-hidden="true" size={13} />
303
- Public <span className="opacity-65">{publicCount}</span>
304
- </ToggleButton>
305
- </FilterGroup>
306
- <FilterGroup label="Type">
307
- {(["all", "scheduled", "event"] as const).map((kind) => (
308
- <ToggleButton
309
- key={kind}
310
- onClick={() => setFilter(kind)}
311
- pressed={filter === kind}
312
- variant="pill"
313
- >
314
- {kind}
315
- </ToggleButton>
316
- ))}
317
- </FilterGroup>
318
- </FilterBar>
319
- <div className="flex flex-wrap items-baseline justify-between gap-x-5 gap-y-1 border-b border-white/[0.07] pb-3">
320
- <p className="m-0 font-display text-lg text-dashboard-text">
277
+ <div className="flex flex-wrap items-baseline justify-between gap-x-5 gap-y-1">
278
+ <p className="m-0 text-xs text-dashboard-text-muted">
321
279
  {visibleTaskCount}{" "}
322
280
  {visibleTaskCount === 1 ? "automation" : "automations"}
323
281
  </p>
324
282
  <p className="m-0 text-xs text-dashboard-text-muted">
325
- {scope === "mine"
326
- ? "Automations you created, including private destinations."
327
- : "All automations assigned to public destinations in your linked workspaces."}
283
+ {query.isFetching ? (
284
+ <span role="status">Updating results…</span>
285
+ ) : null}
328
286
  </p>
329
287
  </div>
330
- {query.error ? (
331
- <Card padding="md">
332
- <InlineError>
333
- Automations could not be loaded. Try again.
334
- </InlineError>
335
- </Card>
336
- ) : visibleTaskCount === 0 ? (
288
+ {!query.error && visibleTaskCount === 0 ? (
337
289
  <Card padding="md">
338
290
  <p className="m-0 text-sm text-dashboard-text-muted">
339
- {emptyText({ filter, mineCount, publicCount, scope, search })}
291
+ No automations matched these filters.
340
292
  </p>
341
293
  </Card>
342
294
  ) : (
343
- <Card>
295
+ <Card className="overflow-visible">
344
296
  <AutomationListHeader />
345
- <div className="divide-y divide-white/[0.07]" role="list">
346
- {pagedTasks.map((automation) => {
297
+ <div
298
+ className="divide-y divide-dashboard-border-subtle"
299
+ role="list"
300
+ >
301
+ {automations.map((automation) => {
347
302
  const key = `${automation.kind}:${automation.id}`;
348
303
  return (
349
- <TaskRow
304
+ <AutomationRow
350
305
  deleting={
351
306
  deletion.isPending &&
352
307
  deletion.variables?.id === automation.id
@@ -368,7 +323,6 @@ export function AutomationsPage(props: {
368
323
  : selectedTaskPath(automation.id),
369
324
  )
370
325
  }
371
- range={range}
372
326
  selected={automationId === automation.id}
373
327
  automation={automation}
374
328
  />
@@ -381,226 +335,21 @@ export function AutomationsPage(props: {
381
335
  onPageChange={setPage}
382
336
  page={page}
383
337
  pageCount={totalPages}
384
- pageSize={AUTOMATION_PAGE_SIZE}
338
+ pageSize={filters.pageSize}
385
339
  total={visibleTaskCount}
386
340
  />
387
- {query.data?.truncated ? (
388
- <p className="m-0 text-center text-xs text-dashboard-text-muted">
389
- Showing up to 100 recent automations in each scope.
390
- </p>
391
- ) : null}
392
341
  {deletion.error ? (
393
342
  <InlineError className="text-center">
394
343
  The automation could not be deleted. Try again.
395
344
  </InlineError>
396
345
  ) : null}
397
- <AutomationDetailsDrawer
398
- onClose={() => navigate(tasksPath(listPath))}
399
- range={range}
400
- automation={selectedTask}
401
- />
402
346
  </>
403
347
  ) : null}
348
+ <AutomationDetailsDrawer
349
+ onClose={() => navigate(tasksPath(listPath))}
350
+ range={range}
351
+ automation={selectedTask}
352
+ />
404
353
  </>
405
354
  );
406
355
  }
407
-
408
- function emptyText(input: {
409
- filter: AutomationFilter;
410
- mineCount: number;
411
- publicCount: number;
412
- scope: AutomationScope;
413
- search: string;
414
- }): string {
415
- if (input.search || input.filter !== "all") {
416
- return "No automations matched these filters.";
417
- }
418
- if (input.scope === "mine" && input.mineCount === 0) {
419
- return "You have no active or completed automations.";
420
- }
421
- if (input.scope === "public" && input.publicCount === 0) {
422
- return "No automations are assigned to public destinations in your linked workspaces.";
423
- }
424
- return "No automations are available.";
425
- }
426
-
427
- function AutomationListHeader() {
428
- return (
429
- <div
430
- aria-hidden="true"
431
- className="hidden grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1fr)_4.5rem_7.5rem_auto_auto] items-center gap-3 border-b border-white/[0.07] px-4 py-2.5 text-left font-mono text-xs uppercase tracking-[0.12em] text-dashboard-text-muted lg:grid"
432
- >
433
- <span>Automation</span>
434
- <span>Destination</span>
435
- <span>Trigger</span>
436
- <span>Runs</span>
437
- <span>Last run</span>
438
- <span aria-hidden="true" className="size-8" />
439
- <span aria-hidden="true" className="size-9" />
440
- </div>
441
- );
442
- }
443
-
444
- function TaskRow(props: {
445
- deleting: boolean;
446
- onDelete(): void;
447
- onSelect(): void;
448
- range: TimeRangeDays;
449
- selected: boolean;
450
- automation: AutomationSummary;
451
- }) {
452
- const { range, automation } = props;
453
- const runCount = automation.runs[range];
454
- return (
455
- <article role="listitem">
456
- <SelectableRow
457
- className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 px-4 py-3 md:grid-cols-[repeat(2,minmax(0,1fr))_auto_auto] lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1fr)_4.5rem_7.5rem_auto_auto]"
458
- onSelect={props.onSelect}
459
- selected={props.selected}
460
- >
461
- <button
462
- aria-expanded={props.selected}
463
- className="flex min-w-0 cursor-pointer items-center gap-3 border-0 bg-transparent p-0 text-left"
464
- onClick={props.onSelect}
465
- type="button"
466
- >
467
- <TaskSourceMark automation={automation} />
468
- <span className="min-w-0">
469
- <span className="block truncate font-display text-base font-medium text-dashboard-text">
470
- {automation.title}
471
- </span>
472
- <span className="mt-1 block truncate font-mono text-xs uppercase tracking-[0.08em] text-dashboard-text-muted">
473
- <span className="md:hidden">{automation.destination.label}</span>
474
- <span className="hidden md:inline">
475
- {formatDate(automation.createdAt)}
476
- </span>
477
- </span>
478
- </span>
479
- </button>
480
- <div className="hidden min-w-0 md:block">
481
- <div className="truncate text-sm font-medium text-dashboard-text">
482
- {automation.destination.label}
483
- </div>
484
- <div className="mt-1 font-mono text-xs uppercase tracking-[0.08em] text-dashboard-text-muted">
485
- {automation.destination.visibility}
486
- </div>
487
- </div>
488
- <div className="hidden min-w-0 lg:block">
489
- <div className="truncate text-sm text-dashboard-text">
490
- {automation.kind === "scheduled"
491
- ? automation.schedule
492
- : automation.resource}
493
- </div>
494
- <div className="mt-1 truncate font-mono text-xs text-dashboard-text-muted">
495
- {automation.kind === "scheduled"
496
- ? automation.nextRunAt
497
- ? `Next ${formatRunDate(automation.nextRunAt)}`
498
- : "No next run"
499
- : automation.events.join(", ")}
500
- </div>
501
- </div>
502
- <div className="hidden min-w-0 lg:block">
503
- <div className="truncate text-sm font-medium text-dashboard-text">
504
- {runCount}
505
- </div>
506
- </div>
507
- <div className="hidden min-w-0 lg:block">
508
- <div className="truncate text-sm text-dashboard-text">
509
- {automation.lastRunAt
510
- ? formatRelativeTime(automation.lastRunAt)
511
- : "Never"}
512
- </div>
513
- <div className="mt-1 truncate font-mono text-xs text-dashboard-text-muted">
514
- {automation.lastRunAt
515
- ? formatRunDate(automation.lastRunAt)
516
- : "No executions"}
517
- </div>
518
- </div>
519
- <button
520
- aria-expanded={props.selected}
521
- aria-label={`View automation details: ${automation.title}`}
522
- className="grid size-8 cursor-pointer place-items-center rounded border border-transparent bg-transparent text-dashboard-text-muted transition-colors hover:border-white/10 hover:bg-white/[0.04] hover:text-dashboard-text"
523
- onClick={props.onSelect}
524
- type="button"
525
- >
526
- <ChevronRight
527
- aria-hidden="true"
528
- className={cn(
529
- "transition-transform",
530
- props.selected && "translate-x-0.5 text-cyan-200",
531
- )}
532
- size={16}
533
- />
534
- </button>
535
- {automation.ownedByViewer ? (
536
- <Button
537
- aria-label={`Delete: ${automation.title}`}
538
- disabled={props.deleting}
539
- onClick={props.onDelete}
540
- size="icon"
541
- title="Delete automation"
542
- >
543
- <Trash2 aria-hidden="true" size={15} />
544
- </Button>
545
- ) : (
546
- <span aria-hidden="true" className="size-8" />
547
- )}
548
- </SelectableRow>
549
- </article>
550
- );
551
- }
552
-
553
- function TaskSourceMark(props: { automation: AutomationSummary }) {
554
- const { automation } = props;
555
- if (automation.kind === "scheduled") {
556
- return (
557
- <div
558
- aria-label="Scheduled automation"
559
- className="grid size-9 shrink-0 place-items-center rounded border border-white/[0.08] bg-white/[0.03] text-cyan-300/75"
560
- role="img"
561
- title="Scheduled automation"
562
- >
563
- <CalendarClock aria-hidden="true" size={16} />
564
- </div>
565
- );
566
- }
567
- const source = automation.source.trim();
568
- const sourceKey = source.toLowerCase();
569
- const isGitHub = sourceKey === "github";
570
- const sourceLabel = isGitHub
571
- ? "GitHub"
572
- : sourceKey === "pagerduty"
573
- ? "PagerDuty"
574
- : source;
575
- const sourceMark =
576
- sourceKey === "pagerduty" ? "PD" : source.slice(0, 2).toUpperCase();
577
- return (
578
- <div
579
- aria-label={`${sourceLabel} event automation`}
580
- className="grid size-9 shrink-0 place-items-center rounded border border-white/[0.08] bg-white/[0.03] text-cyan-300/75"
581
- role="img"
582
- title={`${sourceLabel} event automation`}
583
- >
584
- {isGitHub ? (
585
- <GitHubMark />
586
- ) : (
587
- <span className="font-mono text-xs font-semibold uppercase tracking-[0.08em]">
588
- {sourceMark}
589
- </span>
590
- )}
591
- </div>
592
- );
593
- }
594
-
595
- function GitHubMark() {
596
- return (
597
- <svg
598
- aria-hidden="true"
599
- className="size-[18px]"
600
- fill="currentColor"
601
- viewBox="0 0 24 24"
602
- >
603
- <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.418-1.305.762-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.793 1.23 1.1-.306 2.28-.459 3.45-.465 1.17.006 2.35.159 3.45.465 2.79-1.552 3.795-1.23 3.795-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.435.375.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
604
- </svg>
605
- );
606
- }