@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
@@ -11,7 +11,11 @@ import { SecondaryNavigation } from "../../components/layout/SecondaryNavigation
11
11
  const RESERVED_TASK_SEGMENTS = new Set(["list", "runs"]);
12
12
 
13
13
  function isTasksListPath(pathname: string): boolean {
14
- if (pathname === "/automations/list") return true;
14
+ if (
15
+ pathname === "/automations/list" ||
16
+ /^\/automations\/(scheduled|event)\/[^/]+\/edit$/.test(pathname)
17
+ )
18
+ return true;
15
19
  const match = pathname.match(/^\/automations\/([^/]+)$/);
16
20
  return Boolean(match?.[1] && !RESERVED_TASK_SEGMENTS.has(match[1]));
17
21
  }
@@ -22,16 +26,24 @@ const taskNavigationItems = [
22
26
  { label: "Runs", to: "/automations/runs" },
23
27
  ];
24
28
 
29
+ /** Keep Automation navigation consistent across lists, details, and editing. */
30
+ export function AutomationsNavigation(props: { children: ReactNode }) {
31
+ return (
32
+ <SecondaryNavigation
33
+ ariaLabel="Automations navigation"
34
+ items={taskNavigationItems}
35
+ >
36
+ {props.children}
37
+ </SecondaryNavigation>
38
+ );
39
+ }
40
+
25
41
  /** Place the shared secondary navigation above one Automations page. */
26
42
  export function AutomationsPageLayout(props: { children: ReactNode }) {
27
43
  return (
28
- <>
29
- <SecondaryNavigation
30
- ariaLabel="Automations navigation"
31
- items={taskNavigationItems}
32
- />
44
+ <AutomationsNavigation>
33
45
  <PageLayout>{props.children}</PageLayout>
34
- </>
46
+ </AutomationsNavigation>
35
47
  );
36
48
  }
37
49
 
@@ -0,0 +1,19 @@
1
+ # Automation editor
2
+
3
+ Core owns validation, creator access, revision checks, Schedule previews, and
4
+ the Event catalog. Preview requests cannot save or run work. Completed scheduled
5
+ Automations remain read-only.
6
+
7
+ The editor sends only changed fields. Keep unsupported triggers and stored
8
+ Destinations intact unless the user replaces them. Outcomes keep their order.
9
+ New messages can target only the current Destination or the creator.
10
+
11
+ Query refreshes must not replace an unsaved draft. After a stale save, the user
12
+ can keep their changed fields or load the latest saved version. Unchanged fields
13
+ come from that version; core checks its revision again on save.
14
+
15
+ The data router blocks navigation with unsaved changes. Browser unload uses the
16
+ native prompt. Save and Cancel keep the list query string.
17
+
18
+ Browser behavior lives in `e2e/automation-editor.spec.ts`. API and persistence
19
+ coverage lives in core's `tests/integration/api/automation-edits.test.ts`.
@@ -0,0 +1,110 @@
1
+ import type {
2
+ AutomationEdit,
3
+ AutomationScheduleIntent,
4
+ AutomationUpdate,
5
+ } from "@sentry/junior/api/schema";
6
+
7
+ export const weekdays = [
8
+ "sunday",
9
+ "monday",
10
+ "tuesday",
11
+ "wednesday",
12
+ "thursday",
13
+ "friday",
14
+ "saturday",
15
+ ] as const;
16
+ export type AutomationScheduleDraft =
17
+ | Extract<AutomationScheduleIntent, { kind: "recurring" }>
18
+ | {
19
+ kind: "one_off";
20
+ timezone: string;
21
+ timing: { type: "at"; date: string; time: string };
22
+ };
23
+
24
+ export type AutomationDraft = {
25
+ title: string;
26
+ instruction: string;
27
+ credentialMode: AutomationEdit["credentialMode"];
28
+ outcomes: NonNullable<AutomationUpdate["outcomes"]>;
29
+ schedule?: AutomationScheduleDraft;
30
+ trigger?: Extract<AutomationEdit, { kind: "event" }>["trigger"];
31
+ };
32
+
33
+ /** Keep stored values intact until a user edits that field. */
34
+ export function createAutomationDraft(value: AutomationEdit): AutomationDraft {
35
+ return {
36
+ title: value.title ?? "",
37
+ instruction: value.instruction,
38
+ credentialMode: value.credentialMode,
39
+ outcomes: value.outcomes,
40
+ trigger: value.kind === "event" ? value.trigger : undefined,
41
+ };
42
+ }
43
+
44
+ /** Populate calendar controls without changing the saved start date. */
45
+ export function scheduleDraft(
46
+ value: Extract<AutomationEdit, { kind: "scheduled" }>,
47
+ ): AutomationScheduleDraft {
48
+ const recurrence = value.schedule.recurrence;
49
+ if (value.schedule.kind === "recurring" && recurrence) {
50
+ const { time, weekdays: days, ...fields } = recurrence;
51
+ return {
52
+ kind: "recurring",
53
+ ...fields,
54
+ timezone: value.schedule.timezone,
55
+ time: `${String(time.hour).padStart(2, "0")}:${String(time.minute).padStart(2, "0")}`,
56
+ weekdays: days?.map((day) => weekdays[day]),
57
+ };
58
+ }
59
+ if (value.nextRunAtMs) {
60
+ const parts = new Intl.DateTimeFormat("en-CA", {
61
+ timeZone: value.schedule.timezone,
62
+ year: "numeric",
63
+ month: "2-digit",
64
+ day: "2-digit",
65
+ hour: "2-digit",
66
+ minute: "2-digit",
67
+ hourCycle: "h23",
68
+ }).formatToParts(value.nextRunAtMs);
69
+ const part = (name: string) =>
70
+ parts.find((p) => p.type === name)?.value ?? "";
71
+ return {
72
+ kind: "one_off",
73
+ timezone: value.schedule.timezone,
74
+ timing: {
75
+ type: "at",
76
+ date: `${part("year")}-${part("month")}-${part("day")}`,
77
+ time: `${part("hour")}:${part("minute")}`,
78
+ },
79
+ };
80
+ }
81
+ return {
82
+ kind: "recurring",
83
+ frequency: "daily",
84
+ time: "09:00",
85
+ timezone: value.schedule.timezone,
86
+ };
87
+ }
88
+
89
+ /** Keep metadata edits from replacing unchanged triggers or outcomes. */
90
+ export function automationDraftChanges(
91
+ original: AutomationEdit,
92
+ draft: AutomationDraft,
93
+ ): Partial<AutomationDraft> {
94
+ const changes: Partial<AutomationDraft> = {};
95
+ if (draft.title !== (original.title ?? "")) changes.title = draft.title;
96
+ if (draft.instruction !== original.instruction)
97
+ changes.instruction = draft.instruction;
98
+ if (draft.credentialMode !== original.credentialMode)
99
+ changes.credentialMode = draft.credentialMode;
100
+ if (JSON.stringify(draft.outcomes) !== JSON.stringify(original.outcomes))
101
+ changes.outcomes = draft.outcomes;
102
+ if (original.kind === "scheduled" && draft.schedule)
103
+ changes.schedule = draft.schedule;
104
+ if (
105
+ original.kind === "event" &&
106
+ JSON.stringify(draft.trigger) !== JSON.stringify(original.trigger)
107
+ )
108
+ changes.trigger = draft.trigger;
109
+ return changes;
110
+ }
@@ -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,23 @@
1
+ import type {
2
+ AutomationSummary,
3
+ AutomationUpdate,
4
+ } from "@sentry/junior/api/schema";
5
+
6
+ type Outcome = NonNullable<AutomationUpdate["outcomes"]>[number];
7
+
8
+ /** Describe a retained Destination without guessing a recipient from a private id. */
9
+ export function automationOutcomeLabel(
10
+ outcome: Outcome,
11
+ destination: AutomationSummary["destination"],
12
+ ): string {
13
+ if (typeof outcome.destination === "string")
14
+ return outcome.destination === "task_creator"
15
+ ? "Creator · Direct message"
16
+ : `${destination.label} · ${destination.visibility}`;
17
+ if (
18
+ outcome.destination.channelId === destination.channelId &&
19
+ outcome.destination.teamId === destination.teamId
20
+ )
21
+ return `${destination.label} · ${destination.visibility}`;
22
+ return `Saved destination · ${outcome.destination.channelId}`;
23
+ }
@@ -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
+ }
@@ -1,11 +1,9 @@
1
1
  import { useState } from "react";
2
2
  import type { CodeOverviewReport } from "@sentry/junior/api/schema";
3
- import { Coins, GitPullRequest, LibraryBig, Timer } from "lucide-react";
3
+ import { Link } from "react-router";
4
4
  import { useCodeOverviewData } from "../../api";
5
- import { formatDuration } from "../../components/Duration";
6
5
  import { EmptyTelemetry } from "../../components/EmptyTelemetry";
7
6
  import { PageRouteLoading } from "../../components/PageRouteLoading";
8
- import { StatusChip } from "../../components/StatusChip";
9
7
  import {
10
8
  selectTimeSeries,
11
9
  timeRangeBucketUnit,
@@ -14,29 +12,14 @@ import {
14
12
  import { Card } from "../../components/layout/Card";
15
13
  import { PageHeader } from "../../components/layout/PageHeader";
16
14
  import { PageLayout } from "../../components/layout/PageLayout";
17
- import { StatCard } from "../../components/metrics/StatCard";
18
- import { formatCompactNumber, formatCostSummary } from "../../format";
19
15
  import { CodeActivityChart } from "./CodeActivityChart";
20
-
21
- function mergeRate(value: number | undefined): string {
22
- return value === undefined ? "—" : `${Math.round(value * 100)}%`;
23
- }
24
-
25
- function medianMergeTime(value: number | undefined): string {
26
- return formatDuration(value) || "—";
27
- }
28
-
29
- function costUsd(value: number | undefined): string {
30
- return (
31
- formatCostSummary(value === undefined ? undefined : { total: value }) || "—"
32
- );
33
- }
34
-
35
- function stateTone(state: "closed" | "merged" | "open") {
36
- if (state === "merged") return "success" as const;
37
- if (state === "open") return "info" as const;
38
- return "neutral" as const;
39
- }
16
+ import { CodeChangeRow } from "./CodeChangeRow";
17
+ import {
18
+ CodeSummaryCards,
19
+ formatCodeCost,
20
+ formatMergeRate,
21
+ } from "./CodeSummaryCards";
22
+ import { codeRepositoryPath } from "./codeRepositoryRoutes";
40
23
 
41
24
  /** Render code analytics and recent code changes. */
42
25
  export function CodePage() {
@@ -77,32 +60,7 @@ function CodeOverview(props: {
77
60
  const data = props.data;
78
61
  return (
79
62
  <>
80
- <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
81
- <StatCard
82
- detail="In the last 30 days"
83
- icon={GitPullRequest}
84
- label="Created"
85
- value={formatCompactNumber(data.summary.created)}
86
- />
87
- <StatCard
88
- detail="Share of completed changes that merged"
89
- icon={LibraryBig}
90
- label="Merge rate"
91
- value={mergeRate(data.summary.mergeRate)}
92
- />
93
- <StatCard
94
- detail="Median time from open to merge in the last 30 days"
95
- icon={Timer}
96
- label="Median merge time"
97
- value={medianMergeTime(data.summary.medianMergeTimeMs)}
98
- />
99
- <StatCard
100
- detail="Conversation cost for changes opened in the last 30 days"
101
- icon={Coins}
102
- label="Cost"
103
- value={costUsd(data.summary.costUsd)}
104
- />
105
- </div>
63
+ <CodeSummaryCards summary={data.summary} />
106
64
  <CodeActivityChart
107
65
  bucketUnit={timeRangeBucketUnit(props.range)}
108
66
  days={selectTimeSeries({
@@ -160,18 +118,12 @@ function CodeOverview(props: {
160
118
  >
161
119
  <td className="min-w-0 px-4 py-3">
162
120
  <div className="truncate font-display text-sm text-dashboard-text">
163
- {repository.url ? (
164
- <a
165
- className="text-inherit no-underline hover:text-cyan-100"
166
- href={repository.url}
167
- rel="noreferrer"
168
- target="_blank"
169
- >
170
- {repository.name}
171
- </a>
172
- ) : (
173
- repository.name
174
- )}
121
+ <Link
122
+ className="text-inherit no-underline hover:text-cyan-100"
123
+ to={codeRepositoryPath(repository.id)}
124
+ >
125
+ {repository.name}
126
+ </Link>
175
127
  </div>
176
128
  <div className="mt-1 truncate font-mono text-xs text-dashboard-text-muted">
177
129
  {repository.provider}
@@ -181,10 +133,10 @@ function CodeOverview(props: {
181
133
  {repository.created}
182
134
  </td>
183
135
  <td className="px-4 py-3 text-right font-mono text-sm whitespace-nowrap text-dashboard-text">
184
- {mergeRate(repository.mergeRate)}
136
+ {formatMergeRate(repository.mergeRate)}
185
137
  </td>
186
138
  <td className="px-4 py-3 text-right font-mono text-sm whitespace-nowrap text-dashboard-text">
187
- {costUsd(repository.medianCostUsd)}
139
+ {formatCodeCost(repository.medianCostUsd)}
188
140
  </td>
189
141
  </tr>
190
142
  ))}
@@ -200,34 +152,7 @@ function CodeOverview(props: {
200
152
  </div>
201
153
  <div>
202
154
  {data.changes.map((change) => (
203
- <div
204
- 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"
205
- key={change.id}
206
- >
207
- <div className="min-w-0">
208
- <div className="truncate font-display text-sm text-dashboard-text">
209
- {change.url ? (
210
- <a
211
- className="text-inherit no-underline hover:text-cyan-100"
212
- href={change.url}
213
- rel="noreferrer"
214
- target="_blank"
215
- >
216
- {change.title ??
217
- `${change.repository} #${change.number}`}
218
- </a>
219
- ) : (
220
- (change.title ?? `${change.repository} #${change.number}`)
221
- )}
222
- </div>
223
- <div className="mt-1 truncate font-mono text-xs text-dashboard-text-muted">
224
- {change.repository} #{change.number} · {change.provider}
225
- </div>
226
- </div>
227
- <StatusChip size="compact" tone={stateTone(change.state)}>
228
- {change.state}
229
- </StatusChip>
230
- </div>
155
+ <CodeChangeRow change={change} key={change.id} />
231
156
  ))}
232
157
  </div>
233
158
  </Card>
@@ -0,0 +1,53 @@
1
+ import type { CodeOverviewReport } from "@sentry/junior/api/schema";
2
+ import { Coins, GitPullRequest, LibraryBig, Timer } from "lucide-react";
3
+
4
+ import { formatDuration } from "../../components/Duration";
5
+ import { StatCard } from "../../components/metrics/StatCard";
6
+ import { formatCompactNumber, formatCostSummary } from "../../format";
7
+
8
+ /** Format a merge rate, or a dash when no change has finished. */
9
+ export function formatMergeRate(value: number | undefined): string {
10
+ return value === undefined ? "—" : `${Math.round(value * 100)}%`;
11
+ }
12
+
13
+ /** Format a code cost, or a dash when no cost is recorded. */
14
+ export function formatCodeCost(value: number | undefined): string {
15
+ return (
16
+ formatCostSummary(value === undefined ? undefined : { total: value }) || "—"
17
+ );
18
+ }
19
+
20
+ /** Show the 30-day code change summary cards. */
21
+ export function CodeSummaryCards(props: {
22
+ summary: CodeOverviewReport["summary"];
23
+ }) {
24
+ const summary = props.summary;
25
+ return (
26
+ <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
27
+ <StatCard
28
+ detail="In the last 30 days"
29
+ icon={GitPullRequest}
30
+ label="Created"
31
+ value={formatCompactNumber(summary.created)}
32
+ />
33
+ <StatCard
34
+ detail="Share of completed changes that merged"
35
+ icon={LibraryBig}
36
+ label="Merge rate"
37
+ value={formatMergeRate(summary.mergeRate)}
38
+ />
39
+ <StatCard
40
+ detail="Median time from open to merge in the last 30 days"
41
+ icon={Timer}
42
+ label="Median merge time"
43
+ value={formatDuration(summary.medianMergeTimeMs) || "—"}
44
+ />
45
+ <StatCard
46
+ detail="Conversation cost for changes opened in the last 30 days"
47
+ icon={Coins}
48
+ label="Cost"
49
+ value={formatCodeCost(summary.costUsd)}
50
+ />
51
+ </div>
52
+ );
53
+ }