@sentry/junior-dashboard 0.233.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 (59) hide show
  1. package/dist/app.js +178 -29
  2. package/dist/client.js +53 -50
  3. package/dist/index.js +178 -29
  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 +372 -345
  10. package/src/client/api.ts +60 -1
  11. package/src/client/components/ActorAvatar.tsx +37 -0
  12. package/src/client/components/Button.tsx +12 -10
  13. package/src/client/components/Field.tsx +14 -1
  14. package/src/client/components/FormNotice.tsx +40 -0
  15. package/src/client/components/ParticipantAvatarStack.tsx +6 -14
  16. package/src/client/components/README.md +19 -6
  17. package/src/client/components/SegmentedNav.tsx +167 -0
  18. package/src/client/components/SegmentedTabs.tsx +24 -5
  19. package/src/client/components/Select.tsx +18 -0
  20. package/src/client/components/StatusChip.tsx +15 -12
  21. package/src/client/components/TextInput.tsx +25 -5
  22. package/src/client/components/layout/SecondaryNavigation.tsx +40 -83
  23. package/src/client/http.ts +31 -3
  24. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +78 -68
  25. package/src/client/pages/automations/AutomationEditPage.tsx +171 -0
  26. package/src/client/pages/automations/AutomationEditor.tsx +397 -0
  27. package/src/client/pages/automations/AutomationEditorLoading.tsx +18 -0
  28. package/src/client/pages/automations/AutomationEventFields.tsx +264 -0
  29. package/src/client/pages/automations/AutomationFilters.tsx +179 -0
  30. package/src/client/pages/automations/AutomationFormSection.tsx +22 -0
  31. package/src/client/pages/automations/AutomationLifecycle.tsx +146 -0
  32. package/src/client/pages/automations/AutomationOutcomeFields.tsx +177 -0
  33. package/src/client/pages/automations/AutomationRow.tsx +343 -0
  34. package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
  35. package/src/client/pages/automations/AutomationsPage.tsx +186 -439
  36. package/src/client/pages/automations/AutomationsPageLayout.tsx +19 -7
  37. package/src/client/pages/automations/README.md +19 -0
  38. package/src/client/pages/automations/automationDraft.ts +110 -0
  39. package/src/client/pages/automations/automationFormat.ts +43 -0
  40. package/src/client/pages/automations/automationOutcomes.ts +23 -0
  41. package/src/client/pages/code/CodeChangeRow.tsx +41 -0
  42. package/src/client/pages/code/CodePage.tsx +18 -93
  43. package/src/client/pages/code/CodeSummaryCards.tsx +53 -0
  44. package/src/client/pages/code/RepositoryPage.tsx +227 -0
  45. package/src/client/pages/code/RepositorySections.tsx +108 -0
  46. package/src/client/pages/code/RepositoryTabs.tsx +146 -0
  47. package/src/client/pages/code/codeRepositoryRoutes.ts +18 -0
  48. package/src/client/pages/dev/ComponentsPage.tsx +55 -26
  49. package/src/client/pages/dev/SegmentedFixtures.tsx +58 -0
  50. package/src/client/pages/memory/MemoryPageLayout.tsx +12 -13
  51. package/src/client/pages/system/SystemNavigation.tsx +6 -2
  52. package/src/client/pages/system/SystemPageLayout.tsx +4 -6
  53. package/src/client/searchParams.ts +4 -2
  54. package/src/client.tsx +7 -4
  55. package/src/mock-reporting/automation-collection.ts +101 -0
  56. package/src/mock-reporting/code-repository.ts +77 -0
  57. package/src/mock-reporting/fixtures.ts +9 -3
  58. package/src/mock-reporting/routes.ts +39 -17
  59. package/src/shell.ts +21 -5
@@ -1,6 +1,10 @@
1
- import { Link, NavLink, useLocation } from "react-router";
1
+ import type { ReactNode } from "react";
2
2
 
3
- import { pathWithSearch } from "../../searchParams";
3
+ import {
4
+ SegmentedNav,
5
+ SiblingPageLink,
6
+ type SegmentedNavItem,
7
+ } from "../SegmentedNav";
4
8
  import {
5
9
  cn,
6
10
  dashboardContainerClass,
@@ -8,62 +12,20 @@ import {
8
12
  } from "../../styles";
9
13
  import { SecondaryNavigationPortal } from "./DashboardChrome";
10
14
 
11
- export type SecondaryNavigationItem = {
12
- end?: boolean;
13
- /** Override default NavLink matching when a path family should stay active. */
14
- isActive?: (pathname: string) => boolean;
15
- label: string;
16
- to: string;
17
- };
18
-
19
- type LinkClassState = { isActive: boolean };
20
-
21
- function SecondaryNavItem(props: {
22
- className: (state: LinkClassState) => string;
23
- item: SecondaryNavigationItem;
24
- pathname: string;
25
- search: string;
26
- }) {
27
- const { className, item, pathname, search } = props;
28
- // Keep page filters (range, scope, q) when moving across secondary sections.
29
- const to = pathWithSearch(item.to, search);
30
- if (item.isActive) {
31
- const isActive = item.isActive(pathname);
32
- return (
33
- <Link
34
- aria-current={isActive ? "page" : undefined}
35
- className={className({ isActive })}
36
- to={to}
37
- >
38
- {item.label}
39
- </Link>
40
- );
41
- }
42
-
43
- return (
44
- <NavLink className={className} end={item.end} to={to}>
45
- {item.label}
46
- </NavLink>
47
- );
48
- }
15
+ export type SecondaryNavigationItem = SegmentedNavItem;
49
16
 
50
- /** Render page navigation in the desktop chrome and mobile drawer. */
17
+ /**
18
+ * Render page navigation above the page, in the desktop chrome, and in the
19
+ * mobile menu. On mobile the links sit at the top of the page and scroll with
20
+ * it. Pass the page as children so the links and the page share one layout
21
+ * row.
22
+ */
51
23
  export function SecondaryNavigation(props: {
52
24
  ariaLabel: string;
25
+ children: ReactNode;
53
26
  items: SecondaryNavigationItem[];
54
27
  }) {
55
- const location = useLocation();
56
- const desktopLinkClass = ({ isActive }: LinkClassState) =>
57
- cn(
58
- "relative flex h-12 shrink-0 items-center px-3 font-display text-xs font-medium no-underline transition-colors after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:transition-colors sm:text-sm",
59
- isActive
60
- ? "text-dashboard-text after:bg-cyan-300"
61
- : cn(
62
- "after:bg-transparent hover:bg-white/[0.025]",
63
- dashboardInteractiveTextClass,
64
- ),
65
- );
66
- const mobileLinkClass = ({ isActive }: LinkClassState) =>
28
+ const menuLinkClass = ({ isActive }: { isActive: boolean }) =>
67
29
  cn(
68
30
  "rounded-lg px-3 py-3 pl-6 font-mono text-sm font-medium no-underline transition-colors",
69
31
  isActive
@@ -72,44 +34,39 @@ export function SecondaryNavigation(props: {
72
34
  );
73
35
 
74
36
  return (
75
- <SecondaryNavigationPortal
76
- desktop={
77
- <div className="border-b border-white/[0.06] bg-white/[0.018]">
37
+ <div className="min-w-0">
38
+ <SecondaryNavigationPortal
39
+ desktop={
40
+ <div className="border-b border-white/[0.06] bg-white/[0.018]">
41
+ <div
42
+ className={cn(
43
+ dashboardContainerClass,
44
+ "min-w-0 px-4 py-2 md:px-8",
45
+ )}
46
+ >
47
+ <SegmentedNav ariaLabel={props.ariaLabel} items={props.items} />
48
+ </div>
49
+ </div>
50
+ }
51
+ mobile={
78
52
  <nav
79
53
  aria-label={props.ariaLabel}
80
- className={cn(
81
- dashboardContainerClass,
82
- "flex min-w-0 gap-1 overflow-x-auto px-4 [scrollbar-width:none] md:px-8 [&::-webkit-scrollbar]:hidden",
83
- )}
54
+ className="mt-3 grid gap-1 border-t border-white/[0.07] pt-3"
84
55
  >
85
56
  {props.items.map((item) => (
86
- <SecondaryNavItem
87
- className={desktopLinkClass}
57
+ <SiblingPageLink
58
+ className={menuLinkClass}
88
59
  item={item}
89
60
  key={item.to}
90
- pathname={location.pathname}
91
- search={location.search}
92
61
  />
93
62
  ))}
94
63
  </nav>
95
- </div>
96
- }
97
- mobile={
98
- <nav
99
- aria-label={props.ariaLabel}
100
- className="mt-3 grid gap-1 border-t border-white/[0.07] pt-3"
101
- >
102
- {props.items.map((item) => (
103
- <SecondaryNavItem
104
- className={mobileLinkClass}
105
- item={item}
106
- key={item.to}
107
- pathname={location.pathname}
108
- search={location.search}
109
- />
110
- ))}
111
- </nav>
112
- }
113
- />
64
+ }
65
+ />
66
+ <div className="px-4 pt-4 sm:px-8 md:hidden">
67
+ <SegmentedNav ariaLabel={props.ariaLabel} fill items={props.items} />
68
+ </div>
69
+ {props.children}
70
+ </div>
114
71
  );
115
72
  }
@@ -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,13 @@
1
+ import { AutomationLifecycle } from "./AutomationLifecycle";
2
+ import { automationOutcomeLabel } from "./automationOutcomes";
3
+ import {
4
+ automationEventConditions,
5
+ automationEventNames,
6
+ automationScheduleLabel,
7
+ formatAutomationRunTime,
8
+ } from "./automationFormat";
1
9
  import type { AutomationSummary } from "@sentry/junior/api/schema";
2
- import { Link } from "react-router";
3
- import { MapPin } from "lucide-react";
10
+ import { Link, useLocation } from "react-router";
4
11
  import { Detail, DetailList } from "../../components/DetailList";
5
12
  import { Drawer } from "../../components/Drawer";
6
13
  import {
@@ -17,6 +24,7 @@ export function AutomationDetailsDrawer(props: {
17
24
  range: TimeRangeDays;
18
25
  automation: AutomationSummary | undefined;
19
26
  }) {
27
+ const location = useLocation();
20
28
  if (!props.automation) return null;
21
29
 
22
30
  const { automation } = props;
@@ -32,28 +40,25 @@ export function AutomationDetailsDrawer(props: {
32
40
  ) : (
33
41
  automation.createdBy
34
42
  );
43
+ const conditions =
44
+ automation.kind === "event" ? automationEventConditions(automation) : "";
35
45
  const details =
36
46
  automation.kind === "scheduled"
37
47
  ? [
38
- { label: "Schedule", value: automation.schedule },
48
+ { label: "Schedule", value: automationScheduleLabel(automation) },
39
49
  {
40
50
  label: "Next run",
41
- value: automation.nextRunAt
42
- ? formatRunDate(automation.nextRunAt)
43
- : "None",
51
+ value:
52
+ automation.status === "active" && automation.nextRunAt
53
+ ? formatAutomationRunTime(automation.nextRunAt)
54
+ : "None",
44
55
  },
45
56
  ]
46
57
  : [
47
- { label: "Resource", value: automation.resource },
48
- { label: "Events", value: automation.events.join(", ") },
58
+ { label: "Watching", value: automation.resource },
59
+ { label: "Events", value: automationEventNames(automation) },
60
+ ...(conditions ? [{ label: "Conditions", value: conditions }] : []),
49
61
  ];
50
- const statusLabel =
51
- automation.kind === "scheduled"
52
- ? automation.status
53
- : automation.triggerAvailable
54
- ? "ready"
55
- : "unavailable";
56
-
57
62
  const titleId = "automation-details-drawer-title";
58
63
 
59
64
  return (
@@ -68,9 +73,8 @@ export function AutomationDetailsDrawer(props: {
68
73
  >
69
74
  {automation.title}
70
75
  </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)}
76
+ <div className="mt-1 break-words text-sm text-dashboard-text-muted">
77
+ Created by {createdBy} · {automation.destination.label}
74
78
  </div>
75
79
  </>
76
80
  }
@@ -80,53 +84,70 @@ export function AutomationDetailsDrawer(props: {
80
84
  >
81
85
  <section className="grid gap-5">
82
86
  <div>
83
- <div className="mb-2 font-mono text-xs uppercase tracking-[0.12em] text-dashboard-text-muted">
84
- Instruction
85
- </div>
87
+ <h3 className="mt-0 mb-2 text-sm font-medium text-dashboard-text-muted">
88
+ What to do
89
+ </h3>
86
90
  <TranscriptText text={automation.instruction} />
87
91
  </div>
92
+ <AutomationLifecycle
93
+ key={`${automation.kind}:${automation.id}`}
94
+ automation={automation}
95
+ editPath={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/edit${location.search}`}
96
+ />
88
97
  <DetailList>
89
98
  {details.map((detail) => (
90
99
  <Detail key={detail.label} label={detail.label}>
91
100
  {detail.value}
92
101
  </Detail>
93
102
  ))}
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
- />
103
+ <Detail label="On success">
104
+ {automation.outcomes.length === 0 ? (
105
+ "No message on success"
106
+ ) : (
107
+ <ol className="m-0 pl-4">
108
+ {automation.outcomes.map((outcome, index) => (
109
+ <li key={index}>
110
+ {automationOutcomeLabel(outcome, automation.destination)}
111
+ </li>
112
+ ))}
113
+ </ol>
114
+ )}
118
115
  </Detail>
119
116
  </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>
117
+ {automation.totalRuns > 0 &&
118
+ automation.lastRunStatus !== "failed" &&
119
+ automation.lastRunStatus !== "blocked" ? (
120
+ <Link
121
+ className="w-fit py-2 text-sm text-dashboard-text underline underline-offset-2"
122
+ to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
123
+ >
124
+ View all runs
125
+ </Link>
129
126
  ) : null}
127
+ <details>
128
+ <summary className="cursor-pointer py-3 text-sm text-dashboard-text-muted hover:text-dashboard-text focus-visible:outline focus-visible:outline-dashboard-focus">
129
+ More details
130
+ </summary>
131
+ <DetailList>
132
+ <Detail label="Connected accounts">
133
+ {automation.credentialMode === "creator" ? (
134
+ <>
135
+ Uses accounts connected by{" "}
136
+ {automation.ownedByViewer ? "you" : automation.createdBy}.
137
+ </>
138
+ ) : (
139
+ "Junior’s accounts only"
140
+ )}
141
+ </Detail>
142
+ <Detail label="Created">{formatDate(automation.createdAt)}</Detail>
143
+ <Detail label="Run history">
144
+ <AutomationExecutionSummary
145
+ range={props.range}
146
+ automation={automation}
147
+ />
148
+ </Detail>
149
+ </DetailList>
150
+ </details>
130
151
  </section>
131
152
  </Drawer>
132
153
  );
@@ -169,16 +190,16 @@ function AutomationExecutionSummary(props: {
169
190
  ) : (
170
191
  <>{automation.totalRuns} total</>
171
192
  )}
172
- {automation.lastRunAt ? " · Last execution " : " · Never run"}
193
+ {automation.lastRunAt ? " · Last run " : " · Never run"}
173
194
  {automation.lastRunAt && automation.lastConversationId ? (
174
195
  <Link
175
196
  className="text-dashboard-text underline decoration-white/20 underline-offset-2 hover:decoration-white/60"
176
197
  to={conversationPath(automation.lastConversationId)}
177
198
  >
178
- {formatRunDate(automation.lastRunAt)}
199
+ {formatAutomationRunTime(automation.lastRunAt)}
179
200
  </Link>
180
201
  ) : automation.lastRunAt ? (
181
- formatRunDate(automation.lastRunAt)
202
+ formatAutomationRunTime(automation.lastRunAt)
182
203
  ) : null}
183
204
  </span>
184
205
  </div>
@@ -191,14 +212,3 @@ function formatDate(value: string): string {
191
212
  timeStyle: "short",
192
213
  });
193
214
  }
194
-
195
- function formatRunDate(value: string): string {
196
- return formatTime(value, {
197
- day: "numeric",
198
- hour: "numeric",
199
- minute: "2-digit",
200
- month: "short",
201
- timeZoneName: "short",
202
- year: "numeric",
203
- });
204
- }
@@ -0,0 +1,171 @@
1
+ import { Link, useLocation, useParams } from "react-router";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { ArrowLeft } from "lucide-react";
4
+ import {
5
+ automationEditSchema,
6
+ type AutomationSummary,
7
+ } from "@sentry/junior/api/schema";
8
+ import { useAutomationData } from "../../api";
9
+ import { fetchDashboardJson } from "../../http";
10
+ import { Button } from "../../components/Button";
11
+ import { FormNotice } from "../../components/FormNotice";
12
+ import { StatusChip } from "../../components/StatusChip";
13
+ import { AutomationsNavigation } from "./AutomationsPageLayout";
14
+ import { AutomationEditor } from "./AutomationEditor";
15
+ import { AutomationEditorLoading } from "./AutomationEditorLoading";
16
+ import { AutomationFormSection } from "./AutomationFormSection";
17
+ import { automationOutcomeLabel } from "./automationOutcomes";
18
+
19
+ /** Load a linkable editor, with readable public values for non-creators. */
20
+ export function AutomationEditPage(props: { enabled: boolean }) {
21
+ const { kind, automationId } = useParams();
22
+ const location = useLocation();
23
+ const summary = useAutomationData(props.enabled, automationId);
24
+ const canEdit =
25
+ summary.data?.ownedByViewer &&
26
+ !(summary.data.kind === "scheduled" && summary.data.status === "completed");
27
+ const edit = useQuery({
28
+ queryKey: ["dashboard", "automations", "edit", kind, automationId],
29
+ enabled: props.enabled && Boolean(canEdit) && summary.data?.kind === kind,
30
+ retry: false,
31
+ queryFn: ({ signal }) =>
32
+ fetchDashboardJson(
33
+ automationEditSchema,
34
+ `/api/automations/${kind}/${encodeURIComponent(automationId!)}/edit`,
35
+ signal,
36
+ ),
37
+ });
38
+ const returnPath = `/automations/list${location.search}`;
39
+ const value = summary.data;
40
+ const status =
41
+ value?.kind === "event"
42
+ ? value.status === "paused"
43
+ ? "paused"
44
+ : value.triggerAvailable
45
+ ? "active"
46
+ : "unavailable"
47
+ : value?.status;
48
+ const statusTone =
49
+ status === "active"
50
+ ? "success"
51
+ : status === "blocked" || status === "unavailable"
52
+ ? "warning"
53
+ : "neutral";
54
+ return (
55
+ <AutomationsNavigation>
56
+ <div className="mx-auto w-full min-w-0 max-w-5xl px-4 pt-7 sm:px-8 sm:pt-9">
57
+ <Link
58
+ to={returnPath}
59
+ className="inline-flex min-h-8 items-center gap-2 text-sm text-dashboard-text-muted no-underline hover:text-dashboard-text"
60
+ >
61
+ <ArrowLeft aria-hidden size={15} />
62
+ All automations
63
+ </Link>
64
+ <div className="mt-4 mb-8 flex flex-wrap items-start justify-between gap-4">
65
+ <div className="min-w-0">
66
+ <h1 className="m-0 break-words font-display text-3xl font-light tracking-tight">
67
+ {value && !canEdit ? value.title : "Edit automation"}
68
+ </h1>
69
+ {value ? (
70
+ <p className="mt-2 mb-0 text-sm text-dashboard-text-muted">
71
+ Created by {value.ownedByViewer ? "you" : value.createdBy} ·{" "}
72
+ {value.destination.label}
73
+ </p>
74
+ ) : null}
75
+ </div>
76
+ {value ? (
77
+ <StatusChip tone={statusTone}>
78
+ {status === "unavailable" ? "Trigger unavailable" : status}
79
+ </StatusChip>
80
+ ) : null}
81
+ </div>
82
+ {(!value && summary.error) ||
83
+ (!edit.data && edit.error) ||
84
+ (value && value.kind !== kind) ? (
85
+ <FormNotice tone="error" title="Automation could not be loaded.">
86
+ It may no longer be available, or you may not have access.{" "}
87
+ <Button
88
+ onClick={() => {
89
+ void summary.refetch();
90
+ if (canEdit) void edit.refetch();
91
+ }}
92
+ >
93
+ Try again
94
+ </Button>
95
+ </FormNotice>
96
+ ) : !value || (canEdit && !edit.data) ? (
97
+ <AutomationEditorLoading />
98
+ ) : canEdit && edit.data ? (
99
+ <AutomationEditor
100
+ key={`${kind}:${automationId}`}
101
+ automation={edit.data}
102
+ summary={value}
103
+ returnPath={returnPath}
104
+ />
105
+ ) : (
106
+ <AutomationReadOnly automation={value} />
107
+ )}
108
+ </div>
109
+ </AutomationsNavigation>
110
+ );
111
+ }
112
+
113
+ function AutomationReadOnly({ automation }: { automation: AutomationSummary }) {
114
+ return (
115
+ <>
116
+ <p className="mb-6 text-sm text-dashboard-text-muted">
117
+ {automation.ownedByViewer
118
+ ? "This automation has completed and cannot be edited. It will not run again."
119
+ : `Only ${automation.createdBy} can edit this automation.`}
120
+ </p>
121
+ <AutomationFormSection title="What to do" detail="The saved instruction.">
122
+ <p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed">
123
+ {automation.instruction}
124
+ </p>
125
+ </AutomationFormSection>
126
+ <AutomationFormSection title="When to run" detail="The saved trigger.">
127
+ {automation.kind === "scheduled" ? (
128
+ <p className="m-0 text-sm">
129
+ {automation.schedule} · {automation.timezone}
130
+ </p>
131
+ ) : (
132
+ <div className="grid gap-3 text-sm">
133
+ <p className="m-0 break-words">{automation.resource}</p>
134
+ <p className="m-0 break-words">{automation.events.join(", ")}</p>
135
+ {automation.match ? (
136
+ <pre className="whitespace-pre-wrap break-words text-xs">
137
+ {JSON.stringify(automation.match, null, 2)}
138
+ </pre>
139
+ ) : null}
140
+ </div>
141
+ )}
142
+ </AutomationFormSection>
143
+ <AutomationFormSection
144
+ title="Where results go"
145
+ detail="Messages after successful work."
146
+ >
147
+ {automation.outcomes.length ? (
148
+ <ol className="m-0 grid gap-2 pl-5 text-sm">
149
+ {automation.outcomes.map((outcome, index) => (
150
+ <li key={index}>
151
+ {automationOutcomeLabel(outcome, automation.destination)}
152
+ </li>
153
+ ))}
154
+ </ol>
155
+ ) : (
156
+ <p className="m-0 text-sm">No success message</p>
157
+ )}
158
+ </AutomationFormSection>
159
+ <AutomationFormSection
160
+ title="Credentials"
161
+ detail="The accounts Junior can use."
162
+ >
163
+ <p className="m-0 text-sm">
164
+ {automation.credentialMode === "creator"
165
+ ? `Uses ${automation.ownedByViewer ? "your" : `${automation.createdBy}’s`} connected accounts.`
166
+ : "System credentials only."}
167
+ </p>
168
+ </AutomationFormSection>
169
+ </>
170
+ );
171
+ }