@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
package/src/client/api.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  personalSpendReportSchema,
17
17
  automationExecutionListSchema,
18
18
  automationListSchema,
19
+ automationSummarySchema,
19
20
  automationRunListSchema,
20
21
  } from "@sentry/junior/api/schema";
21
22
  import {
@@ -122,7 +123,7 @@ export function useAutomationsData(enabled: boolean, search: string) {
122
123
  queryFn: ({ signal }) =>
123
124
  fetchDashboardJson(
124
125
  automationListSchema,
125
- `/api/automations${search ? `?q=${encodeURIComponent(search)}` : ""}`,
126
+ `/api/automations${search ? `?${search}` : ""}`,
126
127
  signal,
127
128
  ),
128
129
  placeholderData: keepPreviousData,
@@ -130,6 +131,21 @@ export function useAutomationsData(enabled: boolean, search: string) {
130
131
  });
131
132
  }
132
133
 
134
+ /** Load details by ID, independent of list filters and pagination. */
135
+ export function useAutomationData(enabled: boolean, id: string | undefined) {
136
+ return useQuery({
137
+ enabled: enabled && Boolean(id),
138
+ queryKey: ["dashboard", "automations", "detail", id],
139
+ queryFn: ({ signal }) =>
140
+ fetchDashboardJson(
141
+ automationSummarySchema,
142
+ `/api/automations/${encodeURIComponent(id!)}`,
143
+ signal,
144
+ ),
145
+ retry: false,
146
+ });
147
+ }
148
+
133
149
  /** Fetch newest runs across all viewer-visible automations. */
134
150
  export function useAutomationRunsData(enabled: boolean) {
135
151
  return useQuery({
@@ -0,0 +1,37 @@
1
+ import { useState } from "react";
2
+ import { cn } from "../styles";
3
+
4
+ /** Show an actor's image, with initials when the image is missing or fails. */
5
+ export function ActorAvatar(props: {
6
+ name: string;
7
+ imageUrl?: string;
8
+ size: "detail" | "list";
9
+ }) {
10
+ const [failedUrl, setFailedUrl] = useState<string>();
11
+ const words = props.name.trim().split(/\s+/).filter(Boolean);
12
+ const initials =
13
+ words.length > 1
14
+ ? `${words[0]![0]}${words.at(-1)![0]}`
15
+ : props.name.slice(0, 2);
16
+ return (
17
+ <span
18
+ aria-hidden="true"
19
+ className={cn(
20
+ "relative inline-grid shrink-0 place-items-center overflow-hidden rounded-full border-2 border-dashboard-bg bg-dashboard-focus font-sans font-bold leading-none text-dashboard-text-inverse shadow-sm",
21
+ props.size === "list" ? "size-6 text-2xs" : "size-7 text-xs",
22
+ )}
23
+ >
24
+ {initials.toUpperCase()}
25
+ {props.imageUrl && props.imageUrl !== failedUrl ? (
26
+ <img
27
+ alt=""
28
+ className="absolute inset-0 size-full object-cover"
29
+ loading="lazy"
30
+ onError={() => setFailedUrl(props.imageUrl)}
31
+ referrerPolicy="no-referrer"
32
+ src={props.imageUrl}
33
+ />
34
+ ) : null}
35
+ </span>
36
+ );
37
+ }
@@ -4,7 +4,7 @@ import { Link, type LinkProps } from "react-router";
4
4
  import { cn } from "../styles";
5
5
 
6
6
  type ButtonSize = "default" | "icon";
7
- type ButtonTone = "default" | "danger";
7
+ type ButtonTone = "default" | "danger" | "primary";
8
8
  type ToggleButtonVariant = "pill" | "segment" | "text";
9
9
 
10
10
  export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
@@ -44,15 +44,17 @@ export function Button({
44
44
  ? "text-dashboard-text-muted hover:!bg-rose-300/10 hover:!text-rose-300 focus-visible:outline-rose-300/55 disabled:hover:!text-dashboard-text-muted"
45
45
  : "text-dashboard-text-muted hover:!bg-dashboard-fill-strong hover:!text-dashboard-text focus-visible:outline-cyan-300/55 disabled:hover:!text-dashboard-text-muted",
46
46
  )
47
- : isDanger
48
- ? cn(
49
- dangerButtonClassName,
50
- "disabled:hover:border-dashboard-border-strong disabled:hover:text-dashboard-text-muted",
51
- )
52
- : cn(
53
- buttonClassName,
54
- "disabled:hover:border-dashboard-border-emphasis disabled:hover:bg-dashboard-surface-raised disabled:hover:text-dashboard-text",
55
- ),
47
+ : tone === "primary"
48
+ ? "inline-flex h-9 items-center justify-center gap-2 rounded border border-dashboard-text-solid bg-dashboard-text-solid px-3 font-sans text-sm font-semibold text-dashboard-text-inverse hover:bg-dashboard-text-subtle focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dashboard-focus"
49
+ : isDanger
50
+ ? cn(
51
+ dangerButtonClassName,
52
+ "disabled:hover:border-dashboard-border-strong disabled:hover:text-dashboard-text-muted",
53
+ )
54
+ : cn(
55
+ buttonClassName,
56
+ "disabled:hover:border-dashboard-border-emphasis disabled:hover:bg-dashboard-surface-raised disabled:hover:text-dashboard-text",
57
+ ),
56
58
  props.disabled ? "" : "cursor-pointer",
57
59
  className,
58
60
  )}
@@ -20,6 +20,7 @@ export function Field(props: {
20
20
  children: ReactNode;
21
21
  className?: string;
22
22
  help?: ReactNode;
23
+ error?: string;
23
24
  htmlFor?: string;
24
25
  label: ReactNode;
25
26
  size?: FieldSize;
@@ -31,8 +32,20 @@ export function Field(props: {
31
32
  {props.label}
32
33
  </label>
33
34
  {props.children}
35
+ {props.error ? (
36
+ <p
37
+ id={props.htmlFor ? `${props.htmlFor}-error` : undefined}
38
+ role="alert"
39
+ className="m-0 text-xs leading-relaxed text-rose-300"
40
+ >
41
+ {props.error}
42
+ </p>
43
+ ) : null}
34
44
  {props.help ? (
35
- <p className="m-0 text-xs leading-relaxed text-dashboard-text-muted sm:text-sm">
45
+ <p
46
+ id={props.htmlFor ? `${props.htmlFor}-help` : undefined}
47
+ className="m-0 text-xs leading-relaxed text-dashboard-text-muted sm:text-sm"
48
+ >
36
49
  {props.help}
37
50
  </p>
38
51
  ) : null}
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from "react";
2
+ import { TriangleAlert } from "lucide-react";
3
+ import { cn } from "../styles";
4
+
5
+ /** Keep form recovery text and actions visible without truncation. */
6
+ export function FormNotice(props: {
7
+ title: string;
8
+ children?: ReactNode;
9
+ tone?: "error" | "warning";
10
+ }) {
11
+ const error = props.tone === "error";
12
+ return (
13
+ <div
14
+ role={error ? "alert" : "status"}
15
+ className={cn(
16
+ "flex min-w-0 gap-3 rounded-md border p-4",
17
+ error
18
+ ? "border-rose-300/25 bg-rose-300/5"
19
+ : "border-amber-300/25 bg-amber-300/5",
20
+ )}
21
+ >
22
+ <TriangleAlert
23
+ aria-hidden
24
+ size={18}
25
+ className={cn(
26
+ "mt-0.5 shrink-0",
27
+ error ? "text-rose-300" : "text-amber-300",
28
+ )}
29
+ />
30
+ <div className="min-w-0">
31
+ <p className="m-0 text-sm font-medium">{props.title}</p>
32
+ {props.children ? (
33
+ <div className="mt-1 text-sm leading-relaxed text-dashboard-text-muted">
34
+ {props.children}
35
+ </div>
36
+ ) : null}
37
+ </div>
38
+ </div>
39
+ );
40
+ }
@@ -3,6 +3,7 @@ import type { ActorIdentity } from "@sentry/junior/api/schema";
3
3
  import { actorLabel } from "../format";
4
4
  import { cn } from "../styles";
5
5
  import type { Conversation } from "../types";
6
+ import { ActorAvatar } from "./ActorAvatar";
6
7
  import { Tooltip } from "./Tooltip";
7
8
 
8
9
  const MAX_VISIBLE_PARTICIPANTS = 3;
@@ -28,15 +29,6 @@ function participantDescription(participant: ActorIdentity): string {
28
29
  return email && email !== name ? `${name}, ${email}` : name;
29
30
  }
30
31
 
31
- function participantInitials(participant: ActorIdentity): string {
32
- const name = participantName(participant);
33
- const words = name.split(/\s+/).filter(Boolean);
34
- if (words.length > 1) {
35
- return `${words[0]![0] ?? ""}${words.at(-1)?.[0] ?? ""}`.toUpperCase();
36
- }
37
- return name.slice(0, 2).toUpperCase();
38
- }
39
-
40
32
  function participantKey(participant: ActorIdentity, index: number): string {
41
33
  return (
42
34
  participant.email?.trim().toLowerCase() ||
@@ -72,14 +64,14 @@ function Avatar(props: {
72
64
  >
73
65
  <span
74
66
  aria-label={label}
75
- className={cn(
76
- "relative inline-grid shrink-0 place-items-center rounded-full border-2 border-dashboard-bg bg-dashboard-focus font-sans font-bold leading-none text-dashboard-text-inverse shadow-sm focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus",
77
- props.size === "list" ? "size-6 text-2xs" : "size-7 text-xs",
78
- )}
67
+ className="relative inline-flex rounded-full focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus"
79
68
  onKeyDown={(event) => event.stopPropagation()}
80
69
  tabIndex={0}
81
70
  >
82
- {participantInitials(props.participant)}
71
+ <ActorAvatar
72
+ name={participantName(props.participant)}
73
+ size={props.size}
74
+ />
83
75
  </span>
84
76
  </Tooltip>
85
77
  );
@@ -6,7 +6,7 @@ instead of copying Tailwind blocks.
6
6
  ## Layers
7
7
 
8
8
  1. **Primitives** — `Button` / `ToggleButton`, `Field`, `TextInput`,
9
- `StatusChip`, `StatusDot`, `Notice`, `Drawer`, `Tooltip`, `Skeleton`,
9
+ `StatusChip`, `StatusDot`, `Notice`, `FormNotice`, `Select`, `Drawer`, `Tooltip`, `Skeleton`,
10
10
  `PageContentSkeleton`, `SegmentedTabs`, and other small reusable controls.
11
11
  Use `SegmentedTabs` for equal-width panel navigation. Pass the selected value,
12
12
  items, change handler, and selected panel content. It owns tab/panel labels
@@ -14,7 +14,9 @@ instead of copying Tailwind blocks.
14
14
  `ToggleButton` variants (`pill`, `segment`, `text`) over one-off pressed
15
15
  styles. Prefer `Field` sizes (`default`, `compact`) over handwritten labels.
16
16
  Prefer `StatusDot` for dense table or list status markers and `StatusChip`
17
- when the label itself must stay visible. Prefer `PageContentSkeleton`
17
+ when the label itself must stay visible. `StatusChip` uses a small dot and
18
+ sentence-case text, without a border, fill, or all-caps styling. It is not
19
+ an interactive control. Prefer `PageContentSkeleton`
18
20
  variants over ad-hoc pulse blocks in route modules. Use `PageRouteLoading`
19
21
  when a standard page must keep its real header above a body skeleton. Use a
20
22
  feature-owned loading component for distinct geometry, such as conversations.
@@ -54,3 +56,10 @@ instead of copying Tailwind blocks.
54
56
  - One-off page glue may stay inline when extraction only adds indirection.
55
57
 
56
58
  See `policies/frontend-components.md`.
59
+
60
+ Use `TextInput size="comfortable"` for larger mobile form controls.
61
+ Form pages use `Button tone="primary"` for their one main save action. Use
62
+ `TextArea prose` for instructions rather than code. `Field error` renders an
63
+ error at `<htmlFor>-error`; the owning control must set `aria-invalid` and
64
+ `aria-describedby`. `Select` matches native text controls. Use `FormNotice` for
65
+ wrapping form recovery text; use `Notice` for compact transient messages.
@@ -0,0 +1,18 @@
1
+ import type { SelectHTMLAttributes } from "react";
2
+ import { cn } from "../styles";
3
+
4
+ /** Match native form choices to the dashboard text controls. */
5
+ export function Select({
6
+ className,
7
+ ...props
8
+ }: SelectHTMLAttributes<HTMLSelectElement>) {
9
+ return (
10
+ <select
11
+ {...props}
12
+ className={cn(
13
+ "block min-h-11 w-full min-w-0 rounded border border-dashboard-border-emphasis bg-dashboard-ink px-3 py-2 text-base text-dashboard-text focus:border-dashboard-focus focus:outline-none aria-invalid:border-rose-300 disabled:cursor-not-allowed disabled:opacity-60 sm:min-h-10 sm:text-sm",
14
+ className,
15
+ )}
16
+ />
17
+ );
18
+ }
@@ -13,21 +13,20 @@ export type StatusChipTone =
13
13
  export type StatusChipSize = "default" | "compact";
14
14
 
15
15
  const toneClass: Record<StatusChipTone, string> = {
16
- accent: "border-violet-300/20 bg-violet-300/[0.07] text-violet-100",
17
- danger: "border-rose-400/25 bg-rose-400/10 text-rose-200",
18
- info: "border-cyan-300/20 bg-cyan-300/[0.07] text-cyan-100",
19
- neutral: "border-dashboard-border bg-dashboard-fill-faint text-dashboard-text-muted",
20
- success: "border-emerald-400/25 bg-emerald-400/10 text-emerald-200",
21
- warning: "border-amber-400/25 bg-amber-400/10 text-amber-100",
16
+ accent: "text-violet-300",
17
+ danger: "text-rose-300",
18
+ info: "text-cyan-300",
19
+ neutral: "text-dashboard-text-muted",
20
+ success: "text-emerald-300",
21
+ warning: "text-amber-300",
22
22
  };
23
23
 
24
24
  const sizeClass: Record<StatusChipSize, string> = {
25
- compact:
26
- "gap-1.5 px-2 py-1 font-mono text-2xs uppercase tracking-[0.08em]",
27
- default: "px-2 py-1 font-mono text-xs uppercase tracking-[0.1em]",
25
+ compact: "text-xs",
26
+ default: "text-sm",
28
27
  };
29
28
 
30
- /** Render a compact status or kind label with shared tone and size contracts. */
29
+ /** Show status as quiet text, not a button-shaped badge. */
31
30
  export function StatusChip(props: {
32
31
  children: ReactNode;
33
32
  className?: string;
@@ -37,13 +36,17 @@ export function StatusChip(props: {
37
36
  return (
38
37
  <span
39
38
  className={cn(
40
- "inline-flex w-fit items-center rounded border",
39
+ "inline-flex w-fit items-center gap-1.5 font-sans font-medium normal-case leading-snug tracking-normal",
41
40
  sizeClass[props.size ?? "default"],
42
41
  toneClass[props.tone ?? "neutral"],
43
42
  props.className,
44
43
  )}
45
44
  >
46
- {props.children}
45
+ <span
46
+ aria-hidden="true"
47
+ className="size-1.5 shrink-0 rounded-full bg-current"
48
+ />
49
+ <span className="block first-letter:uppercase">{props.children}</span>
47
50
  </span>
48
51
  );
49
52
  }
@@ -3,36 +3,56 @@ import type { InputHTMLAttributes, TextareaHTMLAttributes } from "react";
3
3
  import { cn } from "../styles";
4
4
 
5
5
  const textControlClassName =
6
- "block w-full rounded border border-dashboard-border-emphasis bg-dashboard-ink px-3 py-2 text-sm text-dashboard-text focus:border-dashboard-focus focus:outline-none disabled:cursor-not-allowed disabled:opacity-50";
6
+ "block w-full rounded border border-dashboard-border-emphasis bg-dashboard-ink px-3 py-2 text-sm text-dashboard-text focus:border-dashboard-focus focus:outline-none aria-invalid:border-rose-300 disabled:cursor-not-allowed disabled:opacity-50";
7
7
 
8
8
  export type TextInputProps = Omit<
9
9
  InputHTMLAttributes<HTMLInputElement>,
10
10
  "size"
11
11
  > & {
12
12
  className?: string;
13
+ size?: "default" | "comfortable";
13
14
  };
14
15
 
15
16
  /** Render the dashboard's standard single-line text control. */
16
- export function TextInput({ className, type = "text", ...props }: TextInputProps) {
17
+ export function TextInput({
18
+ className,
19
+ type = "text",
20
+ size = "default",
21
+ ...props
22
+ }: TextInputProps) {
17
23
  return (
18
24
  <input
19
25
  {...props}
20
- className={cn(textControlClassName, className)}
26
+ className={cn(
27
+ textControlClassName,
28
+ size === "comfortable" && "min-h-11 text-base sm:min-h-10 sm:text-sm",
29
+ className,
30
+ )}
21
31
  type={type}
22
32
  />
23
33
  );
24
34
  }
25
35
 
26
36
  export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
37
+ prose?: boolean;
27
38
  className?: string;
28
39
  };
29
40
 
30
41
  /** Render the dashboard's standard multi-line text control. */
31
- export function TextArea({ className, ...props }: TextAreaProps) {
42
+ export function TextArea({
43
+ className,
44
+ prose = false,
45
+ ...props
46
+ }: TextAreaProps) {
32
47
  return (
33
48
  <textarea
34
49
  {...props}
35
- className={cn(textControlClassName, "min-h-36 font-mono", className)}
50
+ className={cn(
51
+ textControlClassName,
52
+ "min-h-36",
53
+ prose ? "font-sans leading-relaxed" : "font-mono",
54
+ className,
55
+ )}
36
56
  />
37
57
  );
38
58
  }
@@ -4,11 +4,20 @@ import { recordDashboardServerVersion } from "./dashboard-version";
4
4
  /** An authenticated dashboard request rejected by the product API. */
5
5
  export class DashboardApiError extends Error {
6
6
  readonly status: number;
7
+ readonly fields?: Record<string, string[]>;
8
+ readonly code?: string;
7
9
  readonly apiError?: string;
8
10
 
9
- constructor(path: string, status: number, apiError?: string) {
11
+ constructor(
12
+ path: string,
13
+ status: number,
14
+ apiError?: string,
15
+ detail?: { fields?: Record<string, string[]>; code?: string },
16
+ ) {
10
17
  super(`${path} returned ${status}`);
11
18
  this.status = status;
19
+ this.fields = detail?.fields;
20
+ this.code = detail?.code;
12
21
  if (apiError?.trim()) this.apiError = apiError.trim();
13
22
  }
14
23
  }
@@ -18,13 +27,32 @@ async function throwDashboardApiError(
18
27
  response: Response,
19
28
  ): Promise<never> {
20
29
  let apiError: string | undefined;
30
+ let fields: Record<string, string[]> | undefined;
31
+ let code: string | undefined;
21
32
  try {
22
- const body = (await response.json()) as { error?: unknown };
33
+ const body = (await response.json()) as {
34
+ error?: unknown;
35
+ code?: unknown;
36
+ fields?: unknown;
37
+ };
23
38
  if (typeof body.error === "string") apiError = body.error;
39
+ if (typeof body.code === "string") code = body.code;
40
+ if (body.fields && typeof body.fields === "object") {
41
+ fields = Object.fromEntries(
42
+ Object.entries(body.fields).filter(
43
+ (entry): entry is [string, string[]] =>
44
+ Array.isArray(entry[1]) &&
45
+ entry[1].every((value) => typeof value === "string"),
46
+ ),
47
+ );
48
+ }
24
49
  } catch {
25
50
  // Keep the status-only fallback when the body is not JSON.
26
51
  }
27
- throw new DashboardApiError(path, response.status, apiError);
52
+ throw new DashboardApiError(path, response.status, apiError, {
53
+ fields,
54
+ code,
55
+ });
28
56
  }
29
57
 
30
58
  function restartDashboardSignIn(): void {
@@ -1,6 +1,7 @@
1
+ import { AutomationLifecycle } from "./AutomationLifecycle";
2
+ import { automationOutcomeLabel } from "./automationOutcomes";
1
3
  import type { AutomationSummary } from "@sentry/junior/api/schema";
2
- import { Link } from "react-router";
3
- import { MapPin } from "lucide-react";
4
+ import { Link, useLocation } from "react-router";
4
5
  import { Detail, DetailList } from "../../components/DetailList";
5
6
  import { Drawer } from "../../components/Drawer";
6
7
  import {
@@ -17,6 +18,7 @@ export function AutomationDetailsDrawer(props: {
17
18
  range: TimeRangeDays;
18
19
  automation: AutomationSummary | undefined;
19
20
  }) {
21
+ const location = useLocation();
20
22
  if (!props.automation) return null;
21
23
 
22
24
  const { automation } = props;
@@ -38,22 +40,21 @@ export function AutomationDetailsDrawer(props: {
38
40
  { label: "Schedule", value: automation.schedule },
39
41
  {
40
42
  label: "Next run",
41
- value: automation.nextRunAt
42
- ? formatRunDate(automation.nextRunAt)
43
- : "None",
43
+ value:
44
+ automation.status === "active" && automation.nextRunAt
45
+ ? formatRunDate(automation.nextRunAt)
46
+ : "None",
44
47
  },
45
48
  ]
46
49
  : [
47
- { label: "Resource", value: automation.resource },
48
- { label: "Events", value: automation.events.join(", ") },
50
+ { label: "Watching", value: automation.resource },
51
+ {
52
+ label: "Events",
53
+ value: automation.events
54
+ .map((event) => event.replaceAll(/[._]/g, " "))
55
+ .join(", "),
56
+ },
49
57
  ];
50
- const statusLabel =
51
- automation.kind === "scheduled"
52
- ? automation.status
53
- : automation.triggerAvailable
54
- ? "ready"
55
- : "unavailable";
56
-
57
58
  const titleId = "automation-details-drawer-title";
58
59
 
59
60
  return (
@@ -68,9 +69,8 @@ export function AutomationDetailsDrawer(props: {
68
69
  >
69
70
  {automation.title}
70
71
  </h2>
71
- <div className="mt-1 break-words font-mono text-xs leading-snug text-dashboard-text-muted capitalize">
72
- {automation.kind} automation · {statusLabel} ·{" "}
73
- {formatDate(automation.createdAt)}
72
+ <div className="mt-1 break-words text-sm text-dashboard-text-muted">
73
+ Created by {createdBy} · {automation.destination.label}
74
74
  </div>
75
75
  </>
76
76
  }
@@ -80,53 +80,70 @@ export function AutomationDetailsDrawer(props: {
80
80
  >
81
81
  <section className="grid gap-5">
82
82
  <div>
83
- <div className="mb-2 font-mono text-xs uppercase tracking-[0.12em] text-dashboard-text-muted">
84
- Instruction
85
- </div>
83
+ <h3 className="mt-0 mb-2 text-sm font-medium text-dashboard-text-muted">
84
+ What to do
85
+ </h3>
86
86
  <TranscriptText text={automation.instruction} />
87
87
  </div>
88
+ <AutomationLifecycle
89
+ key={`${automation.kind}:${automation.id}`}
90
+ automation={automation}
91
+ editPath={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/edit${location.search}`}
92
+ />
88
93
  <DetailList>
89
94
  {details.map((detail) => (
90
95
  <Detail key={detail.label} label={detail.label}>
91
96
  {detail.value}
92
97
  </Detail>
93
98
  ))}
94
- <Detail label="Outcomes">
95
- {automation.outcomes.length === 0
96
- ? "None"
97
- : `${automation.outcomes.length} message${automation.outcomes.length === 1 ? "" : "s"}`}
98
- </Detail>
99
- <Detail label="Destination">
100
- <span className="inline-flex items-center gap-1.5">
101
- <MapPin
102
- aria-hidden="true"
103
- className="text-cyan-300/70"
104
- size={13}
105
- />
106
- {automation.destination.label} ·{" "}
107
- {automation.destination.visibility}
108
- </span>
109
- </Detail>
110
- <Detail label="Created">
111
- {createdBy} · {formatDate(automation.createdAt)}
112
- </Detail>
113
- <Detail label="Executions">
114
- <AutomationExecutionSummary
115
- range={props.range}
116
- automation={automation}
117
- />
99
+ <Detail label="On success">
100
+ {automation.outcomes.length === 0 ? (
101
+ "No message on success"
102
+ ) : (
103
+ <ol className="m-0 pl-4">
104
+ {automation.outcomes.map((outcome, index) => (
105
+ <li key={index}>
106
+ {automationOutcomeLabel(outcome, automation.destination)}
107
+ </li>
108
+ ))}
109
+ </ol>
110
+ )}
118
111
  </Detail>
119
112
  </DetailList>
120
- {automation.totalRuns > 0 ? (
121
- <div className="pt-1">
122
- <Link
123
- className="inline-flex items-center justify-center rounded border border-white/12 bg-white/[0.03] px-3 py-2 font-mono text-xs font-medium text-dashboard-text no-underline transition-colors hover:border-white/25 hover:bg-white/[0.06]"
124
- to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
125
- >
126
- View all executions
127
- </Link>
128
- </div>
113
+ {automation.totalRuns > 0 &&
114
+ automation.lastRunStatus !== "failed" &&
115
+ automation.lastRunStatus !== "blocked" ? (
116
+ <Link
117
+ className="w-fit py-2 text-sm text-dashboard-text underline underline-offset-2"
118
+ to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
119
+ >
120
+ View all runs
121
+ </Link>
129
122
  ) : null}
123
+ <details>
124
+ <summary className="cursor-pointer py-3 text-sm text-dashboard-text-muted hover:text-dashboard-text focus-visible:outline focus-visible:outline-dashboard-focus">
125
+ More details
126
+ </summary>
127
+ <DetailList>
128
+ <Detail label="Connected accounts">
129
+ {automation.credentialMode === "creator" ? (
130
+ <>
131
+ Uses accounts connected by{" "}
132
+ {automation.ownedByViewer ? "you" : automation.createdBy}.
133
+ </>
134
+ ) : (
135
+ "Junior’s accounts only"
136
+ )}
137
+ </Detail>
138
+ <Detail label="Created">{formatDate(automation.createdAt)}</Detail>
139
+ <Detail label="Run history">
140
+ <AutomationExecutionSummary
141
+ range={props.range}
142
+ automation={automation}
143
+ />
144
+ </Detail>
145
+ </DetailList>
146
+ </details>
130
147
  </section>
131
148
  </Drawer>
132
149
  );
@@ -169,7 +186,7 @@ function AutomationExecutionSummary(props: {
169
186
  ) : (
170
187
  <>{automation.totalRuns} total</>
171
188
  )}
172
- {automation.lastRunAt ? " · Last execution " : " · Never run"}
189
+ {automation.lastRunAt ? " · Last run " : " · Never run"}
173
190
  {automation.lastRunAt && automation.lastConversationId ? (
174
191
  <Link
175
192
  className="text-dashboard-text underline decoration-white/20 underline-offset-2 hover:decoration-white/60"