@sentry/junior-dashboard 0.270.0 → 0.271.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 +2 -2
- package/dist/client.js +77 -77
- package/dist/index.js +2 -2
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/client/App.tsx +89 -61
- package/src/client/components/AGENTS.md +30 -59
- package/src/client/components/ProfileMenu.tsx +0 -24
- package/src/client/components/UserInputForm.tsx +38 -7
- package/src/client/components/layout/PageHeader.tsx +25 -10
- package/src/client/conversations/AGENTS.md +12 -33
- package/src/client/conversations/ConversationPage.tsx +44 -8
- package/src/client/conversations/conversationRoutes.ts +30 -3
- package/src/client/pages/automations/AGENTS.md +6 -25
- package/src/client/pages/settings/ConnectionsPage.tsx +198 -0
- package/src/client/pages/settings/McpSettingsPage.tsx +77 -0
- package/src/client/pages/settings/PersonalTokensPage.tsx +198 -0
- package/src/client/pages/{SettingsPage.tsx → settings/SettingsPage.tsx} +54 -62
- package/src/client/pages/settings/SettingsPageLayout.tsx +45 -0
- package/src/client/primaryNav.ts +1 -1
- package/src/client/review-desk/ReviewDeskPage.tsx +202 -241
- package/src/client/review-desk/ReviewDeskSidebar.tsx +253 -0
- package/src/client/review-desk/ReviewItemContext.tsx +108 -0
- package/src/client/review-desk/ReviewItemDetails.tsx +153 -0
- package/src/client/review-desk/ReviewItemView.tsx +159 -0
- package/src/client/review-desk/queries.ts +4 -2
- package/src/client/review-desk/reviewItemFormat.ts +54 -0
- package/src/client/components/charts/AGENTS.md +0 -15
- package/src/client/pages/ConnectionsPage.tsx +0 -207
- package/src/client/pages/PersonalTokensPage.tsx +0 -205
- package/src/client/pages/system/AGENTS.md +0 -12
- package/src/client/review-desk/ReviewItemDetail.tsx +0 -123
|
@@ -31,7 +31,9 @@ import { CopyButton } from "../components/CopyButton";
|
|
|
31
31
|
import { ConversationBrief } from "./ConversationBrief";
|
|
32
32
|
import {
|
|
33
33
|
CONVERSATION_EVENT_PARAM,
|
|
34
|
+
CONVERSATION_TURN_PARAM,
|
|
34
35
|
parseConversationEventSeq,
|
|
36
|
+
turnStartedEventSeq,
|
|
35
37
|
} from "./conversationRoutes";
|
|
36
38
|
import {
|
|
37
39
|
ConversationComposer,
|
|
@@ -138,20 +140,24 @@ export function ConversationPage(props: {
|
|
|
138
140
|
const selectedEventSeq = parseConversationEventSeq(
|
|
139
141
|
searchParams.get(CONVERSATION_EVENT_PARAM),
|
|
140
142
|
);
|
|
143
|
+
// A linked Turn opens the event that started it. Until that event is
|
|
144
|
+
// found, it acts like a linked event that is still loading.
|
|
145
|
+
const linkedTurnId = searchParams.get(CONVERSATION_TURN_PARAM) ?? undefined;
|
|
146
|
+
const eventOpen =
|
|
147
|
+
selectedEventSeq !== undefined || linkedTurnId !== undefined;
|
|
141
148
|
// Event details and turn context show over Conversation details. Closing
|
|
142
149
|
// them shows details again when details are open. A linked event starts
|
|
143
150
|
// with details closed, so closing it does not open a panel nobody chose.
|
|
144
151
|
const [detailsChoice, setDetailsChoice] = useState<boolean | undefined>(() =>
|
|
145
|
-
|
|
152
|
+
eventOpen ? false : undefined,
|
|
146
153
|
);
|
|
147
154
|
const detailsOpen = detailsChoice ?? detailsDefaultOpen;
|
|
148
155
|
const [turnContext, setTurnContext] = useState<TurnContextSelection>();
|
|
149
|
-
const eventOpen = selectedEventSeq !== undefined;
|
|
150
156
|
const showDetails = detailsOpen && !eventOpen && !turnContext;
|
|
151
157
|
const [viewState, setView] = useState<TranscriptViewMode>(() =>
|
|
152
|
-
|
|
158
|
+
eventOpen ? "raw" : "rich",
|
|
153
159
|
);
|
|
154
|
-
const view =
|
|
160
|
+
const view = eventOpen ? "raw" : viewState;
|
|
155
161
|
const [search, setSearch] = useState("");
|
|
156
162
|
const [pinRequestVersion, setPinRequestVersion] = useState(0);
|
|
157
163
|
const conversationId = props.conversationId;
|
|
@@ -213,6 +219,7 @@ export function ConversationPage(props: {
|
|
|
213
219
|
setSearchParams(
|
|
214
220
|
(current) => {
|
|
215
221
|
const next = new URLSearchParams(current);
|
|
222
|
+
next.delete(CONVERSATION_TURN_PARAM);
|
|
216
223
|
if (seq === undefined) next.delete(CONVERSATION_EVENT_PARAM);
|
|
217
224
|
else next.set(CONVERSATION_EVENT_PARAM, String(seq));
|
|
218
225
|
return next;
|
|
@@ -224,14 +231,14 @@ export function ConversationPage(props: {
|
|
|
224
231
|
);
|
|
225
232
|
const onViewChange = useCallback(
|
|
226
233
|
(value: TranscriptViewMode) => {
|
|
227
|
-
if (value !== "raw" &&
|
|
234
|
+
if (value !== "raw" && eventOpen) {
|
|
228
235
|
setSelectedEventSeq(undefined);
|
|
229
236
|
}
|
|
230
237
|
// Turn context belongs to a message in the Conversation view.
|
|
231
238
|
setTurnContext(undefined);
|
|
232
239
|
setView(value);
|
|
233
240
|
},
|
|
234
|
-
[
|
|
241
|
+
[eventOpen, setSelectedEventSeq],
|
|
235
242
|
);
|
|
236
243
|
// A linked event can be older than the first page. Load earlier pages until
|
|
237
244
|
// it is present or history ends.
|
|
@@ -250,12 +257,41 @@ export function ConversationPage(props: {
|
|
|
250
257
|
useEffect(() => {
|
|
251
258
|
if (loadLinkedEvent) loadPreviousPageRef.current();
|
|
252
259
|
}, [loadLinkedEvent, oldestLoadedSeq]);
|
|
253
|
-
//
|
|
260
|
+
// Load earlier pages until the linked Turn starts or history ends. Then
|
|
261
|
+
// replace the Turn link with a link to its first event, or drop it when
|
|
262
|
+
// history has no such event. A history error keeps the link, so a retry
|
|
263
|
+
// or reload can still find the Turn.
|
|
264
|
+
const linkedTurnSeq = linkedTurnId
|
|
265
|
+
? turnStartedEventSeq(transcript?.events, linkedTurnId)
|
|
266
|
+
: undefined;
|
|
267
|
+
const linkedTurnMissing =
|
|
268
|
+
linkedTurnId !== undefined &&
|
|
269
|
+
linkedTurnSeq === undefined &&
|
|
270
|
+
transcript !== undefined;
|
|
271
|
+
const loadLinkedTurn =
|
|
272
|
+
linkedTurnMissing &&
|
|
273
|
+
detail.hasPreviousPage &&
|
|
274
|
+
!detail.isLoadingPreviousPage &&
|
|
275
|
+
!detail.historyError;
|
|
276
|
+
const linkedTurnNotFound =
|
|
277
|
+
linkedTurnMissing &&
|
|
278
|
+
!detail.hasPreviousPage &&
|
|
279
|
+
!detail.isLoadingPreviousPage;
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
if (loadLinkedTurn) loadPreviousPageRef.current();
|
|
282
|
+
}, [loadLinkedTurn, oldestLoadedSeq]);
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (linkedTurnSeq !== undefined) setSelectedEventSeq(linkedTurnSeq);
|
|
285
|
+
else if (linkedTurnNotFound) setSelectedEventSeq(undefined);
|
|
286
|
+
}, [linkedTurnNotFound, linkedTurnSeq, setSelectedEventSeq]);
|
|
287
|
+
// Keep the panel while the transcript or a linked event loads, and for the
|
|
288
|
+
// render before a found Turn link becomes an event link.
|
|
254
289
|
const eventLoading =
|
|
255
290
|
eventOpen &&
|
|
256
291
|
!selectedEvent &&
|
|
257
292
|
(detail.isPending ||
|
|
258
|
-
|
|
293
|
+
linkedTurnSeq !== undefined ||
|
|
294
|
+
((linkedEventMissing || linkedTurnMissing) &&
|
|
259
295
|
(detail.hasPreviousPage || detail.isLoadingPreviousPage) &&
|
|
260
296
|
!detail.historyError));
|
|
261
297
|
const showEvent = Boolean(selectedEvent) || eventLoading;
|
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import type { ConversationReportEvent } from "@sentry/junior/api/schema";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build the dashboard route for a conversation id. A `turnId` opens the
|
|
5
|
+
* start of that Turn in the event log.
|
|
6
|
+
*/
|
|
7
|
+
export function conversationPath(
|
|
8
|
+
conversationId: string,
|
|
9
|
+
options?: { turnId?: string },
|
|
10
|
+
): string {
|
|
11
|
+
const path = `/conversations/${encodeURIComponent(conversationId)}`;
|
|
12
|
+
return options?.turnId
|
|
13
|
+
? `${path}?${CONVERSATION_TURN_PARAM}=${encodeURIComponent(options.turnId)}`
|
|
14
|
+
: path;
|
|
4
15
|
}
|
|
5
16
|
|
|
6
17
|
/** Dashboard home route. Legacy `/conversations/new` redirects here. */
|
|
@@ -20,3 +31,19 @@ export function parseConversationEventSeq(
|
|
|
20
31
|
): number | undefined {
|
|
21
32
|
return value && /^\d+$/.test(value) ? Number(value) : undefined;
|
|
22
33
|
}
|
|
34
|
+
|
|
35
|
+
/** Query param that opens the start of one Turn in the event log. */
|
|
36
|
+
export const CONVERSATION_TURN_PARAM = "turn";
|
|
37
|
+
|
|
38
|
+
/** Find the sequence number of the event that started a Turn. */
|
|
39
|
+
export function turnStartedEventSeq(
|
|
40
|
+
events: readonly ConversationReportEvent[] | undefined,
|
|
41
|
+
turnId: string,
|
|
42
|
+
): number | undefined {
|
|
43
|
+
return events?.find(
|
|
44
|
+
({ data }) =>
|
|
45
|
+
data.type === "turn_lifecycle" &&
|
|
46
|
+
data.state === "started" &&
|
|
47
|
+
data.turnId === turnId,
|
|
48
|
+
)?.seq;
|
|
49
|
+
}
|
|
@@ -1,34 +1,15 @@
|
|
|
1
1
|
# Automation pages
|
|
2
2
|
|
|
3
|
-
## Boundaries
|
|
4
|
-
|
|
5
3
|
- Core owns validation, edit access, creator-only rules, revision checks,
|
|
6
|
-
Schedule previews, and the Event catalog.
|
|
7
|
-
|
|
4
|
+
Schedule previews, and the Event catalog. Show core errors. Do not repeat
|
|
5
|
+
these rules in the page.
|
|
8
6
|
- Preview requests never save or run work.
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- Send only changed fields. Keep unsupported triggers and stored
|
|
14
|
-
Destinations unless the user replaces them. Keep outcome order.
|
|
15
|
-
- New messages can target only the current Destination or the creator.
|
|
16
|
-
- A public reader sees outcomes as read-only and cannot turn on creator
|
|
17
|
-
credentials. Warn that an instruction or event selector change switches to
|
|
18
|
-
system credentials.
|
|
19
|
-
- Pause, Resume, and Delete are creator-only. Hide them from other viewers.
|
|
7
|
+
- Send only changed fields. Keep unsupported triggers and stored Destinations
|
|
8
|
+
unless the user replaces them. Keep outcome order.
|
|
9
|
+
- Warn that an instruction or event selector change switches to system
|
|
10
|
+
credentials.
|
|
20
11
|
- Query refreshes must not replace an unsaved draft. After a stale save, let
|
|
21
12
|
the user keep their changes or load the latest version.
|
|
22
|
-
- Block navigation with unsaved changes.
|
|
23
13
|
- Lifecycle actions and "Make active" read the latest revision first.
|
|
24
|
-
|
|
25
|
-
## Page rules
|
|
26
|
-
|
|
27
|
-
- Each Automation has one page at `/automations/:id`. Keep the list query
|
|
28
|
-
string on the way back. Drop tab-only params: `result`, `run`, `version`.
|
|
29
14
|
- `AutomationRecentRuns` shows terminal runs only. Do not infer queued or
|
|
30
15
|
running work from terminal records.
|
|
31
|
-
- Delete keeps errors in the dialog and blocks dismissal while pending.
|
|
32
|
-
|
|
33
|
-
Browser journeys: `e2e/automation-editor.spec.ts` and
|
|
34
|
-
`e2e/automations.spec.ts`.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { Plug } from "lucide-react";
|
|
3
|
+
import type { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
connectionListSchema,
|
|
6
|
+
disconnectConnectionResponseSchema,
|
|
7
|
+
startConnectionResponseSchema,
|
|
8
|
+
type connectionSchema,
|
|
9
|
+
} from "@sentry/junior/api/schema";
|
|
10
|
+
import { del, fetchDashboardJson, post } from "../../http";
|
|
11
|
+
import { getDashboardAgentName } from "../../agentName";
|
|
12
|
+
import { Button } from "../../components/ui/button";
|
|
13
|
+
import { InlineError } from "../../components/InlineError";
|
|
14
|
+
import { Card } from "../../components/layout/Card";
|
|
15
|
+
import { SettingsPageLayout } from "./SettingsPageLayout";
|
|
16
|
+
|
|
17
|
+
type Connection = z.infer<typeof connectionSchema>;
|
|
18
|
+
|
|
19
|
+
const connectionsQueryKey = ["dashboard", "connections"] as const;
|
|
20
|
+
|
|
21
|
+
function connectionPath(provider: string): string {
|
|
22
|
+
return `/api/connections/${encodeURIComponent(provider)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Connect and disconnect the viewer's plugin accounts. */
|
|
26
|
+
export function ConnectionsPage() {
|
|
27
|
+
const queryClient = useQueryClient();
|
|
28
|
+
const connectionsQuery = useQuery({
|
|
29
|
+
queryKey: connectionsQueryKey,
|
|
30
|
+
queryFn: ({ signal }) =>
|
|
31
|
+
fetchDashboardJson(connectionListSchema, "/api/connections", signal),
|
|
32
|
+
// OAuth finishes in another tab. Refresh when the viewer returns.
|
|
33
|
+
refetchOnWindowFocus: true,
|
|
34
|
+
retry: false,
|
|
35
|
+
});
|
|
36
|
+
const connectMutation = useMutation({
|
|
37
|
+
mutationFn: (input: { provider: string; popup: Window | null }) =>
|
|
38
|
+
post(startConnectionResponseSchema, connectionPath(input.provider), {}),
|
|
39
|
+
onSuccess: ({ authorizationUrl }, { popup }) => {
|
|
40
|
+
// A blocked popup returns null, so use this tab. A tab the viewer
|
|
41
|
+
// closed means they cancelled; keep them on the dashboard.
|
|
42
|
+
if (!popup) {
|
|
43
|
+
window.location.assign(authorizationUrl);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (popup.closed) return;
|
|
47
|
+
popup.opener = null;
|
|
48
|
+
popup.location.href = authorizationUrl;
|
|
49
|
+
},
|
|
50
|
+
onError: (_error, { popup }) => {
|
|
51
|
+
popup?.close();
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const disconnectMutation = useMutation({
|
|
55
|
+
mutationFn: (connection: Connection) =>
|
|
56
|
+
del(
|
|
57
|
+
disconnectConnectionResponseSchema,
|
|
58
|
+
connectionPath(connection.provider),
|
|
59
|
+
),
|
|
60
|
+
onSuccess: (_result, connection) => {
|
|
61
|
+
queryClient.setQueryData<{ connections: Connection[] }>(
|
|
62
|
+
connectionsQueryKey,
|
|
63
|
+
(current) =>
|
|
64
|
+
current && {
|
|
65
|
+
connections: current.connections.map((item) =>
|
|
66
|
+
item.provider === connection.provider
|
|
67
|
+
? {
|
|
68
|
+
provider: item.provider,
|
|
69
|
+
displayName: item.displayName,
|
|
70
|
+
description: item.description,
|
|
71
|
+
connected: false,
|
|
72
|
+
}
|
|
73
|
+
: item,
|
|
74
|
+
),
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const connections = connectionsQuery.data?.connections ?? [];
|
|
80
|
+
const busy = connectMutation.isPending || disconnectMutation.isPending;
|
|
81
|
+
const pendingProvider = connectMutation.isPending
|
|
82
|
+
? connectMutation.variables?.provider
|
|
83
|
+
: disconnectMutation.isPending
|
|
84
|
+
? disconnectMutation.variables?.provider
|
|
85
|
+
: undefined;
|
|
86
|
+
const listFailed = !connectionsQuery.data && connectionsQuery.isError;
|
|
87
|
+
const displayedError = connectMutation.isError
|
|
88
|
+
? "Could not start the connection. Try again."
|
|
89
|
+
: disconnectMutation.isError
|
|
90
|
+
? "Could not disconnect the account. Try again."
|
|
91
|
+
: listFailed
|
|
92
|
+
? "Could not load identities. Try again."
|
|
93
|
+
: undefined;
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<SettingsPageLayout
|
|
97
|
+
description={
|
|
98
|
+
<>
|
|
99
|
+
Connect an account so {getDashboardAgentName()} can act as you in
|
|
100
|
+
conversations you start here.
|
|
101
|
+
</>
|
|
102
|
+
}
|
|
103
|
+
title="Identities"
|
|
104
|
+
>
|
|
105
|
+
{displayedError ? <InlineError>{displayedError}</InlineError> : null}
|
|
106
|
+
{listFailed ? null : (
|
|
107
|
+
<Card className="mb-0" variant="raised">
|
|
108
|
+
{connectionsQuery.isPending ? (
|
|
109
|
+
<p className="m-0 p-5 text-sm text-dashboard-text-muted">
|
|
110
|
+
Loading identities…
|
|
111
|
+
</p>
|
|
112
|
+
) : connections.length === 0 ? (
|
|
113
|
+
<p className="m-0 p-5 text-sm text-dashboard-text-muted">
|
|
114
|
+
No installed plugins support account connections.
|
|
115
|
+
</p>
|
|
116
|
+
) : (
|
|
117
|
+
<ul className="m-0 list-none divide-y divide-dashboard-border-subtle p-0">
|
|
118
|
+
{connections.map((connection) => (
|
|
119
|
+
<li
|
|
120
|
+
className="flex items-center gap-3 px-5 py-4"
|
|
121
|
+
key={connection.provider}
|
|
122
|
+
>
|
|
123
|
+
<Plug className="shrink-0 text-dashboard-accent" size={16} />
|
|
124
|
+
<div className="min-w-0 flex-1">
|
|
125
|
+
<p className="m-0 truncate text-sm font-semibold">
|
|
126
|
+
{connection.displayName}
|
|
127
|
+
</p>
|
|
128
|
+
<p className="mt-1 mb-0 line-clamp-2 break-words text-xs text-dashboard-text-muted">
|
|
129
|
+
{connection.connected ? (
|
|
130
|
+
connection.account ? (
|
|
131
|
+
<>
|
|
132
|
+
Connected as{" "}
|
|
133
|
+
{connection.account.url ? (
|
|
134
|
+
<a
|
|
135
|
+
className="text-dashboard-text"
|
|
136
|
+
href={connection.account.url}
|
|
137
|
+
rel="noreferrer"
|
|
138
|
+
target="_blank"
|
|
139
|
+
>
|
|
140
|
+
{connection.account.label}
|
|
141
|
+
</a>
|
|
142
|
+
) : (
|
|
143
|
+
connection.account.label
|
|
144
|
+
)}
|
|
145
|
+
</>
|
|
146
|
+
) : (
|
|
147
|
+
"Connected"
|
|
148
|
+
)
|
|
149
|
+
) : (
|
|
150
|
+
connection.description
|
|
151
|
+
)}
|
|
152
|
+
</p>
|
|
153
|
+
</div>
|
|
154
|
+
{connection.connected ? (
|
|
155
|
+
<Button
|
|
156
|
+
aria-busy={pendingProvider === connection.provider}
|
|
157
|
+
aria-label={`Disconnect ${connection.displayName}`}
|
|
158
|
+
disabled={busy}
|
|
159
|
+
onClick={() => {
|
|
160
|
+
connectMutation.reset();
|
|
161
|
+
disconnectMutation.mutate(connection);
|
|
162
|
+
}}
|
|
163
|
+
variant="danger"
|
|
164
|
+
>
|
|
165
|
+
{pendingProvider === connection.provider
|
|
166
|
+
? "Disconnecting…"
|
|
167
|
+
: "Disconnect"}
|
|
168
|
+
</Button>
|
|
169
|
+
) : (
|
|
170
|
+
<Button
|
|
171
|
+
aria-busy={pendingProvider === connection.provider}
|
|
172
|
+
aria-label={`Connect ${connection.displayName}`}
|
|
173
|
+
disabled={busy}
|
|
174
|
+
onClick={() => {
|
|
175
|
+
disconnectMutation.reset();
|
|
176
|
+
// Open the tab during the click so popup blockers
|
|
177
|
+
// allow it, then point it at the provider.
|
|
178
|
+
const popup = window.open("about:blank", "_blank");
|
|
179
|
+
connectMutation.mutate({
|
|
180
|
+
provider: connection.provider,
|
|
181
|
+
popup,
|
|
182
|
+
});
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
{pendingProvider === connection.provider
|
|
186
|
+
? "Connecting…"
|
|
187
|
+
: "Connect"}
|
|
188
|
+
</Button>
|
|
189
|
+
)}
|
|
190
|
+
</li>
|
|
191
|
+
))}
|
|
192
|
+
</ul>
|
|
193
|
+
)}
|
|
194
|
+
</Card>
|
|
195
|
+
)}
|
|
196
|
+
</SettingsPageLayout>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { getDashboardAgentName } from "../../agentName";
|
|
2
|
+
import { CopyButton } from "../../components/CopyButton";
|
|
3
|
+
import { dashboardInlineLinkClass } from "../../styles";
|
|
4
|
+
import { SettingsPageLayout } from "./SettingsPageLayout";
|
|
5
|
+
|
|
6
|
+
const MCP_DOCS_URL = "https://junior.sentry.dev/operate/mcp-server/";
|
|
7
|
+
|
|
8
|
+
/** Name the MCP server entry after the agent, for example `junior`. */
|
|
9
|
+
function mcpServerName(): string {
|
|
10
|
+
return (
|
|
11
|
+
getDashboardAgentName()
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
14
|
+
.replace(/^-+|-+$/g, "") || "junior"
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ConfigBlock(props: { label: string; value: string }) {
|
|
19
|
+
return (
|
|
20
|
+
<div>
|
|
21
|
+
<div className="flex items-center justify-between gap-3">
|
|
22
|
+
<h2 className="m-0 text-sm font-semibold">{props.label}</h2>
|
|
23
|
+
<CopyButton getText={() => props.value} label={`Copy ${props.label}`} />
|
|
24
|
+
</div>
|
|
25
|
+
<pre className="mt-2 mb-0 whitespace-pre-wrap break-words rounded-md border border-dashboard-border-emphasis bg-dashboard-ink p-3 font-mono text-xs text-dashboard-text">
|
|
26
|
+
{props.value}
|
|
27
|
+
</pre>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Show the MCP server URL and client config for this deployment. */
|
|
33
|
+
export function McpSettingsPage() {
|
|
34
|
+
// The dashboard redirects to its canonical origin, which also serves MCP.
|
|
35
|
+
const url = new URL("/api/mcp", window.location.origin).toString();
|
|
36
|
+
const name = mcpServerName();
|
|
37
|
+
const jsonConfig = JSON.stringify(
|
|
38
|
+
{ mcpServers: { [name]: { type: "http", url } } },
|
|
39
|
+
null,
|
|
40
|
+
2,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<SettingsPageLayout
|
|
45
|
+
description={
|
|
46
|
+
<>
|
|
47
|
+
Connect Claude Code or another MCP client to list your{" "}
|
|
48
|
+
{getDashboardAgentName()} conversations. The client signs in through
|
|
49
|
+
this dashboard. Personal API tokens do not work here.
|
|
50
|
+
</>
|
|
51
|
+
}
|
|
52
|
+
title="MCP"
|
|
53
|
+
>
|
|
54
|
+
<div className="grid gap-5">
|
|
55
|
+
<ConfigBlock label="Server URL" value={url} />
|
|
56
|
+
<ConfigBlock
|
|
57
|
+
label="Claude Code"
|
|
58
|
+
value={`claude mcp add --transport http ${name} ${url}`}
|
|
59
|
+
/>
|
|
60
|
+
<ConfigBlock label="JSON config" value={jsonConfig} />
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<p className="m-0 text-sm text-dashboard-text-muted">
|
|
64
|
+
The client must support OAuth with Client ID Metadata Documents.{" "}
|
|
65
|
+
<a
|
|
66
|
+
className={dashboardInlineLinkClass}
|
|
67
|
+
href={MCP_DOCS_URL}
|
|
68
|
+
rel="noreferrer"
|
|
69
|
+
target="_blank"
|
|
70
|
+
>
|
|
71
|
+
Read the MCP server docs
|
|
72
|
+
</a>
|
|
73
|
+
.
|
|
74
|
+
</p>
|
|
75
|
+
</SettingsPageLayout>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { KeyRound, Trash2 } from "lucide-react";
|
|
3
|
+
import { useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
createdPersonalTokenSchema,
|
|
6
|
+
personalTokenListSchema,
|
|
7
|
+
type PersonalTokenMetadata,
|
|
8
|
+
} from "@sentry/junior/api/schema";
|
|
9
|
+
import { deleteDashboardResource, fetchDashboardJson, post } from "../../http";
|
|
10
|
+
import { getDashboardAgentName } from "../../agentName";
|
|
11
|
+
import { Button } from "../../components/ui/button";
|
|
12
|
+
import { Input } from "../../components/ui/input";
|
|
13
|
+
import { InlineError } from "../../components/InlineError";
|
|
14
|
+
import { Card } from "../../components/layout/Card";
|
|
15
|
+
import { SettingsPageLayout } from "./SettingsPageLayout";
|
|
16
|
+
|
|
17
|
+
const personalTokensQueryKey = ["dashboard", "personal-tokens"] as const;
|
|
18
|
+
|
|
19
|
+
/** Create and revoke personal API tokens for local clients. */
|
|
20
|
+
export function PersonalTokensPage() {
|
|
21
|
+
const queryClient = useQueryClient();
|
|
22
|
+
const createTokenStarted = useRef(false);
|
|
23
|
+
const [name, setName] = useState("Local agent");
|
|
24
|
+
const cancelTokenListRefetch = () =>
|
|
25
|
+
queryClient.cancelQueries({
|
|
26
|
+
exact: true,
|
|
27
|
+
queryKey: personalTokensQueryKey,
|
|
28
|
+
});
|
|
29
|
+
const tokensQuery = useQuery({
|
|
30
|
+
queryKey: personalTokensQueryKey,
|
|
31
|
+
queryFn: ({ signal }) =>
|
|
32
|
+
fetchDashboardJson(
|
|
33
|
+
personalTokenListSchema,
|
|
34
|
+
"/api/personal-tokens",
|
|
35
|
+
signal,
|
|
36
|
+
),
|
|
37
|
+
retry: false,
|
|
38
|
+
});
|
|
39
|
+
const createTokenMutation = useMutation({
|
|
40
|
+
gcTime: 0,
|
|
41
|
+
mutationFn: (tokenName: string) =>
|
|
42
|
+
post(createdPersonalTokenSchema, "/api/personal-tokens", {
|
|
43
|
+
name: tokenName,
|
|
44
|
+
}),
|
|
45
|
+
onMutate: () => cancelTokenListRefetch(),
|
|
46
|
+
onSuccess: async (created) => {
|
|
47
|
+
const { token: _token, ...metadata } = created;
|
|
48
|
+
await cancelTokenListRefetch();
|
|
49
|
+
queryClient.setQueryData<{ tokens: PersonalTokenMetadata[] }>(
|
|
50
|
+
personalTokensQueryKey,
|
|
51
|
+
(current) => ({
|
|
52
|
+
tokens: [
|
|
53
|
+
metadata,
|
|
54
|
+
...(current?.tokens ?? []).filter(
|
|
55
|
+
(token) => token.id !== metadata.id,
|
|
56
|
+
),
|
|
57
|
+
],
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
},
|
|
61
|
+
onSettled: () => {
|
|
62
|
+
createTokenStarted.current = false;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const revokeTokenMutation = useMutation({
|
|
66
|
+
mutationFn: (token: PersonalTokenMetadata) =>
|
|
67
|
+
deleteDashboardResource(
|
|
68
|
+
`/api/personal-tokens/${encodeURIComponent(token.id)}`,
|
|
69
|
+
),
|
|
70
|
+
onMutate: () => cancelTokenListRefetch(),
|
|
71
|
+
onSuccess: async (_result, token) => {
|
|
72
|
+
await cancelTokenListRefetch();
|
|
73
|
+
queryClient.setQueryData<{ tokens: PersonalTokenMetadata[] }>(
|
|
74
|
+
personalTokensQueryKey,
|
|
75
|
+
(current) => ({
|
|
76
|
+
tokens: (current?.tokens ?? []).filter(
|
|
77
|
+
(item) => item.id !== token.id,
|
|
78
|
+
),
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
if (createTokenMutation.data?.id === token.id) {
|
|
82
|
+
createTokenMutation.reset();
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const createdToken = createTokenMutation.data;
|
|
87
|
+
const tokens = tokensQuery.data?.tokens ?? [];
|
|
88
|
+
const loading = tokensQuery.isPending;
|
|
89
|
+
const busy = createTokenMutation.isPending || revokeTokenMutation.isPending;
|
|
90
|
+
const displayedError =
|
|
91
|
+
(createTokenMutation.isError
|
|
92
|
+
? "Could not create the API token. Try again."
|
|
93
|
+
: revokeTokenMutation.isError
|
|
94
|
+
? "Could not revoke the API token. Try again."
|
|
95
|
+
: undefined) ??
|
|
96
|
+
(!tokensQuery.data && tokensQuery.isError
|
|
97
|
+
? "Could not load API tokens. Try again."
|
|
98
|
+
: undefined);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<SettingsPageLayout
|
|
102
|
+
description={
|
|
103
|
+
<>
|
|
104
|
+
Use a token to read {getDashboardAgentName()} APIs from a local agent
|
|
105
|
+
or script. Tokens expire after 90 days.
|
|
106
|
+
</>
|
|
107
|
+
}
|
|
108
|
+
title="API Tokens"
|
|
109
|
+
>
|
|
110
|
+
{createdToken ? (
|
|
111
|
+
<div className="rounded-md border border-emerald-400/40 bg-emerald-400/5 p-3">
|
|
112
|
+
<p className="mt-0 mb-2 text-sm font-semibold">
|
|
113
|
+
Copy this token now. It won't be shown again.
|
|
114
|
+
</p>
|
|
115
|
+
<code className="block overflow-x-auto rounded-md bg-dashboard-ink p-2 text-xs text-emerald-300">
|
|
116
|
+
{createdToken.token}
|
|
117
|
+
</code>
|
|
118
|
+
<Button
|
|
119
|
+
className="mt-3"
|
|
120
|
+
onClick={() => {
|
|
121
|
+
createTokenMutation.reset();
|
|
122
|
+
setName("Local agent");
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
Create another token
|
|
126
|
+
</Button>
|
|
127
|
+
</div>
|
|
128
|
+
) : (
|
|
129
|
+
<div className="flex gap-2">
|
|
130
|
+
<Input
|
|
131
|
+
aria-label="Token name"
|
|
132
|
+
className="min-w-0 flex-1"
|
|
133
|
+
maxLength={80}
|
|
134
|
+
onChange={(event) => setName(event.target.value)}
|
|
135
|
+
value={name}
|
|
136
|
+
/>
|
|
137
|
+
<Button
|
|
138
|
+
disabled={loading || busy || !name.trim()}
|
|
139
|
+
onClick={() => {
|
|
140
|
+
if (createTokenStarted.current) return;
|
|
141
|
+
revokeTokenMutation.reset();
|
|
142
|
+
createTokenStarted.current = true;
|
|
143
|
+
createTokenMutation.mutate(name);
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
Create token
|
|
147
|
+
</Button>
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{displayedError ? <InlineError>{displayedError}</InlineError> : null}
|
|
152
|
+
|
|
153
|
+
<Card className="mb-0" variant="raised">
|
|
154
|
+
{loading ? (
|
|
155
|
+
<p className="m-0 p-5 text-sm text-dashboard-text-muted">
|
|
156
|
+
Loading API tokens…
|
|
157
|
+
</p>
|
|
158
|
+
) : tokens.length === 0 ? (
|
|
159
|
+
<p className="m-0 p-5 text-sm text-dashboard-text-muted">
|
|
160
|
+
No active API tokens.
|
|
161
|
+
</p>
|
|
162
|
+
) : (
|
|
163
|
+
<ul className="m-0 list-none divide-y divide-dashboard-border-subtle p-0">
|
|
164
|
+
{tokens.map((token) => (
|
|
165
|
+
<li className="flex items-center gap-3 px-5 py-4" key={token.id}>
|
|
166
|
+
<KeyRound className="text-dashboard-accent" size={16} />
|
|
167
|
+
<div className="min-w-0 flex-1">
|
|
168
|
+
<p className="m-0 truncate text-sm font-semibold">
|
|
169
|
+
{token.name}
|
|
170
|
+
</p>
|
|
171
|
+
<p className="mt-1 mb-0 text-xs text-dashboard-text-muted">
|
|
172
|
+
Ends in {token.tokenSuffix} · Expires{" "}
|
|
173
|
+
{new Date(token.expiresAt).toLocaleDateString()}
|
|
174
|
+
</p>
|
|
175
|
+
</div>
|
|
176
|
+
<Button
|
|
177
|
+
aria-label={`Revoke ${token.name}`}
|
|
178
|
+
disabled={busy}
|
|
179
|
+
onClick={() => {
|
|
180
|
+
if (createTokenMutation.isError) {
|
|
181
|
+
createTokenMutation.reset();
|
|
182
|
+
}
|
|
183
|
+
revokeTokenMutation.mutate(token);
|
|
184
|
+
}}
|
|
185
|
+
size="icon"
|
|
186
|
+
variant="danger"
|
|
187
|
+
type="button"
|
|
188
|
+
>
|
|
189
|
+
<Trash2 size={16} />
|
|
190
|
+
</Button>
|
|
191
|
+
</li>
|
|
192
|
+
))}
|
|
193
|
+
</ul>
|
|
194
|
+
)}
|
|
195
|
+
</Card>
|
|
196
|
+
</SettingsPageLayout>
|
|
197
|
+
);
|
|
198
|
+
}
|