@sentry/junior-dashboard 0.232.0 → 0.234.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +163 -46
- package/dist/client.js +55 -52
- package/dist/index.js +163 -46
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/client/App.tsx +359 -345
- package/src/client/api.ts +17 -1
- package/src/client/components/ActorAvatar.tsx +37 -0
- package/src/client/components/Button.tsx +12 -10
- package/src/client/components/Field.tsx +14 -1
- package/src/client/components/FormNotice.tsx +40 -0
- package/src/client/components/ParticipantAvatarStack.tsx +6 -14
- package/src/client/components/README.md +11 -2
- package/src/client/components/Select.tsx +18 -0
- package/src/client/components/StatusChip.tsx +15 -12
- package/src/client/components/TextInput.tsx +25 -5
- package/src/client/conversations/ConversationEventLog.tsx +49 -9
- package/src/client/conversations/EventDetails.tsx +94 -11
- package/src/client/conversations/EventUsage.tsx +118 -0
- package/src/client/conversations/eventLog.ts +59 -6
- package/src/client/conversations/eventTranscript.ts +2 -0
- package/src/client/conversations/transcriptBottomPinning.ts +2 -2
- package/src/client/http.ts +31 -3
- package/src/client/pages/automations/AutomationDetailsDrawer.tsx +71 -54
- package/src/client/pages/automations/AutomationEditPage.tsx +172 -0
- package/src/client/pages/automations/AutomationEditor.tsx +397 -0
- package/src/client/pages/automations/AutomationEditorLoading.tsx +18 -0
- package/src/client/pages/automations/AutomationEventFields.tsx +264 -0
- package/src/client/pages/automations/AutomationFilters.tsx +171 -0
- package/src/client/pages/automations/AutomationFormSection.tsx +22 -0
- package/src/client/pages/automations/AutomationLifecycle.tsx +146 -0
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +177 -0
- package/src/client/pages/automations/AutomationRow.tsx +358 -0
- package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
- package/src/client/pages/automations/AutomationsPage.tsx +157 -408
- package/src/client/pages/automations/AutomationsPageLayout.tsx +16 -5
- package/src/client/pages/automations/README.md +19 -0
- package/src/client/pages/automations/automationDraft.ts +110 -0
- package/src/client/pages/automations/automationOutcomes.ts +23 -0
- package/src/client/pages/dev/ComponentsPage.tsx +50 -4
- package/src/client/searchParams.ts +4 -2
- package/src/client.tsx +7 -4
- package/src/mock-reporting/automation-collection.ts +101 -0
- package/src/mock-reporting/fixtures.ts +82 -32
- package/src/mock-reporting/routes.ts +14 -9
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { ConversationUsage } from "@sentry/junior/api/schema";
|
|
2
|
+
|
|
3
|
+
import { formatCostBreakdown, summarizeCost } from "../format";
|
|
4
|
+
import { cn } from "../styles";
|
|
5
|
+
|
|
6
|
+
/** Align recorded token counts with their costs without adding overlapping counters. */
|
|
7
|
+
export function EventUsage({ usage }: { usage?: ConversationUsage }) {
|
|
8
|
+
const cost = summarizeCost(usage);
|
|
9
|
+
const rows = [
|
|
10
|
+
{ label: "Input", tokens: usage?.inputTokens, cost: usage?.cost?.input },
|
|
11
|
+
{ label: "Output", tokens: usage?.outputTokens, cost: usage?.cost?.output },
|
|
12
|
+
{
|
|
13
|
+
label: "Cache read",
|
|
14
|
+
tokens: usage?.cachedInputTokens,
|
|
15
|
+
cost: usage?.cost?.cacheRead,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
label: "Cache write",
|
|
19
|
+
tokens: usage?.cacheCreationTokens,
|
|
20
|
+
cost: usage?.cost?.cacheWrite,
|
|
21
|
+
},
|
|
22
|
+
{ label: "Reasoning", tokens: usage?.reasoningTokens },
|
|
23
|
+
{ label: "Total", tokens: usage?.totalTokens, cost: cost?.total },
|
|
24
|
+
].filter((row) => row.tokens !== undefined || row.cost !== undefined);
|
|
25
|
+
|
|
26
|
+
if (rows.length === 0) {
|
|
27
|
+
return (
|
|
28
|
+
<p className="m-0 text-sm text-dashboard-text-muted">
|
|
29
|
+
Token usage and cost were not recorded for this call.
|
|
30
|
+
</p>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
<div className="min-w-0 overflow-x-auto">
|
|
37
|
+
<table
|
|
38
|
+
aria-label="Model call token and cost breakdown"
|
|
39
|
+
className="w-full min-w-72 table-fixed border-collapse text-xs leading-relaxed"
|
|
40
|
+
>
|
|
41
|
+
<colgroup>
|
|
42
|
+
<col />
|
|
43
|
+
<col className="w-24" />
|
|
44
|
+
<col className="w-24" />
|
|
45
|
+
</colgroup>
|
|
46
|
+
<thead>
|
|
47
|
+
<tr className="border-b border-dashboard-border text-dashboard-text-muted">
|
|
48
|
+
<th scope="col" className="py-2 text-left font-normal">
|
|
49
|
+
Usage
|
|
50
|
+
</th>
|
|
51
|
+
<th scope="col" className="py-2 text-right font-normal">
|
|
52
|
+
Tokens
|
|
53
|
+
</th>
|
|
54
|
+
<th scope="col" className="py-2 text-right font-normal">
|
|
55
|
+
Cost (USD)
|
|
56
|
+
</th>
|
|
57
|
+
</tr>
|
|
58
|
+
</thead>
|
|
59
|
+
<tbody>
|
|
60
|
+
{rows.map((row) => (
|
|
61
|
+
<tr
|
|
62
|
+
key={row.label}
|
|
63
|
+
className={cn(
|
|
64
|
+
"border-b border-dashboard-border-subtle last:border-b-0",
|
|
65
|
+
row.label === "Total" &&
|
|
66
|
+
"border-t border-dashboard-border font-semibold",
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
<th
|
|
70
|
+
scope="row"
|
|
71
|
+
className={cn(
|
|
72
|
+
"py-2 text-left font-normal",
|
|
73
|
+
row.label === "Total"
|
|
74
|
+
? "font-semibold text-dashboard-text"
|
|
75
|
+
: "text-dashboard-text-muted",
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{row.label}
|
|
79
|
+
</th>
|
|
80
|
+
<td className="py-2 text-right font-mono tabular-nums text-dashboard-text">
|
|
81
|
+
{row.tokens === undefined ? (
|
|
82
|
+
<span
|
|
83
|
+
aria-label="Not recorded"
|
|
84
|
+
className="text-dashboard-text-faint"
|
|
85
|
+
>
|
|
86
|
+
—
|
|
87
|
+
</span>
|
|
88
|
+
) : (
|
|
89
|
+
row.tokens.toLocaleString("en-US")
|
|
90
|
+
)}
|
|
91
|
+
</td>
|
|
92
|
+
<td className="py-2 text-right font-mono tabular-nums text-dashboard-text">
|
|
93
|
+
{row.cost === undefined ? (
|
|
94
|
+
<span
|
|
95
|
+
aria-label="Not recorded separately"
|
|
96
|
+
className="text-dashboard-text-faint"
|
|
97
|
+
>
|
|
98
|
+
—
|
|
99
|
+
</span>
|
|
100
|
+
) : (
|
|
101
|
+
<span title={`${row.cost} USD`}>
|
|
102
|
+
{formatCostBreakdown({ total: row.cost })}
|
|
103
|
+
</span>
|
|
104
|
+
)}
|
|
105
|
+
</td>
|
|
106
|
+
</tr>
|
|
107
|
+
))}
|
|
108
|
+
</tbody>
|
|
109
|
+
</table>
|
|
110
|
+
</div>
|
|
111
|
+
<p className="m-0 text-xs leading-relaxed text-dashboard-text-muted">
|
|
112
|
+
This call only · Estimated costs
|
|
113
|
+
<br />
|
|
114
|
+
Input excludes cache reads and writes. Reasoning is included in output.
|
|
115
|
+
</p>
|
|
116
|
+
</>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -1,6 +1,57 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ConversationReportEvent,
|
|
3
|
+
ConversationReportEventData,
|
|
4
|
+
} from "@sentry/junior/api/schema";
|
|
2
5
|
|
|
3
6
|
import { toolCallPreview } from "./toolCallPreview";
|
|
7
|
+
import {
|
|
8
|
+
formatCompactNumber,
|
|
9
|
+
formatCostBreakdown,
|
|
10
|
+
summarizeCost,
|
|
11
|
+
} from "../format";
|
|
12
|
+
|
|
13
|
+
/** Read recorded event configuration, including reports from before model context was added. */
|
|
14
|
+
export function eventLogModel(
|
|
15
|
+
event: ConversationReportEvent,
|
|
16
|
+
): ConversationReportEvent["model"] {
|
|
17
|
+
if (event.model) return event.model;
|
|
18
|
+
const data = event.data;
|
|
19
|
+
if (data.type === "turn_routed" || data.type === "handoff") {
|
|
20
|
+
return {
|
|
21
|
+
modelId: data.modelId,
|
|
22
|
+
modelProfile: data.modelProfile,
|
|
23
|
+
reasoningLevel: data.reasoningLevel,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Keep call costs distinct from router cost and never repeat usage on tool results. */
|
|
30
|
+
export function eventLogUsage(event: ConversationReportEvent): string {
|
|
31
|
+
const usage = event.modelCall?.usage;
|
|
32
|
+
const cost = summarizeCost(usage);
|
|
33
|
+
const fields = [
|
|
34
|
+
cost ? `${formatCostBreakdown(cost)} call` : undefined,
|
|
35
|
+
event.data.type === "turn_routed" && event.data.costUsd !== undefined
|
|
36
|
+
? `${formatCostBreakdown({ total: event.data.costUsd })} router`
|
|
37
|
+
: undefined,
|
|
38
|
+
...(
|
|
39
|
+
[
|
|
40
|
+
["inputTokens", "in"],
|
|
41
|
+
["outputTokens", "out"],
|
|
42
|
+
["cachedInputTokens", "cache read"],
|
|
43
|
+
["cacheCreationTokens", "cache write"],
|
|
44
|
+
["reasoningTokens", "reasoning"],
|
|
45
|
+
["totalTokens", "total"],
|
|
46
|
+
] as const
|
|
47
|
+
).map(([key, label]) =>
|
|
48
|
+
usage?.[key] !== undefined
|
|
49
|
+
? `${formatCompactNumber(usage[key])} ${label}`
|
|
50
|
+
: undefined,
|
|
51
|
+
),
|
|
52
|
+
];
|
|
53
|
+
return fields.filter((field) => field !== undefined).join(" · ");
|
|
54
|
+
}
|
|
4
55
|
|
|
5
56
|
/** Summarize an event without merging it with earlier or later events. */
|
|
6
57
|
export function eventLogSummary(data: ConversationReportEventData): string {
|
|
@@ -10,9 +61,11 @@ export function eventLogSummary(data: ConversationReportEventData): string {
|
|
|
10
61
|
case "message_handled":
|
|
11
62
|
return data.messageId;
|
|
12
63
|
case "assistant_message":
|
|
13
|
-
return
|
|
14
|
-
.
|
|
15
|
-
|
|
64
|
+
return (
|
|
65
|
+
data.parts
|
|
66
|
+
.map((part) => (part.redacted ? "Content hidden" : part.text))
|
|
67
|
+
.join(" · ") || "Model call recorded"
|
|
68
|
+
);
|
|
16
69
|
case "tool_calls":
|
|
17
70
|
return data.calls
|
|
18
71
|
.map((call) => {
|
|
@@ -25,7 +78,7 @@ export function eventLogSummary(data: ConversationReportEventData): string {
|
|
|
25
78
|
? `${data.state} · ${data.failureCode}${data.failureReason ? ` · ${data.failureReason}` : ""}`
|
|
26
79
|
: `${data.state} · ${data.turnId}`;
|
|
27
80
|
case "turn_routed":
|
|
28
|
-
return
|
|
81
|
+
return `Route selected · ${data.source}${data.confidence !== undefined ? ` · ${Math.round(data.confidence * 100)}% confidence` : ""}`;
|
|
29
82
|
case "turn_context":
|
|
30
83
|
return `${data.pluginName} · ${data.kind} · v${data.version}`;
|
|
31
84
|
case "guardian_action_reviewed":
|
|
@@ -39,7 +92,7 @@ export function eventLogSummary(data: ConversationReportEventData): string {
|
|
|
39
92
|
case "compaction":
|
|
40
93
|
return data.summary ?? "Agent history compacted";
|
|
41
94
|
case "handoff":
|
|
42
|
-
return
|
|
95
|
+
return "Handoff · New configuration applied";
|
|
43
96
|
case "subagent":
|
|
44
97
|
return `${data.subagentKind} · ${data.status} · ${data.childConversationId}`;
|
|
45
98
|
}
|
|
@@ -242,6 +242,8 @@ export function transcriptMessagesFromEvents(
|
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
if (data.type === "assistant_message") {
|
|
245
|
+
// Calls with no reasoning still carry usage in the event log, not a chat bubble.
|
|
246
|
+
if (data.parts.length === 0) continue;
|
|
245
247
|
messages.push(
|
|
246
248
|
eventMessage(
|
|
247
249
|
event,
|
|
@@ -73,7 +73,7 @@ export function transcriptJuniorMessageVersion(
|
|
|
73
73
|
data.redacted ? "redacted" : (data.text?.length ?? 0),
|
|
74
74
|
].join(":");
|
|
75
75
|
}
|
|
76
|
-
if (data.type === "assistant_message") {
|
|
76
|
+
if (data.type === "assistant_message" && data.parts.length > 0) {
|
|
77
77
|
const lastPart = data.parts.at(-1);
|
|
78
78
|
return [
|
|
79
79
|
event.seq,
|
|
@@ -117,7 +117,7 @@ function changesVisibleTranscript(event: ConversationReportEvent): boolean {
|
|
|
117
117
|
if (data.type === "turn_lifecycle") return data.state === "failed";
|
|
118
118
|
return (
|
|
119
119
|
data.type === "message" ||
|
|
120
|
-
data.type === "assistant_message" ||
|
|
120
|
+
(data.type === "assistant_message" && data.parts.length > 0) ||
|
|
121
121
|
data.type === "tool_calls" ||
|
|
122
122
|
data.type === "subagent" ||
|
|
123
123
|
data.type === "structured_event" ||
|
package/src/client/http.ts
CHANGED
|
@@ -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(
|
|
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 {
|
|
33
|
+
const body = (await response.json()) as {
|
|
34
|
+
error?: unknown;
|
|
35
|
+
code?: unknown;
|
|
36
|
+
fields?: unknown;
|
|
37
|
+
};
|
|
23
38
|
if (typeof body.error === "string") apiError = body.error;
|
|
39
|
+
if (typeof body.code === "string") code = body.code;
|
|
40
|
+
if (body.fields && typeof body.fields === "object") {
|
|
41
|
+
fields = Object.fromEntries(
|
|
42
|
+
Object.entries(body.fields).filter(
|
|
43
|
+
(entry): entry is [string, string[]] =>
|
|
44
|
+
Array.isArray(entry[1]) &&
|
|
45
|
+
entry[1].every((value) => typeof value === "string"),
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
24
49
|
} catch {
|
|
25
50
|
// Keep the status-only fallback when the body is not JSON.
|
|
26
51
|
}
|
|
27
|
-
throw new DashboardApiError(path, response.status, apiError
|
|
52
|
+
throw new DashboardApiError(path, response.status, apiError, {
|
|
53
|
+
fields,
|
|
54
|
+
code,
|
|
55
|
+
});
|
|
28
56
|
}
|
|
29
57
|
|
|
30
58
|
function restartDashboardSignIn(): void {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { AutomationLifecycle } from "./AutomationLifecycle";
|
|
2
|
+
import { automationOutcomeLabel } from "./automationOutcomes";
|
|
1
3
|
import type { AutomationSummary } from "@sentry/junior/api/schema";
|
|
2
|
-
import { Link } from "react-router";
|
|
3
|
-
import { MapPin } from "lucide-react";
|
|
4
|
+
import { Link, useLocation } from "react-router";
|
|
4
5
|
import { Detail, DetailList } from "../../components/DetailList";
|
|
5
6
|
import { Drawer } from "../../components/Drawer";
|
|
6
7
|
import {
|
|
@@ -17,6 +18,7 @@ export function AutomationDetailsDrawer(props: {
|
|
|
17
18
|
range: TimeRangeDays;
|
|
18
19
|
automation: AutomationSummary | undefined;
|
|
19
20
|
}) {
|
|
21
|
+
const location = useLocation();
|
|
20
22
|
if (!props.automation) return null;
|
|
21
23
|
|
|
22
24
|
const { automation } = props;
|
|
@@ -38,22 +40,21 @@ export function AutomationDetailsDrawer(props: {
|
|
|
38
40
|
{ label: "Schedule", value: automation.schedule },
|
|
39
41
|
{
|
|
40
42
|
label: "Next run",
|
|
41
|
-
value:
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
value:
|
|
44
|
+
automation.status === "active" && automation.nextRunAt
|
|
45
|
+
? formatRunDate(automation.nextRunAt)
|
|
46
|
+
: "None",
|
|
44
47
|
},
|
|
45
48
|
]
|
|
46
49
|
: [
|
|
47
|
-
{ label: "
|
|
48
|
-
{
|
|
50
|
+
{ label: "Watching", value: automation.resource },
|
|
51
|
+
{
|
|
52
|
+
label: "Events",
|
|
53
|
+
value: automation.events
|
|
54
|
+
.map((event) => event.replaceAll(/[._]/g, " "))
|
|
55
|
+
.join(", "),
|
|
56
|
+
},
|
|
49
57
|
];
|
|
50
|
-
const statusLabel =
|
|
51
|
-
automation.kind === "scheduled"
|
|
52
|
-
? automation.status
|
|
53
|
-
: automation.triggerAvailable
|
|
54
|
-
? "ready"
|
|
55
|
-
: "unavailable";
|
|
56
|
-
|
|
57
58
|
const titleId = "automation-details-drawer-title";
|
|
58
59
|
|
|
59
60
|
return (
|
|
@@ -68,9 +69,8 @@ export function AutomationDetailsDrawer(props: {
|
|
|
68
69
|
>
|
|
69
70
|
{automation.title}
|
|
70
71
|
</h2>
|
|
71
|
-
<div className="mt-1 break-words
|
|
72
|
-
|
|
73
|
-
{formatDate(automation.createdAt)}
|
|
72
|
+
<div className="mt-1 break-words text-sm text-dashboard-text-muted">
|
|
73
|
+
Created by {createdBy} · {automation.destination.label}
|
|
74
74
|
</div>
|
|
75
75
|
</>
|
|
76
76
|
}
|
|
@@ -80,53 +80,70 @@ export function AutomationDetailsDrawer(props: {
|
|
|
80
80
|
>
|
|
81
81
|
<section className="grid gap-5">
|
|
82
82
|
<div>
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
</
|
|
83
|
+
<h3 className="mt-0 mb-2 text-sm font-medium text-dashboard-text-muted">
|
|
84
|
+
What to do
|
|
85
|
+
</h3>
|
|
86
86
|
<TranscriptText text={automation.instruction} />
|
|
87
87
|
</div>
|
|
88
|
+
<AutomationLifecycle
|
|
89
|
+
key={`${automation.kind}:${automation.id}`}
|
|
90
|
+
automation={automation}
|
|
91
|
+
editPath={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/edit${location.search}`}
|
|
92
|
+
/>
|
|
88
93
|
<DetailList>
|
|
89
94
|
{details.map((detail) => (
|
|
90
95
|
<Detail key={detail.label} label={detail.label}>
|
|
91
96
|
{detail.value}
|
|
92
97
|
</Detail>
|
|
93
98
|
))}
|
|
94
|
-
<Detail label="
|
|
95
|
-
{automation.outcomes.length === 0
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
{automation.destination.label} ·{" "}
|
|
107
|
-
{automation.destination.visibility}
|
|
108
|
-
</span>
|
|
109
|
-
</Detail>
|
|
110
|
-
<Detail label="Created">
|
|
111
|
-
{createdBy} · {formatDate(automation.createdAt)}
|
|
112
|
-
</Detail>
|
|
113
|
-
<Detail label="Executions">
|
|
114
|
-
<AutomationExecutionSummary
|
|
115
|
-
range={props.range}
|
|
116
|
-
automation={automation}
|
|
117
|
-
/>
|
|
99
|
+
<Detail label="On success">
|
|
100
|
+
{automation.outcomes.length === 0 ? (
|
|
101
|
+
"No message on success"
|
|
102
|
+
) : (
|
|
103
|
+
<ol className="m-0 pl-4">
|
|
104
|
+
{automation.outcomes.map((outcome, index) => (
|
|
105
|
+
<li key={index}>
|
|
106
|
+
{automationOutcomeLabel(outcome, automation.destination)}
|
|
107
|
+
</li>
|
|
108
|
+
))}
|
|
109
|
+
</ol>
|
|
110
|
+
)}
|
|
118
111
|
</Detail>
|
|
119
112
|
</DetailList>
|
|
120
|
-
{automation.totalRuns > 0
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
</
|
|
113
|
+
{automation.totalRuns > 0 &&
|
|
114
|
+
automation.lastRunStatus !== "failed" &&
|
|
115
|
+
automation.lastRunStatus !== "blocked" ? (
|
|
116
|
+
<Link
|
|
117
|
+
className="w-fit py-2 text-sm text-dashboard-text underline underline-offset-2"
|
|
118
|
+
to={`/automations/${automation.kind}/${encodeURIComponent(automation.id)}/executions`}
|
|
119
|
+
>
|
|
120
|
+
View all runs
|
|
121
|
+
</Link>
|
|
129
122
|
) : null}
|
|
123
|
+
<details>
|
|
124
|
+
<summary className="cursor-pointer py-3 text-sm text-dashboard-text-muted hover:text-dashboard-text focus-visible:outline focus-visible:outline-dashboard-focus">
|
|
125
|
+
More details
|
|
126
|
+
</summary>
|
|
127
|
+
<DetailList>
|
|
128
|
+
<Detail label="Connected accounts">
|
|
129
|
+
{automation.credentialMode === "creator" ? (
|
|
130
|
+
<>
|
|
131
|
+
Uses accounts connected by{" "}
|
|
132
|
+
{automation.ownedByViewer ? "you" : automation.createdBy}.
|
|
133
|
+
</>
|
|
134
|
+
) : (
|
|
135
|
+
"Junior’s accounts only"
|
|
136
|
+
)}
|
|
137
|
+
</Detail>
|
|
138
|
+
<Detail label="Created">{formatDate(automation.createdAt)}</Detail>
|
|
139
|
+
<Detail label="Run history">
|
|
140
|
+
<AutomationExecutionSummary
|
|
141
|
+
range={props.range}
|
|
142
|
+
automation={automation}
|
|
143
|
+
/>
|
|
144
|
+
</Detail>
|
|
145
|
+
</DetailList>
|
|
146
|
+
</details>
|
|
130
147
|
</section>
|
|
131
148
|
</Drawer>
|
|
132
149
|
);
|
|
@@ -169,7 +186,7 @@ function AutomationExecutionSummary(props: {
|
|
|
169
186
|
) : (
|
|
170
187
|
<>{automation.totalRuns} total</>
|
|
171
188
|
)}
|
|
172
|
-
{automation.lastRunAt ? " · Last
|
|
189
|
+
{automation.lastRunAt ? " · Last run " : " · Never run"}
|
|
173
190
|
{automation.lastRunAt && automation.lastConversationId ? (
|
|
174
191
|
<Link
|
|
175
192
|
className="text-dashboard-text underline decoration-white/20 underline-offset-2 hover:decoration-white/60"
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
<>
|
|
56
|
+
<AutomationsNavigation />
|
|
57
|
+
<div className="mx-auto w-full min-w-0 max-w-5xl px-5 pt-7 sm:px-8 sm:pt-9">
|
|
58
|
+
<Link
|
|
59
|
+
to={returnPath}
|
|
60
|
+
className="inline-flex min-h-8 items-center gap-2 text-sm text-dashboard-text-muted no-underline hover:text-dashboard-text"
|
|
61
|
+
>
|
|
62
|
+
<ArrowLeft aria-hidden size={15} />
|
|
63
|
+
All automations
|
|
64
|
+
</Link>
|
|
65
|
+
<div className="mt-4 mb-8 flex flex-wrap items-start justify-between gap-4">
|
|
66
|
+
<div className="min-w-0">
|
|
67
|
+
<h1 className="m-0 break-words font-display text-3xl font-light tracking-tight">
|
|
68
|
+
{value && !canEdit ? value.title : "Edit automation"}
|
|
69
|
+
</h1>
|
|
70
|
+
{value ? (
|
|
71
|
+
<p className="mt-2 mb-0 text-sm text-dashboard-text-muted">
|
|
72
|
+
Created by {value.ownedByViewer ? "you" : value.createdBy} ·{" "}
|
|
73
|
+
{value.destination.label}
|
|
74
|
+
</p>
|
|
75
|
+
) : null}
|
|
76
|
+
</div>
|
|
77
|
+
{value ? (
|
|
78
|
+
<StatusChip tone={statusTone}>
|
|
79
|
+
{status === "unavailable" ? "Trigger unavailable" : status}
|
|
80
|
+
</StatusChip>
|
|
81
|
+
) : null}
|
|
82
|
+
</div>
|
|
83
|
+
{(!value && summary.error) ||
|
|
84
|
+
(!edit.data && edit.error) ||
|
|
85
|
+
(value && value.kind !== kind) ? (
|
|
86
|
+
<FormNotice tone="error" title="Automation could not be loaded.">
|
|
87
|
+
It may no longer be available, or you may not have access.{" "}
|
|
88
|
+
<Button
|
|
89
|
+
onClick={() => {
|
|
90
|
+
void summary.refetch();
|
|
91
|
+
if (canEdit) void edit.refetch();
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
Try again
|
|
95
|
+
</Button>
|
|
96
|
+
</FormNotice>
|
|
97
|
+
) : !value || (canEdit && !edit.data) ? (
|
|
98
|
+
<AutomationEditorLoading />
|
|
99
|
+
) : canEdit && edit.data ? (
|
|
100
|
+
<AutomationEditor
|
|
101
|
+
key={`${kind}:${automationId}`}
|
|
102
|
+
automation={edit.data}
|
|
103
|
+
summary={value}
|
|
104
|
+
returnPath={returnPath}
|
|
105
|
+
/>
|
|
106
|
+
) : (
|
|
107
|
+
<AutomationReadOnly automation={value} />
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
</>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function AutomationReadOnly({ automation }: { automation: AutomationSummary }) {
|
|
115
|
+
return (
|
|
116
|
+
<>
|
|
117
|
+
<p className="mb-6 text-sm text-dashboard-text-muted">
|
|
118
|
+
{automation.ownedByViewer
|
|
119
|
+
? "This automation has completed and cannot be edited. It will not run again."
|
|
120
|
+
: `Only ${automation.createdBy} can edit this automation.`}
|
|
121
|
+
</p>
|
|
122
|
+
<AutomationFormSection title="What to do" detail="The saved instruction.">
|
|
123
|
+
<p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed">
|
|
124
|
+
{automation.instruction}
|
|
125
|
+
</p>
|
|
126
|
+
</AutomationFormSection>
|
|
127
|
+
<AutomationFormSection title="When to run" detail="The saved trigger.">
|
|
128
|
+
{automation.kind === "scheduled" ? (
|
|
129
|
+
<p className="m-0 text-sm">
|
|
130
|
+
{automation.schedule} · {automation.timezone}
|
|
131
|
+
</p>
|
|
132
|
+
) : (
|
|
133
|
+
<div className="grid gap-3 text-sm">
|
|
134
|
+
<p className="m-0 break-words">{automation.resource}</p>
|
|
135
|
+
<p className="m-0 break-words">{automation.events.join(", ")}</p>
|
|
136
|
+
{automation.match ? (
|
|
137
|
+
<pre className="whitespace-pre-wrap break-words text-xs">
|
|
138
|
+
{JSON.stringify(automation.match, null, 2)}
|
|
139
|
+
</pre>
|
|
140
|
+
) : null}
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
</AutomationFormSection>
|
|
144
|
+
<AutomationFormSection
|
|
145
|
+
title="Where results go"
|
|
146
|
+
detail="Messages after successful work."
|
|
147
|
+
>
|
|
148
|
+
{automation.outcomes.length ? (
|
|
149
|
+
<ol className="m-0 grid gap-2 pl-5 text-sm">
|
|
150
|
+
{automation.outcomes.map((outcome, index) => (
|
|
151
|
+
<li key={index}>
|
|
152
|
+
{automationOutcomeLabel(outcome, automation.destination)}
|
|
153
|
+
</li>
|
|
154
|
+
))}
|
|
155
|
+
</ol>
|
|
156
|
+
) : (
|
|
157
|
+
<p className="m-0 text-sm">No success message</p>
|
|
158
|
+
)}
|
|
159
|
+
</AutomationFormSection>
|
|
160
|
+
<AutomationFormSection
|
|
161
|
+
title="Credentials"
|
|
162
|
+
detail="The accounts Junior can use."
|
|
163
|
+
>
|
|
164
|
+
<p className="m-0 text-sm">
|
|
165
|
+
{automation.credentialMode === "creator"
|
|
166
|
+
? `Uses ${automation.ownedByViewer ? "your" : `${automation.createdBy}’s`} connected accounts.`
|
|
167
|
+
: "System credentials only."}
|
|
168
|
+
</p>
|
|
169
|
+
</AutomationFormSection>
|
|
170
|
+
</>
|
|
171
|
+
);
|
|
172
|
+
}
|