@sentry/junior-dashboard 0.266.0 → 0.268.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.
- package/dist/app.js +85 -12
- package/dist/client.js +94 -64
- package/dist/index.js +85 -12
- package/dist/tailwind.css +1 -1
- package/package.json +11 -5
- package/src/app.ts +0 -2
- package/src/auth.ts +4 -7
- package/src/client/App.tsx +55 -53
- package/src/client/components/DetailsPanel.tsx +12 -13
- package/src/client/components/Drawer.tsx +46 -23
- package/src/client/components/ImageAttachment.tsx +1 -1
- package/src/client/components/ParticipantAvatarStack.tsx +6 -2
- package/src/client/components/README.md +15 -2
- package/src/client/components/UserInputForm.tsx +283 -0
- package/src/client/components/layout/DashboardHeader.tsx +13 -0
- package/src/client/components/ui/alert.tsx +1 -1
- package/src/client/components/ui/button-group.tsx +59 -0
- package/src/client/components/ui/button.tsx +11 -1
- package/src/client/components/ui/dialog.tsx +29 -35
- package/src/client/components/ui/drawer.tsx +140 -0
- package/src/client/components/ui/empty.tsx +30 -1
- package/src/client/components/ui/separator.tsx +22 -0
- package/src/client/components/ui/useSheet.ts +5 -1
- package/src/client/conversations/ComposerImages.tsx +66 -14
- package/src/client/conversations/ConversationComposer.tsx +84 -12
- package/src/client/conversations/ConversationDetails.tsx +3 -27
- package/src/client/conversations/ConversationEventDetails.tsx +26 -10
- package/src/client/conversations/ConversationHomeList.tsx +6 -0
- package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
- package/src/client/conversations/ConversationPage.tsx +160 -2
- package/src/client/conversations/ConversationSidebar.tsx +6 -0
- package/src/client/conversations/ConversationTranscript.tsx +48 -16
- package/src/client/conversations/ConversationWorkspace.tsx +4 -0
- package/src/client/conversations/DetailsList.tsx +33 -0
- package/src/client/conversations/EventDetails.tsx +46 -19
- package/src/client/conversations/ForkConversationButton.tsx +79 -73
- package/src/client/conversations/PendingMailboxStack.tsx +310 -112
- package/src/client/conversations/README.md +30 -13
- package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
- package/src/client/conversations/TranscriptActivityGroup.tsx +27 -19
- package/src/client/conversations/TranscriptAttachment.tsx +39 -4
- package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
- package/src/client/conversations/TranscriptFailureView.tsx +10 -13
- package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
- package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
- package/src/client/conversations/TranscriptMessageView.tsx +10 -1
- package/src/client/conversations/TranscriptSummary.tsx +5 -31
- package/src/client/conversations/TranscriptToolView.tsx +13 -1
- package/src/client/conversations/TranscriptTurnContextView.tsx +174 -161
- package/src/client/conversations/TranscriptView.tsx +7 -3
- package/src/client/conversations/UserInputRequestCard.tsx +39 -0
- package/src/client/conversations/conversationOutbox.ts +46 -0
- package/src/client/conversations/eventTime.ts +13 -1
- package/src/client/conversations/eventTranscript.ts +12 -5
- package/src/client/conversations/queries.ts +127 -26
- package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
- package/src/client/conversations/turnContext.ts +15 -0
- package/src/client/conversations/userInputRequest.ts +56 -0
- package/src/client/format.ts +31 -8
- package/src/client/pages/SettingsPage.tsx +103 -3
- package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
- package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
- package/src/client/pages/automations/AutomationEditor.tsx +182 -156
- package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
- package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
- package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
- package/src/client/pages/automations/AutomationOverview.tsx +238 -0
- package/src/client/pages/automations/AutomationPage.tsx +343 -0
- package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
- package/src/client/pages/automations/AutomationRow.tsx +11 -13
- package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
- package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
- package/src/client/pages/automations/AutomationStatus.tsx +164 -108
- package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
- package/src/client/pages/automations/AutomationsPage.tsx +10 -98
- package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
- package/src/client/pages/automations/README.md +56 -46
- package/src/client/pages/code/RepositorySections.tsx +1 -0
- package/src/client/pages/code/RepositoryTabs.tsx +3 -0
- package/src/client/primaryNav.ts +15 -2
- package/src/client/review-desk/ConversationReviewBar.tsx +62 -0
- package/src/client/review-desk/ReviewDeskPage.tsx +277 -0
- package/src/client/review-desk/ReviewItemActions.tsx +165 -0
- package/src/client/review-desk/ReviewItemCard.tsx +47 -0
- package/src/client/review-desk/ReviewItemDetail.tsx +115 -0
- package/src/client/review-desk/queries.ts +164 -0
- package/src/client/review-desk/reviewItemFormat.ts +61 -0
- package/src/client/types.ts +3 -0
- package/src/mock-reporting/fixtures.ts +54 -2
- package/src/mock-reporting/routes.ts +20 -0
- package/src/shell.ts +3 -0
- package/src/client/conversations/transcriptStyles.ts +0 -10
- package/src/client/pages/automations/AutomationDetails.tsx +0 -193
- package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
- package/src/client/pages/automations/AutomationVersionsPage.tsx +0 -390
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import { cn } from "../../styles";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Keep section headings and fields aligned without nested form cards. The
|
|
7
|
+
* read view and the editor use the same sections, so editing stays in place.
|
|
8
|
+
*/
|
|
4
9
|
export function AutomationFormSection(props: {
|
|
5
10
|
title: string;
|
|
6
11
|
detail?: string;
|
|
12
|
+
/** Mark a section with unsaved edits. */
|
|
13
|
+
changed?: boolean;
|
|
7
14
|
children: ReactNode;
|
|
8
15
|
}) {
|
|
9
16
|
return (
|
|
10
|
-
<section
|
|
17
|
+
<section
|
|
18
|
+
className={cn(
|
|
19
|
+
"grid min-w-0 gap-5 border-t border-dashboard-border-emphasis py-8 md:grid-cols-[12rem_minmax(0,1fr)] md:gap-10",
|
|
20
|
+
props.changed &&
|
|
21
|
+
"-ml-4 pl-4 shadow-[inset_2px_0_0_var(--color-dashboard-accent)] sm:-ml-5 sm:pl-5",
|
|
22
|
+
)}
|
|
23
|
+
>
|
|
11
24
|
<div>
|
|
12
25
|
<h2 className="m-0 font-display text-lg font-medium">{props.title}</h2>
|
|
26
|
+
{props.changed ? (
|
|
27
|
+
<p className="mt-1 mb-0 text-xs text-dashboard-accent-text">
|
|
28
|
+
Unsaved changes
|
|
29
|
+
</p>
|
|
30
|
+
) : null}
|
|
13
31
|
{props.detail ? (
|
|
14
32
|
<p className="mt-2 mb-0 text-sm leading-relaxed text-dashboard-text-muted">
|
|
15
33
|
{props.detail}
|
|
@@ -18,7 +18,7 @@ export function AutomationOutcomeList(props: {
|
|
|
18
18
|
if (!props.outcomes.length)
|
|
19
19
|
return <p className="m-0 text-sm">No success message</p>;
|
|
20
20
|
return (
|
|
21
|
-
<ol className="m-0 grid gap-2 pl-5 text-sm">
|
|
21
|
+
<ol className="m-0 grid list-decimal gap-2 pl-5 text-sm">
|
|
22
22
|
{props.outcomes.map((outcome, index) => (
|
|
23
23
|
<li key={index}>
|
|
24
24
|
{automationOutcomeLabel(outcome, props.destination)}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read view of one Automation. The sections match the editor, so edit mode
|
|
3
|
+
* changes the same rows in place. The side column holds status and activity.
|
|
4
|
+
*/
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
import { Link, useLocation } from "react-router";
|
|
7
|
+
import type { AutomationSummary } from "@sentry/junior/api/schema";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
useAutomationExecutionsData,
|
|
11
|
+
useAutomationVersionsData,
|
|
12
|
+
} from "../../api";
|
|
13
|
+
import {
|
|
14
|
+
selectTimeSeries,
|
|
15
|
+
type TimeRangeDays,
|
|
16
|
+
} from "../../components/controls/TimeRangeSelector";
|
|
17
|
+
import { Card } from "../../components/layout/Card";
|
|
18
|
+
import { TranscriptText } from "../../conversations/TranscriptText";
|
|
19
|
+
import { automationPath, formatCostSummary, peoplePath } from "../../format";
|
|
20
|
+
import { pathWithSearch } from "../../searchParams";
|
|
21
|
+
import { dashboardInlineLinkClass } from "../../styles";
|
|
22
|
+
import { AutomationFormSection } from "./AutomationFormSection";
|
|
23
|
+
import { AutomationOutcomeList } from "./AutomationOutcomeFields";
|
|
24
|
+
import { AutomationRecentRuns } from "./AutomationRecentRuns";
|
|
25
|
+
import { AutomationAttention, AutomationStatus } from "./AutomationStatus";
|
|
26
|
+
import { TriggerFilters } from "./TriggerFilters";
|
|
27
|
+
import {
|
|
28
|
+
automationScheduleLabel,
|
|
29
|
+
formatAutomationRunTime,
|
|
30
|
+
} from "./automationFormat";
|
|
31
|
+
|
|
32
|
+
/** Settings on the left; status, activity, and ownership on the right. */
|
|
33
|
+
export function AutomationOverview(props: {
|
|
34
|
+
automation: AutomationSummary;
|
|
35
|
+
editPath: string;
|
|
36
|
+
range: TimeRangeDays;
|
|
37
|
+
}) {
|
|
38
|
+
const { automation } = props;
|
|
39
|
+
return (
|
|
40
|
+
<div className="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1fr)_18rem] lg:gap-10">
|
|
41
|
+
<div className="grid min-w-0 content-start gap-6">
|
|
42
|
+
<AutomationAttention
|
|
43
|
+
automation={automation}
|
|
44
|
+
editPath={props.editPath}
|
|
45
|
+
/>
|
|
46
|
+
<AutomationSettings automation={automation} />
|
|
47
|
+
<Card padding="sm">
|
|
48
|
+
<AutomationRecentRuns automation={automation} />
|
|
49
|
+
</Card>
|
|
50
|
+
{/* On small screens, status leads and About follows the settings. */}
|
|
51
|
+
<div className="lg:hidden">
|
|
52
|
+
<AutomationAbout automation={automation} />
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
<aside
|
|
56
|
+
aria-label="Automation status"
|
|
57
|
+
className="grid min-w-0 content-start gap-3 max-lg:row-start-1"
|
|
58
|
+
>
|
|
59
|
+
<RailCard title="Status">
|
|
60
|
+
<AutomationStatus automation={automation} />
|
|
61
|
+
</RailCard>
|
|
62
|
+
<AutomationActivity automation={automation} range={props.range} />
|
|
63
|
+
<div className="max-lg:hidden">
|
|
64
|
+
<AutomationAbout automation={automation} />
|
|
65
|
+
</div>
|
|
66
|
+
</aside>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Saved settings, in the same sections and order as the editor. */
|
|
72
|
+
function AutomationSettings(props: { automation: AutomationSummary }) {
|
|
73
|
+
const { automation } = props;
|
|
74
|
+
const creator = automation.ownedByViewer ? "you" : automation.createdBy;
|
|
75
|
+
return (
|
|
76
|
+
<div className="min-w-0 [&>section:first-child]:border-t-0 [&>section:first-child]:pt-0">
|
|
77
|
+
<AutomationFormSection title="What to do">
|
|
78
|
+
<div className="text-base leading-relaxed [overflow-wrap:anywhere]">
|
|
79
|
+
<TranscriptText
|
|
80
|
+
mentions={automation.mentions ?? []}
|
|
81
|
+
text={automation.instruction}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
</AutomationFormSection>
|
|
85
|
+
<AutomationFormSection title="When to run">
|
|
86
|
+
{automation.kind === "scheduled" ? (
|
|
87
|
+
<p className="m-0 text-sm">{automationScheduleLabel(automation)}</p>
|
|
88
|
+
) : (
|
|
89
|
+
<div className="grid gap-1 text-sm">
|
|
90
|
+
<p className="m-0 break-words">{automation.resource}</p>
|
|
91
|
+
<p className="m-0 break-words text-dashboard-text-muted">
|
|
92
|
+
{automation.trigger.events.join(", ")}
|
|
93
|
+
</p>
|
|
94
|
+
<TriggerFilters filters={automation.trigger.filters} />
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
</AutomationFormSection>
|
|
98
|
+
<AutomationFormSection title="Where results go">
|
|
99
|
+
<AutomationOutcomeList
|
|
100
|
+
outcomes={automation.outcomes}
|
|
101
|
+
destination={automation.destination}
|
|
102
|
+
/>
|
|
103
|
+
</AutomationFormSection>
|
|
104
|
+
<AutomationFormSection title="Credentials">
|
|
105
|
+
<p className="m-0 text-sm">
|
|
106
|
+
{automation.credentialMode === "creator"
|
|
107
|
+
? `Uses accounts connected by ${creator}.`
|
|
108
|
+
: "System credentials only. Junior does not use anyone’s connected accounts."}
|
|
109
|
+
</p>
|
|
110
|
+
</AutomationFormSection>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Runs, problems, and spend in the selected range. */
|
|
116
|
+
function AutomationActivity(props: {
|
|
117
|
+
automation: AutomationSummary;
|
|
118
|
+
range: TimeRangeDays;
|
|
119
|
+
}) {
|
|
120
|
+
const { automation, range } = props;
|
|
121
|
+
// AutomationRecentRuns reads the same query, so this adds no request.
|
|
122
|
+
const executions = useAutomationExecutionsData(true, automation.id);
|
|
123
|
+
const problems = executions.data
|
|
124
|
+
? selectTimeSeries({
|
|
125
|
+
days: executions.data.executionDays,
|
|
126
|
+
hours: executions.data.executionHours,
|
|
127
|
+
sixHours: executions.data.executionSixHours,
|
|
128
|
+
range,
|
|
129
|
+
}).reduce(
|
|
130
|
+
(total, bucket) => ({
|
|
131
|
+
blocked: total.blocked + bucket.blocked,
|
|
132
|
+
failed: total.failed + bucket.failed,
|
|
133
|
+
}),
|
|
134
|
+
{ blocked: 0, failed: 0 },
|
|
135
|
+
)
|
|
136
|
+
: undefined;
|
|
137
|
+
return (
|
|
138
|
+
<RailCard title={range === 1 ? "Last 24 hours" : `Last ${range} days`}>
|
|
139
|
+
<dl className="m-0 grid grid-cols-2 gap-x-4 gap-y-3">
|
|
140
|
+
<RailMetric label="Runs" value={automation.runs[range]} />
|
|
141
|
+
<RailMetric
|
|
142
|
+
label="Spend"
|
|
143
|
+
value={formatCostSummary({ total: automation.costUsd[range] })}
|
|
144
|
+
/>
|
|
145
|
+
<RailMetric label="Failed" value={problems?.failed ?? "—"} />
|
|
146
|
+
<RailMetric label="Blocked" value={problems?.blocked ?? "—"} />
|
|
147
|
+
</dl>
|
|
148
|
+
</RailCard>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Who owns the Automation, where it lives, and which version is active. */
|
|
153
|
+
function AutomationAbout(props: { automation: AutomationSummary }) {
|
|
154
|
+
const { automation } = props;
|
|
155
|
+
const location = useLocation();
|
|
156
|
+
const versions = useAutomationVersionsData(true, automation.id);
|
|
157
|
+
const activeVersion = versions.data?.activeVersion;
|
|
158
|
+
return (
|
|
159
|
+
<RailCard title="About">
|
|
160
|
+
<dl className="m-0 grid gap-3 text-sm">
|
|
161
|
+
<RailFact label="Created by">
|
|
162
|
+
{automation.createdByEmail ? (
|
|
163
|
+
<Link
|
|
164
|
+
className={dashboardInlineLinkClass}
|
|
165
|
+
to={peoplePath(automation.createdByEmail)}
|
|
166
|
+
>
|
|
167
|
+
{automation.ownedByViewer ? "You" : automation.createdBy}
|
|
168
|
+
</Link>
|
|
169
|
+
) : automation.ownedByViewer ? (
|
|
170
|
+
"You"
|
|
171
|
+
) : (
|
|
172
|
+
automation.createdBy
|
|
173
|
+
)}
|
|
174
|
+
</RailFact>
|
|
175
|
+
<RailFact label="Created">
|
|
176
|
+
{formatAutomationRunTime(automation.createdAt)}
|
|
177
|
+
</RailFact>
|
|
178
|
+
<RailFact label="Destination">
|
|
179
|
+
{automation.destination.label} ·{" "}
|
|
180
|
+
{automation.destination.visibility === "private"
|
|
181
|
+
? "private"
|
|
182
|
+
: "public"}
|
|
183
|
+
</RailFact>
|
|
184
|
+
<RailFact label="Version">
|
|
185
|
+
<Link
|
|
186
|
+
className={dashboardInlineLinkClass}
|
|
187
|
+
to={pathWithSearch(
|
|
188
|
+
automationPath(automation.id, "versions"),
|
|
189
|
+
location.search,
|
|
190
|
+
)}
|
|
191
|
+
>
|
|
192
|
+
{activeVersion ? `Version ${activeVersion}` : "Version history"}
|
|
193
|
+
</Link>
|
|
194
|
+
</RailFact>
|
|
195
|
+
</dl>
|
|
196
|
+
{automation.ownedByViewer ? null : (
|
|
197
|
+
<p className="mt-3 mb-0 text-xs leading-relaxed text-dashboard-text-muted">
|
|
198
|
+
{automation.destination.visibility === "private"
|
|
199
|
+
? null
|
|
200
|
+
: "You can edit its settings because it is in a public channel. "}
|
|
201
|
+
Only {automation.createdBy} can pause, resume, or delete it, change
|
|
202
|
+
where results go, or turn on their connected accounts.
|
|
203
|
+
</p>
|
|
204
|
+
)}
|
|
205
|
+
</RailCard>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function RailCard(props: { children: ReactNode; title: string }) {
|
|
210
|
+
return (
|
|
211
|
+
<Card as="section" padding="sm">
|
|
212
|
+
<h2 className="m-0 mb-3 font-sans text-xs font-medium text-dashboard-text-muted">
|
|
213
|
+
{props.title}
|
|
214
|
+
</h2>
|
|
215
|
+
{props.children}
|
|
216
|
+
</Card>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function RailMetric(props: { label: string; value: ReactNode }) {
|
|
221
|
+
return (
|
|
222
|
+
<div className="min-w-0">
|
|
223
|
+
<dt className="text-xs text-dashboard-text-muted">{props.label}</dt>
|
|
224
|
+
<dd className="m-0 mt-0.5 font-display text-xl font-light tabular-nums text-dashboard-text">
|
|
225
|
+
{props.value}
|
|
226
|
+
</dd>
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function RailFact(props: { children: ReactNode; label: string }) {
|
|
232
|
+
return (
|
|
233
|
+
<div className="grid min-w-0 gap-0.5">
|
|
234
|
+
<dt className="text-xs text-dashboard-text-muted">{props.label}</dt>
|
|
235
|
+
<dd className="m-0 min-w-0 break-words">{props.children}</dd>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One Automation page. Overview, Runs, and Versions are tabs under one header.
|
|
3
|
+
* Edit mode replaces the Overview read view in place.
|
|
4
|
+
*/
|
|
5
|
+
import { useRef, useState, type ReactNode } from "react";
|
|
6
|
+
import { ArrowLeft } from "lucide-react";
|
|
7
|
+
import {
|
|
8
|
+
Link,
|
|
9
|
+
matchPath,
|
|
10
|
+
Navigate,
|
|
11
|
+
useLocation,
|
|
12
|
+
useNavigate,
|
|
13
|
+
useParams,
|
|
14
|
+
} from "react-router";
|
|
15
|
+
import { useQuery } from "@tanstack/react-query";
|
|
16
|
+
import {
|
|
17
|
+
automationEditSchema,
|
|
18
|
+
type AutomationSummary,
|
|
19
|
+
} from "@sentry/junior/api/schema";
|
|
20
|
+
import { eventTriggerText } from "@sentry/junior-plugin-api";
|
|
21
|
+
|
|
22
|
+
import { useAutomationData } from "../../api";
|
|
23
|
+
import { Button } from "../../components/ui/button";
|
|
24
|
+
import { FormNotice } from "../../components/FormNotice";
|
|
25
|
+
import { PageContentSkeleton } from "../../components/PageContentSkeleton";
|
|
26
|
+
import {
|
|
27
|
+
TimeRangeSelector,
|
|
28
|
+
type TimeRangeDays,
|
|
29
|
+
} from "../../components/controls/TimeRangeSelector";
|
|
30
|
+
import { PageHeader } from "../../components/layout/PageHeader";
|
|
31
|
+
import { PageLayout } from "../../components/layout/PageLayout";
|
|
32
|
+
import { SecondaryNavigation } from "../../components/layout/SecondaryNavigation";
|
|
33
|
+
import { automationPath, type AutomationTab } from "../../format";
|
|
34
|
+
import { DashboardApiError, fetchDashboardJson } from "../../http";
|
|
35
|
+
import { pathWithSearch, useSearchParamEnum } from "../../searchParams";
|
|
36
|
+
import { cn } from "../../styles";
|
|
37
|
+
import { AutomationActions } from "./AutomationActions";
|
|
38
|
+
import { AutomationDeleteDialog } from "./AutomationDeleteDialog";
|
|
39
|
+
import { AutomationEditor } from "./AutomationEditor";
|
|
40
|
+
import { AutomationEditorLoading } from "./AutomationEditorLoading";
|
|
41
|
+
import { AutomationOverview } from "./AutomationOverview";
|
|
42
|
+
import { AUTOMATION_RUN_PARAM } from "./AutomationRunDetails";
|
|
43
|
+
import {
|
|
44
|
+
AutomationRunsTab,
|
|
45
|
+
AUTOMATION_RESULT_PARAM,
|
|
46
|
+
} from "./AutomationRunsTab";
|
|
47
|
+
import {
|
|
48
|
+
AutomationStateChip,
|
|
49
|
+
automationNeedsAttention,
|
|
50
|
+
} from "./AutomationStatus";
|
|
51
|
+
import {
|
|
52
|
+
AUTOMATION_VERSION_PARAM,
|
|
53
|
+
AutomationVersionsTab,
|
|
54
|
+
} from "./AutomationVersionsTab";
|
|
55
|
+
|
|
56
|
+
const RANGE_OPTIONS = ["1", "7", "30", "90"] as const;
|
|
57
|
+
|
|
58
|
+
/** Search params that belong to one tab. The list link drops them. */
|
|
59
|
+
const PAGE_PARAMS = [
|
|
60
|
+
AUTOMATION_RESULT_PARAM,
|
|
61
|
+
AUTOMATION_RUN_PARAM,
|
|
62
|
+
AUTOMATION_VERSION_PARAM,
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const TAB_LABELS: Record<AutomationTab, string> = {
|
|
66
|
+
edit: "Edit",
|
|
67
|
+
overview: "Overview",
|
|
68
|
+
runs: "Runs",
|
|
69
|
+
versions: "Versions",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Render one Automation tab, or edit mode, under the shared header and tabs. */
|
|
73
|
+
export function AutomationPage(props: {
|
|
74
|
+
enabled: boolean;
|
|
75
|
+
tab: AutomationTab;
|
|
76
|
+
}) {
|
|
77
|
+
const { automationId } = useParams();
|
|
78
|
+
const location = useLocation();
|
|
79
|
+
const summary = useAutomationData(props.enabled, automationId);
|
|
80
|
+
const [rangeParam, setRangeParam] = useSearchParamEnum(
|
|
81
|
+
"range",
|
|
82
|
+
"30",
|
|
83
|
+
RANGE_OPTIONS,
|
|
84
|
+
);
|
|
85
|
+
const range = Number(rangeParam) as TimeRangeDays;
|
|
86
|
+
if (!automationId) return <Navigate replace to="/automations/list" />;
|
|
87
|
+
const listSearch = new URLSearchParams(location.search);
|
|
88
|
+
for (const param of PAGE_PARAMS) listSearch.delete(param);
|
|
89
|
+
const listPath = pathWithSearch("/automations/list", listSearch);
|
|
90
|
+
const automation = summary.data;
|
|
91
|
+
const tabs = (["overview", "runs", "versions"] as const).map((tab) => ({
|
|
92
|
+
label: TAB_LABELS[tab],
|
|
93
|
+
to: automationPath(automationId, tab),
|
|
94
|
+
// Edit mode is part of Overview.
|
|
95
|
+
isActive: (pathname: string) =>
|
|
96
|
+
tab === "overview"
|
|
97
|
+
? Boolean(
|
|
98
|
+
matchPath("/automations/:automationId", pathname) ||
|
|
99
|
+
matchPath("/automations/:automationId/edit", pathname),
|
|
100
|
+
)
|
|
101
|
+
: Boolean(matchPath(`/automations/:automationId/${tab}`, pathname)),
|
|
102
|
+
}));
|
|
103
|
+
const runsFrame = props.tab === "runs" && Boolean(automation);
|
|
104
|
+
return (
|
|
105
|
+
<SecondaryNavigation
|
|
106
|
+
ariaLabel="Automation navigation"
|
|
107
|
+
className={
|
|
108
|
+
runsFrame
|
|
109
|
+
? "grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)] md:grid-rows-[minmax(0,1fr)]"
|
|
110
|
+
: undefined
|
|
111
|
+
}
|
|
112
|
+
items={tabs}
|
|
113
|
+
>
|
|
114
|
+
{automation ? (
|
|
115
|
+
<AutomationPageContent
|
|
116
|
+
automation={automation}
|
|
117
|
+
listPath={listPath}
|
|
118
|
+
onRangeChange={(value) =>
|
|
119
|
+
setRangeParam(String(value) as (typeof RANGE_OPTIONS)[number])
|
|
120
|
+
}
|
|
121
|
+
range={range}
|
|
122
|
+
tab={props.tab}
|
|
123
|
+
/>
|
|
124
|
+
) : (
|
|
125
|
+
<PageLayout>
|
|
126
|
+
<BackLink to={listPath} />
|
|
127
|
+
<PageHeader
|
|
128
|
+
description="Settings, runs, and versions for one automation."
|
|
129
|
+
title="Automation"
|
|
130
|
+
/>
|
|
131
|
+
{summary.error ? (
|
|
132
|
+
<FormNotice tone="error" title="Automation could not be loaded.">
|
|
133
|
+
{summary.error instanceof DashboardApiError &&
|
|
134
|
+
summary.error.status === 404
|
|
135
|
+
? "This automation was not found or is not visible to you."
|
|
136
|
+
: "Try again."}{" "}
|
|
137
|
+
<Button onClick={() => void summary.refetch()}>Try again</Button>
|
|
138
|
+
</FormNotice>
|
|
139
|
+
) : (
|
|
140
|
+
<PageContentSkeleton label="Loading automation" variant="list" />
|
|
141
|
+
)}
|
|
142
|
+
</PageLayout>
|
|
143
|
+
)}
|
|
144
|
+
</SecondaryNavigation>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function AutomationPageContent(props: {
|
|
149
|
+
automation: AutomationSummary;
|
|
150
|
+
listPath: string;
|
|
151
|
+
onRangeChange(value: TimeRangeDays): void;
|
|
152
|
+
range: TimeRangeDays;
|
|
153
|
+
tab: AutomationTab;
|
|
154
|
+
}) {
|
|
155
|
+
const { automation, tab } = props;
|
|
156
|
+
const location = useLocation();
|
|
157
|
+
const navigate = useNavigate();
|
|
158
|
+
const [deleting, setDeleting] = useState(false);
|
|
159
|
+
const deleteSource = useRef<HTMLButtonElement | null>(null);
|
|
160
|
+
const completed = automation.status === "completed";
|
|
161
|
+
// Completed Automations cannot change, so edit mode shows the read view.
|
|
162
|
+
if (tab === "edit" && completed) {
|
|
163
|
+
return (
|
|
164
|
+
<Navigate
|
|
165
|
+
replace
|
|
166
|
+
to={pathWithSearch(automationPath(automation.id), location.search)}
|
|
167
|
+
/>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const editPath = pathWithSearch(
|
|
171
|
+
automationPath(automation.id, "edit"),
|
|
172
|
+
location.search,
|
|
173
|
+
);
|
|
174
|
+
const header = (
|
|
175
|
+
<>
|
|
176
|
+
<BackLink to={props.listPath} />
|
|
177
|
+
<PageHeader
|
|
178
|
+
actions={
|
|
179
|
+
tab === "edit" ? undefined : (
|
|
180
|
+
<div className="flex flex-wrap items-start gap-2 md:justify-end">
|
|
181
|
+
{tab === "overview" || tab === "runs" ? (
|
|
182
|
+
<TimeRangeSelector
|
|
183
|
+
onChange={props.onRangeChange}
|
|
184
|
+
value={props.range}
|
|
185
|
+
/>
|
|
186
|
+
) : null}
|
|
187
|
+
<AutomationActions
|
|
188
|
+
automation={automation}
|
|
189
|
+
editPath={editPath}
|
|
190
|
+
editVariant={
|
|
191
|
+
tab === "overview" && automationNeedsAttention(automation)
|
|
192
|
+
? "default"
|
|
193
|
+
: "primary"
|
|
194
|
+
}
|
|
195
|
+
onDelete={(source) => {
|
|
196
|
+
deleteSource.current = source;
|
|
197
|
+
setDeleting(true);
|
|
198
|
+
}}
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
description={<AutomationHeaderLine automation={automation} />}
|
|
204
|
+
documentTitle={
|
|
205
|
+
tab === "overview"
|
|
206
|
+
? automation.title
|
|
207
|
+
: `${automation.title} · ${TAB_LABELS[tab]}`
|
|
208
|
+
}
|
|
209
|
+
title={automation.title}
|
|
210
|
+
/>
|
|
211
|
+
</>
|
|
212
|
+
);
|
|
213
|
+
const dialog = deleting ? (
|
|
214
|
+
<AutomationDeleteDialog
|
|
215
|
+
automation={automation}
|
|
216
|
+
onClose={() => setDeleting(false)}
|
|
217
|
+
onDeleted={() => {
|
|
218
|
+
deleteSource.current = null;
|
|
219
|
+
navigate(props.listPath);
|
|
220
|
+
}}
|
|
221
|
+
returnFocus={() =>
|
|
222
|
+
deleteSource.current?.isConnected ? deleteSource.current : false
|
|
223
|
+
}
|
|
224
|
+
/>
|
|
225
|
+
) : null;
|
|
226
|
+
if (tab === "runs") {
|
|
227
|
+
return (
|
|
228
|
+
<>
|
|
229
|
+
<AutomationRunsTab
|
|
230
|
+
automation={automation}
|
|
231
|
+
header={header}
|
|
232
|
+
range={props.range}
|
|
233
|
+
/>
|
|
234
|
+
{dialog}
|
|
235
|
+
</>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return (
|
|
239
|
+
<PageLayout>
|
|
240
|
+
{header}
|
|
241
|
+
{tab === "overview" ? (
|
|
242
|
+
<AutomationOverview
|
|
243
|
+
automation={automation}
|
|
244
|
+
editPath={editPath}
|
|
245
|
+
range={props.range}
|
|
246
|
+
/>
|
|
247
|
+
) : null}
|
|
248
|
+
{tab === "edit" ? <AutomationEditMode automation={automation} /> : null}
|
|
249
|
+
{tab === "versions" ? (
|
|
250
|
+
<AutomationVersionsTab automation={automation} />
|
|
251
|
+
) : null}
|
|
252
|
+
{dialog}
|
|
253
|
+
</PageLayout>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Load exact editable values, then edit the Overview sections in place. */
|
|
258
|
+
function AutomationEditMode(props: { automation: AutomationSummary }) {
|
|
259
|
+
const { automation } = props;
|
|
260
|
+
const location = useLocation();
|
|
261
|
+
const edit = useQuery({
|
|
262
|
+
queryKey: ["dashboard", "automations", "edit", automation.id],
|
|
263
|
+
retry: false,
|
|
264
|
+
queryFn: ({ signal }) =>
|
|
265
|
+
fetchDashboardJson(
|
|
266
|
+
automationEditSchema,
|
|
267
|
+
`/api/automations/${encodeURIComponent(automation.id)}/edit`,
|
|
268
|
+
signal,
|
|
269
|
+
),
|
|
270
|
+
});
|
|
271
|
+
if (edit.error && !edit.data) {
|
|
272
|
+
return (
|
|
273
|
+
<FormNotice tone="error" title="Automation could not be loaded.">
|
|
274
|
+
It may no longer be available, or you may not have access.{" "}
|
|
275
|
+
<Button onClick={() => void edit.refetch()}>Try again</Button>
|
|
276
|
+
</FormNotice>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (!edit.data) return <AutomationEditorLoading />;
|
|
280
|
+
return (
|
|
281
|
+
<AutomationEditor
|
|
282
|
+
key={automation.id}
|
|
283
|
+
automation={edit.data}
|
|
284
|
+
summary={automation}
|
|
285
|
+
returnPath={pathWithSearch(
|
|
286
|
+
automationPath(automation.id),
|
|
287
|
+
location.search,
|
|
288
|
+
)}
|
|
289
|
+
/>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** State, trigger, Destination, and creator in one line under the title. */
|
|
294
|
+
function AutomationHeaderLine(props: { automation: AutomationSummary }) {
|
|
295
|
+
const { automation } = props;
|
|
296
|
+
const trigger =
|
|
297
|
+
automation.kind === "scheduled"
|
|
298
|
+
? automation.schedule
|
|
299
|
+
: eventTriggerText(automation.resource, automation.trigger);
|
|
300
|
+
const parts: ReactNode[] = [
|
|
301
|
+
trigger,
|
|
302
|
+
automation.destination.label,
|
|
303
|
+
`Created by ${automation.ownedByViewer ? "you" : automation.createdBy}`,
|
|
304
|
+
];
|
|
305
|
+
// Each part starts with its separator. The negative margin and the clip
|
|
306
|
+
// hide the separator of any part that starts a line.
|
|
307
|
+
return (
|
|
308
|
+
<span className="block min-w-0 overflow-hidden">
|
|
309
|
+
<span className="-ml-4 flex min-w-0 flex-wrap items-center gap-y-1">
|
|
310
|
+
<span className="flex items-center">
|
|
311
|
+
<span aria-hidden="true" className="inline-block w-4" />
|
|
312
|
+
<AutomationStateChip automation={automation} />
|
|
313
|
+
</span>
|
|
314
|
+
{parts.map((part, index) => (
|
|
315
|
+
<span className="min-w-0 break-words" key={index}>
|
|
316
|
+
<span
|
|
317
|
+
aria-hidden="true"
|
|
318
|
+
className={cn(
|
|
319
|
+
"inline-block w-4 text-center opacity-45",
|
|
320
|
+
index === 0 && "invisible",
|
|
321
|
+
)}
|
|
322
|
+
>
|
|
323
|
+
·
|
|
324
|
+
</span>
|
|
325
|
+
{part}
|
|
326
|
+
</span>
|
|
327
|
+
))}
|
|
328
|
+
</span>
|
|
329
|
+
</span>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function BackLink(props: { to: string }) {
|
|
334
|
+
return (
|
|
335
|
+
<Link
|
|
336
|
+
className="flex w-fit items-center gap-2 font-display text-sm font-medium text-dashboard-text-muted no-underline transition-colors hover:text-dashboard-text"
|
|
337
|
+
to={props.to}
|
|
338
|
+
>
|
|
339
|
+
<ArrowLeft aria-hidden="true" size={15} strokeWidth={1.8} />
|
|
340
|
+
All automations
|
|
341
|
+
</Link>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
@@ -8,6 +8,7 @@ import { Skeleton } from "../../components/ui/skeleton";
|
|
|
8
8
|
import { StatusChip } from "../../components/StatusChip";
|
|
9
9
|
import { conversationPath } from "../../conversations/conversationRoutes";
|
|
10
10
|
import {
|
|
11
|
+
automationPath,
|
|
11
12
|
formatCostSummary,
|
|
12
13
|
formatRelativeTime,
|
|
13
14
|
formatRuntime,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
import { dashboardInlineLinkClass } from "../../styles";
|
|
16
17
|
import { formatAutomationRunTime } from "./automationFormat";
|
|
17
18
|
|
|
18
|
-
/** Preview the newest terminal runs
|
|
19
|
+
/** Preview the newest terminal runs on the Automation overview. */
|
|
19
20
|
export function AutomationRecentRuns({
|
|
20
21
|
automation,
|
|
21
22
|
}: {
|
|
@@ -32,7 +33,7 @@ export function AutomationRecentRuns({
|
|
|
32
33
|
{automation.totalRuns > 0 || runs.length > 0 ? (
|
|
33
34
|
<Link
|
|
34
35
|
className={`${dashboardInlineLinkClass} text-xs`}
|
|
35
|
-
to={
|
|
36
|
+
to={automationPath(automation.id, "runs")}
|
|
36
37
|
>
|
|
37
38
|
View all runs
|
|
38
39
|
</Link>
|