@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
@@ -0,0 +1,179 @@
1
+ import { useId, useState } from "react";
2
+ import { SlidersHorizontal } from "lucide-react";
3
+ import { cn } from "../../styles";
4
+ import type {
5
+ AutomationList,
6
+ AutomationListQuery,
7
+ } from "@sentry/junior/api/schema";
8
+ import { Button, ToggleButton } from "../../components/Button";
9
+ import { DirectorySortSelect } from "../../components/controls/DirectorySortSelect";
10
+ import { FilterGroup } from "../../components/FilterBar";
11
+ import { SearchInput } from "../../components/SearchInput";
12
+
13
+ /** Keep collection controls visible while a filtered page loads. */
14
+ export function AutomationFilters(props: {
15
+ filters: AutomationListQuery;
16
+ data: AutomationList | undefined;
17
+ onChange(key: string, value: string): void;
18
+ searchText: string;
19
+ onSearch(value: string): void;
20
+ }) {
21
+ const { filters, data, onChange } = props;
22
+ const [expanded, setExpanded] = useState(false);
23
+ const controlsId = useId();
24
+ const activeCount = [
25
+ filters.type !== "all",
26
+ Boolean(filters.creator),
27
+ Boolean(filters.destination),
28
+ filters.state !== "all",
29
+ ].filter(Boolean).length;
30
+ return (
31
+ <div className="grid gap-3">
32
+ <div className="grid gap-2 lg:grid-cols-[auto_minmax(16rem,1fr)] lg:items-center lg:gap-4">
33
+ <div aria-label="Scope" role="group" className="flex flex-wrap gap-2">
34
+ {(
35
+ [
36
+ ["all", "All"],
37
+ ["mine", "Mine"],
38
+ ["public", "Public"],
39
+ ["attention", "Needs attention"],
40
+ ] as const
41
+ ).map(([value, label]) => (
42
+ <ToggleButton
43
+ className="min-h-11 font-sans! normal-case! lg:min-h-9"
44
+ key={value}
45
+ onClick={() => onChange("scope", value)}
46
+ pressed={filters.scope === value}
47
+ variant="pill"
48
+ >
49
+ {label}
50
+ {data && value !== "attention" ? (
51
+ <span className="ml-1.5 opacity-65">{data.counts[value]}</span>
52
+ ) : null}
53
+ </ToggleButton>
54
+ ))}
55
+ </div>
56
+ <div className="flex items-center gap-2">
57
+ <SearchInput
58
+ className="flex-1"
59
+ size="default"
60
+ label="Search automations"
61
+ placeholder="Search automations"
62
+ value={props.searchText}
63
+ onChange={props.onSearch}
64
+ />
65
+ <Button
66
+ className="h-11 shrink-0"
67
+ aria-expanded={expanded}
68
+ aria-controls={controlsId}
69
+ onClick={() => setExpanded(!expanded)}
70
+ >
71
+ <SlidersHorizontal aria-hidden="true" size={14} /> Filters
72
+ {activeCount ? ` (${activeCount})` : ""}
73
+ </Button>
74
+ </div>
75
+ </div>
76
+ <div
77
+ id={controlsId}
78
+ className={cn(
79
+ "grid-cols-2 items-end gap-3 lg:grid-cols-3 xl:grid-cols-[auto_repeat(3,minmax(0,1fr))]",
80
+ expanded ? "grid" : "hidden",
81
+ )}
82
+ >
83
+ <FilterGroup
84
+ className="col-span-2 lg:col-span-3 xl:col-span-1"
85
+ label="Type"
86
+ >
87
+ {(["all", "scheduled", "event"] as const).map((value) => (
88
+ <ToggleButton
89
+ className="min-h-9"
90
+ key={value}
91
+ onClick={() => onChange("type", value)}
92
+ pressed={filters.type === value}
93
+ variant="pill"
94
+ >
95
+ {value}
96
+ </ToggleButton>
97
+ ))}
98
+ </FilterGroup>
99
+ <AutomationFilterSelect
100
+ label="Creator"
101
+ emptyLabel="All"
102
+ value={filters.creator ?? ""}
103
+ options={data?.creators ?? []}
104
+ onChange={(value) => onChange("creator", value)}
105
+ />
106
+ <AutomationFilterSelect
107
+ label="Destination"
108
+ emptyLabel="All"
109
+ value={filters.destination ?? ""}
110
+ options={data?.destinations ?? []}
111
+ onChange={(value) => onChange("destination", value)}
112
+ />
113
+ <AutomationFilterSelect
114
+ label="State"
115
+ emptyLabel="All"
116
+ value={filters.state === "all" ? "" : filters.state}
117
+ options={[
118
+ { value: "active", label: "Active" },
119
+ { value: "blocked", label: "Blocked" },
120
+ { value: "paused", label: "Paused" },
121
+ { value: "unavailable", label: "Trigger unavailable" },
122
+ { value: "completed", label: "Completed" },
123
+ ]}
124
+ onChange={(value) => onChange("state", value)}
125
+ />
126
+ </div>
127
+ </div>
128
+ );
129
+ }
130
+
131
+ /** Keep list order visible beside the result count instead of inside Filters. */
132
+ export function AutomationSort(props: {
133
+ value: AutomationListQuery["sort"];
134
+ onChange(value: string): void;
135
+ }) {
136
+ return (
137
+ <DirectorySortSelect
138
+ ariaLabel="Sort automations"
139
+ onChange={props.onChange}
140
+ options={[
141
+ { value: "newest", label: "Recently created" },
142
+ { value: "oldest", label: "Oldest created" },
143
+ { value: "title", label: "Title A–Z" },
144
+ ]}
145
+ value={props.value}
146
+ />
147
+ );
148
+ }
149
+
150
+ function AutomationFilterSelect(props: {
151
+ label: string;
152
+ ariaLabel?: string;
153
+ emptyLabel?: string;
154
+ value: string;
155
+ options: Array<{ value: string; label: string }>;
156
+ onChange(value: string): void;
157
+ }) {
158
+ return (
159
+ <FilterGroup label={props.label}>
160
+ <select
161
+ aria-label={props.ariaLabel ?? `Filter by ${props.label.toLowerCase()}`}
162
+ className="h-9 w-full min-w-0 rounded-lg border border-dashboard-border-emphasis bg-dashboard-control px-2 text-sm text-dashboard-text transition-colors hover:border-dashboard-border-interactive focus:border-dashboard-focus focus:outline-none focus:ring-1 focus:ring-dashboard-focus/25 [color-scheme:var(--dashboard-color-scheme,dark)]"
163
+ value={props.value}
164
+ onChange={(event) => props.onChange(event.currentTarget.value)}
165
+ >
166
+ {props.emptyLabel ? <option value="">{props.emptyLabel}</option> : null}
167
+ {props.value &&
168
+ !props.options.some((option) => option.value === props.value) ? (
169
+ <option value={props.value}>{props.value}</option>
170
+ ) : null}
171
+ {props.options.map((option) => (
172
+ <option key={option.value} value={option.value}>
173
+ {option.label}
174
+ </option>
175
+ ))}
176
+ </select>
177
+ </FilterGroup>
178
+ );
179
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /** Keep section headings and fields aligned without nested form cards. */
4
+ export function AutomationFormSection(props: {
5
+ title: string;
6
+ detail?: string;
7
+ children: ReactNode;
8
+ }) {
9
+ return (
10
+ <section className="grid min-w-0 gap-5 border-t border-dashboard-border-emphasis py-8 md:grid-cols-[12rem_minmax(0,1fr)] md:gap-10">
11
+ <div>
12
+ <h2 className="m-0 font-display text-lg font-medium">{props.title}</h2>
13
+ {props.detail ? (
14
+ <p className="mt-2 mb-0 text-sm leading-relaxed text-dashboard-text-muted">
15
+ {props.detail}
16
+ </p>
17
+ ) : null}
18
+ </div>
19
+ <div className="grid min-w-0 content-start gap-5">{props.children}</div>
20
+ </section>
21
+ );
22
+ }
@@ -0,0 +1,146 @@
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import {
3
+ automationEditSchema,
4
+ type AutomationSummary,
5
+ } from "@sentry/junior/api/schema";
6
+ import { Link } from "react-router";
7
+ import { Button, ButtonLink } from "../../components/Button";
8
+ import { StatusChip } from "../../components/StatusChip";
9
+ import { FormNotice } from "../../components/FormNotice";
10
+ import { DashboardApiError, fetchDashboardJson, post } from "../../http";
11
+
12
+ /** Explain lifecycle separately from the last execution and offer creator-only actions. */
13
+ export function AutomationLifecycle({
14
+ automation,
15
+ editPath,
16
+ }: {
17
+ automation: AutomationSummary;
18
+ editPath: string;
19
+ }) {
20
+ const queryClient = useQueryClient();
21
+ const completed = automation.status === "completed";
22
+ const paused = automation.status === "paused";
23
+ const blocked = automation.status === "blocked";
24
+ const unavailable =
25
+ automation.kind === "event" && !automation.triggerAvailable;
26
+ const canEdit = automation.ownedByViewer && !completed;
27
+ const blockReason =
28
+ automation.kind === "scheduled" ? automation.statusReason : undefined;
29
+ const action = paused || blocked ? "resume" : "pause";
30
+ const mutation = useMutation({
31
+ mutationFn: async () => {
32
+ const path = `/api/automations/${automation.kind}/${encodeURIComponent(automation.id)}`;
33
+ const current = await fetchDashboardJson(
34
+ automationEditSchema,
35
+ `${path}/edit`,
36
+ );
37
+ // Do not apply the opposite action if another surface changed lifecycle.
38
+ if (current.status !== automation.status) {
39
+ throw new Error(
40
+ "The Automation changed. Close and reopen its details before trying again.",
41
+ );
42
+ }
43
+ return post(automationEditSchema, `${path}/lifecycle`, {
44
+ action,
45
+ revision: current.revision,
46
+ });
47
+ },
48
+ onSuccess: async () => {
49
+ await queryClient.invalidateQueries({
50
+ queryKey: ["dashboard", "automations"],
51
+ });
52
+ },
53
+ });
54
+ const status =
55
+ automation.status === "active" && unavailable
56
+ ? "Trigger unavailable"
57
+ : automation.status;
58
+ const failed =
59
+ automation.lastRunStatus === "failed" ||
60
+ automation.lastRunStatus === "blocked";
61
+ return (
62
+ <div className="grid gap-3 text-sm">
63
+ <div className="flex flex-wrap items-center justify-between gap-3">
64
+ <div role="status" aria-live="polite">
65
+ <StatusChip
66
+ tone={
67
+ paused || completed
68
+ ? "neutral"
69
+ : blocked || unavailable
70
+ ? "warning"
71
+ : "success"
72
+ }
73
+ >
74
+ {status}
75
+ </StatusChip>
76
+ </div>
77
+ <div className="flex items-center gap-2">
78
+ <ButtonLink className="min-h-11" to={editPath}>
79
+ {canEdit ? "Edit automation" : "View settings"}
80
+ </ButtonLink>
81
+ {canEdit ? (
82
+ <Button
83
+ className="min-h-11"
84
+ disabled={mutation.isPending}
85
+ onClick={() => mutation.mutate()}
86
+ >
87
+ {mutation.isPending
88
+ ? "Updating…"
89
+ : action === "pause"
90
+ ? "Pause"
91
+ : "Resume"}
92
+ </Button>
93
+ ) : null}
94
+ </div>
95
+ </div>
96
+ {blocked || blockReason ? (
97
+ <p className="m-0 break-words text-dashboard-text-muted">
98
+ {blockReason ?? "Check the last run to see what needs fixing."}{" "}
99
+ {paused ? "Fix this before resuming." : "Fix this, then resume."}
100
+ </p>
101
+ ) : null}
102
+ {unavailable ? (
103
+ <p className="m-0 text-dashboard-text-muted">
104
+ This event source is unavailable. Enable its plugin or choose another
105
+ event.
106
+ </p>
107
+ ) : null}
108
+ {failed ? (
109
+ <p className="m-0 text-dashboard-text-muted">
110
+ Last run {automation.lastRunStatus}.
111
+ {automation.status === "active" && !unavailable
112
+ ? " Future runs are still on."
113
+ : ""}{" "}
114
+ <Link
115
+ className="text-dashboard-text underline underline-offset-2"
116
+ to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
117
+ >
118
+ View run history
119
+ </Link>
120
+ </p>
121
+ ) : null}
122
+ {canEdit ? (
123
+ <details className="text-dashboard-text-muted">
124
+ <summary className="w-fit cursor-pointer py-2 text-xs hover:text-dashboard-text focus-visible:outline focus-visible:outline-dashboard-focus">
125
+ About pausing
126
+ </summary>
127
+ <p className="mt-1 mb-0 leading-relaxed">
128
+ Pausing stops new runs. Work already queued or running may finish.
129
+ Resuming skips missed runs and waits for the next scheduled time or
130
+ new event.
131
+ {paused && blockReason
132
+ ? " Removing the pause keeps the block until you fix it and resume again."
133
+ : ""}
134
+ </p>
135
+ </details>
136
+ ) : null}
137
+ {mutation.error ? (
138
+ <FormNotice tone="error" title="Could not update this automation.">
139
+ {mutation.error instanceof DashboardApiError
140
+ ? (mutation.error.apiError ?? "Try again.")
141
+ : mutation.error.message}
142
+ </FormNotice>
143
+ ) : null}
144
+ </div>
145
+ );
146
+ }
@@ -0,0 +1,177 @@
1
+ import { ArrowUp, Trash2, Plus } from "lucide-react";
2
+ import type {
3
+ AutomationEdit,
4
+ AutomationSummary,
5
+ } from "@sentry/junior/api/schema";
6
+ import { Button } from "../../components/Button";
7
+ import { Select } from "../../components/Select";
8
+ import { cn } from "../../styles";
9
+ import type { AutomationDraft } from "./automationDraft";
10
+ import { automationOutcomeLabel } from "./automationOutcomes";
11
+
12
+ /** Keep outcome order and offer only supported message Destinations. */
13
+ export function AutomationOutcomeFields(props: {
14
+ value: AutomationDraft["outcomes"];
15
+ original: AutomationEdit["outcomes"];
16
+ destination: AutomationSummary["destination"];
17
+ onChange(value: AutomationDraft["outcomes"]): void;
18
+ }) {
19
+ const defaultOutcomes: AutomationDraft["outcomes"] = props.original.length
20
+ ? props.original
21
+ : [{ action: "send_message", destination: "current_conversation" }];
22
+ const options = [
23
+ ...props.original.flatMap((outcome, index) =>
24
+ typeof outcome.destination === "string"
25
+ ? []
26
+ : [
27
+ {
28
+ value: `saved:${index}`,
29
+ label: automationOutcomeLabel(outcome, props.destination),
30
+ outcome,
31
+ },
32
+ ],
33
+ ),
34
+ {
35
+ value: "current_conversation",
36
+ label: `${props.destination.label} · ${props.destination.visibility}`,
37
+ outcome: {
38
+ action: "send_message",
39
+ destination: "current_conversation",
40
+ } as const,
41
+ },
42
+ {
43
+ value: "task_creator",
44
+ label: "Creator · Direct message",
45
+ outcome: { action: "send_message", destination: "task_creator" } as const,
46
+ },
47
+ ];
48
+ return (
49
+ <>
50
+ <fieldset>
51
+ <legend className="sr-only">Success messages</legend>
52
+ <div className="flex flex-wrap gap-x-6 gap-y-3">
53
+ {[true, false].map((send) => (
54
+ <label
55
+ key={String(send)}
56
+ className="inline-flex min-h-8 cursor-pointer items-center gap-2 text-sm"
57
+ >
58
+ <input
59
+ type="radio"
60
+ name="messages"
61
+ className="size-4 accent-dashboard-focus"
62
+ checked={Boolean(props.value.length) === send}
63
+ onChange={() => props.onChange(send ? defaultOutcomes : [])}
64
+ />
65
+ {send ? "Send a message" : "No success message"}
66
+ </label>
67
+ ))}
68
+ </div>
69
+ </fieldset>
70
+ {props.value.length ? (
71
+ <div className="grid gap-4 sm:gap-2">
72
+ {props.value.map((outcome, index) => {
73
+ const retainedIndex = props.original.findIndex(
74
+ (item) => JSON.stringify(item) === JSON.stringify(outcome),
75
+ );
76
+ return (
77
+ <div
78
+ key={index}
79
+ className="flex min-w-0 flex-wrap items-center gap-2"
80
+ >
81
+ <span className="w-4 text-xs text-dashboard-text-muted">
82
+ {index + 1}.
83
+ </span>
84
+ <div
85
+ className={cn(
86
+ "min-w-0 flex-1",
87
+ props.value.length > 1 &&
88
+ "order-last basis-full sm:order-none sm:basis-auto",
89
+ )}
90
+ >
91
+ <Select
92
+ aria-label={`Message ${index + 1} destination`}
93
+ value={
94
+ typeof outcome.destination === "string"
95
+ ? outcome.destination
96
+ : `saved:${retainedIndex}`
97
+ }
98
+ onChange={(e) => {
99
+ const selected = options.find(
100
+ (option) => option.value === e.target.value,
101
+ );
102
+ if (selected)
103
+ props.onChange(
104
+ props.value.map((item, i) =>
105
+ i === index ? selected.outcome : item,
106
+ ),
107
+ );
108
+ }}
109
+ >
110
+ {options.map((option) => (
111
+ <option key={option.value} value={option.value}>
112
+ {option.label}
113
+ </option>
114
+ ))}
115
+ </Select>
116
+ </div>
117
+ {props.value.length > 1 ? (
118
+ <>
119
+ <Button
120
+ size="icon"
121
+ className="ml-auto sm:ml-0"
122
+ aria-label={`Move message ${index + 1} up`}
123
+ disabled={index === 0}
124
+ onClick={() => {
125
+ const next = [...props.value];
126
+ [next[index - 1], next[index]] = [
127
+ next[index],
128
+ next[index - 1],
129
+ ];
130
+ props.onChange(next);
131
+ }}
132
+ >
133
+ <ArrowUp size={16} />
134
+ </Button>
135
+ <Button
136
+ size="icon"
137
+ aria-label={`Remove message ${index + 1}`}
138
+ onClick={() =>
139
+ props.onChange(
140
+ props.value.filter((_, i) => i !== index),
141
+ )
142
+ }
143
+ >
144
+ <Trash2 size={16} />
145
+ </Button>
146
+ </>
147
+ ) : null}
148
+ </div>
149
+ );
150
+ })}
151
+ {props.value.length < 5 ? (
152
+ <Button
153
+ className="w-fit"
154
+ onClick={() =>
155
+ props.onChange([
156
+ ...props.value,
157
+ { action: "send_message", destination: "task_creator" },
158
+ ])
159
+ }
160
+ >
161
+ <Plus size={15} />
162
+ Add message
163
+ </Button>
164
+ ) : null}
165
+ </div>
166
+ ) : (
167
+ <p className="m-0 text-sm leading-relaxed text-dashboard-text-muted">
168
+ Junior still does the work. No message is sent after success.
169
+ </p>
170
+ )}
171
+ <p className="m-0 text-xs leading-relaxed text-dashboard-text-muted">
172
+ {props.value.length ? "Messages are sent in this order. " : ""}This does
173
+ not limit actions in the instruction.
174
+ </p>
175
+ </>
176
+ );
177
+ }