@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
|
@@ -9,6 +9,19 @@ export function reviewItemPath(id: string): string {
|
|
|
9
9
|
return `/review?item=${encodeURIComponent(id)}`;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Return the subject URL when it is safe to link. Agents and plugins supply
|
|
14
|
+
* it, and URL validation also accepts `javascript:` and `data:` URLs.
|
|
15
|
+
*/
|
|
16
|
+
export function reviewItemSubjectHref(url: string): string | undefined {
|
|
17
|
+
try {
|
|
18
|
+
const { protocol } = new URL(url);
|
|
19
|
+
return protocol === "https:" || protocol === "http:" ? url : undefined;
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
12
25
|
/** Name the state of an item: what it needs while open, or how it closed. */
|
|
13
26
|
export function reviewItemStatusLabel(item: ReviewItemPayload): string {
|
|
14
27
|
if (item.status === "open") {
|
|
@@ -87,3 +100,44 @@ export function reviewItemWaitsForAnswer(item: ReviewItemPayload): boolean {
|
|
|
87
100
|
item.canAct
|
|
88
101
|
);
|
|
89
102
|
}
|
|
103
|
+
|
|
104
|
+
/** The Review items of the inbox, in the groups and order the list shows. */
|
|
105
|
+
export type InboxGroups = {
|
|
106
|
+
archived: ReviewItemPayload[];
|
|
107
|
+
fyi: ReviewItemPayload[];
|
|
108
|
+
needsYou: ReviewItemPayload[];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Inbox order: action items first, then the oldest, then the item id. */
|
|
112
|
+
function inboxOrder(a: ReviewItemPayload, b: ReviewItemPayload): number {
|
|
113
|
+
if (a.kind !== b.kind) return a.kind === "action" ? -1 : 1;
|
|
114
|
+
const age = Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
|
115
|
+
if (age !== 0) return age;
|
|
116
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Split items into inbox groups. Open items show oldest first, so the item
|
|
121
|
+
* that waited longest is at the top. Archived items keep the API order.
|
|
122
|
+
*/
|
|
123
|
+
export function inboxGroups(items: readonly ReviewItemPayload[]): InboxGroups {
|
|
124
|
+
const open = items.filter((item) => item.status === "open");
|
|
125
|
+
return {
|
|
126
|
+
archived: items.filter((item) => item.status !== "open"),
|
|
127
|
+
fyi: open.filter((item) => item.kind === "notice").sort(inboxOrder),
|
|
128
|
+
needsYou: open.filter((item) => item.kind === "action").sort(inboxOrder),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The open item that follows `current` in inbox order. `current` can be an
|
|
134
|
+
* item that closed and left the open groups.
|
|
135
|
+
*/
|
|
136
|
+
export function nextInboxItem(
|
|
137
|
+
groups: InboxGroups,
|
|
138
|
+
current: ReviewItemPayload,
|
|
139
|
+
): ReviewItemPayload | undefined {
|
|
140
|
+
return [...groups.needsYou, ...groups.fyi].find(
|
|
141
|
+
(item) => inboxOrder(current, item) < 0,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# System cache reporting
|
|
2
|
-
|
|
3
|
-
- `cache-input.ts` owns the cache input calculation.
|
|
4
|
-
- `ConversationMetricDay` comes from stored Run metrics, not Sentry spans.
|
|
5
|
-
Its input, cached input, and cache-creation counters are disjoint. Add
|
|
6
|
-
them once for total input. Do not include output. Never use this transform
|
|
7
|
-
for Sentry span totals.
|
|
8
|
-
- Period shares use summed token counts, not the mean of bucket shares.
|
|
9
|
-
- A missing counter is not a zero. When an active bucket has no counter, omit
|
|
10
|
-
its share and the whole-period share.
|
|
11
|
-
- Do not call these values a cache hit rate or a cache failure rate. Field
|
|
12
|
-
presence is not call-level coverage.
|
|
13
|
-
- The page time control selects the buckets for the chart and `CacheWrites`.
|
|
14
|
-
Do not add a local time selector.
|
|
15
|
-
- Add a reporting contract before you add per-call filters or drilldowns.
|
|
@@ -1,207 +0,0 @@
|
|
|
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 { dashboardContainerClass } from "../styles";
|
|
12
|
-
import { getDashboardAgentName } from "../agentName";
|
|
13
|
-
import { useDocumentTitle } from "../documentTitle";
|
|
14
|
-
import { Button } from "../components/ui/button";
|
|
15
|
-
import { InlineError } from "../components/InlineError";
|
|
16
|
-
import { Card } from "../components/layout/Card";
|
|
17
|
-
|
|
18
|
-
type Connection = z.infer<typeof connectionSchema>;
|
|
19
|
-
|
|
20
|
-
const connectionsQueryKey = ["dashboard", "connections"] as const;
|
|
21
|
-
|
|
22
|
-
function connectionPath(provider: string): string {
|
|
23
|
-
return `/api/connections/${encodeURIComponent(provider)}`;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Connect and disconnect the viewer's plugin accounts. */
|
|
27
|
-
export function ConnectionsPage() {
|
|
28
|
-
useDocumentTitle("Connected accounts");
|
|
29
|
-
const queryClient = useQueryClient();
|
|
30
|
-
const connectionsQuery = useQuery({
|
|
31
|
-
queryKey: connectionsQueryKey,
|
|
32
|
-
queryFn: ({ signal }) =>
|
|
33
|
-
fetchDashboardJson(connectionListSchema, "/api/connections", signal),
|
|
34
|
-
// OAuth finishes in another tab. Refresh when the viewer returns.
|
|
35
|
-
refetchOnWindowFocus: true,
|
|
36
|
-
retry: false,
|
|
37
|
-
});
|
|
38
|
-
const connectMutation = useMutation({
|
|
39
|
-
mutationFn: (input: { provider: string; popup: Window | null }) =>
|
|
40
|
-
post(startConnectionResponseSchema, connectionPath(input.provider), {}),
|
|
41
|
-
onSuccess: ({ authorizationUrl }, { popup }) => {
|
|
42
|
-
// A blocked popup returns null, so use this tab. A tab the viewer
|
|
43
|
-
// closed means they cancelled; keep them on the dashboard.
|
|
44
|
-
if (!popup) {
|
|
45
|
-
window.location.assign(authorizationUrl);
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
if (popup.closed) return;
|
|
49
|
-
popup.opener = null;
|
|
50
|
-
popup.location.href = authorizationUrl;
|
|
51
|
-
},
|
|
52
|
-
onError: (_error, { popup }) => {
|
|
53
|
-
popup?.close();
|
|
54
|
-
},
|
|
55
|
-
});
|
|
56
|
-
const disconnectMutation = useMutation({
|
|
57
|
-
mutationFn: (connection: Connection) =>
|
|
58
|
-
del(
|
|
59
|
-
disconnectConnectionResponseSchema,
|
|
60
|
-
connectionPath(connection.provider),
|
|
61
|
-
),
|
|
62
|
-
onSuccess: (_result, connection) => {
|
|
63
|
-
queryClient.setQueryData<{ connections: Connection[] }>(
|
|
64
|
-
connectionsQueryKey,
|
|
65
|
-
(current) =>
|
|
66
|
-
current && {
|
|
67
|
-
connections: current.connections.map((item) =>
|
|
68
|
-
item.provider === connection.provider
|
|
69
|
-
? {
|
|
70
|
-
provider: item.provider,
|
|
71
|
-
displayName: item.displayName,
|
|
72
|
-
description: item.description,
|
|
73
|
-
connected: false,
|
|
74
|
-
}
|
|
75
|
-
: item,
|
|
76
|
-
),
|
|
77
|
-
},
|
|
78
|
-
);
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
|
-
const connections = connectionsQuery.data?.connections ?? [];
|
|
82
|
-
const busy = connectMutation.isPending || disconnectMutation.isPending;
|
|
83
|
-
const pendingProvider = connectMutation.isPending
|
|
84
|
-
? connectMutation.variables?.provider
|
|
85
|
-
: disconnectMutation.isPending
|
|
86
|
-
? disconnectMutation.variables?.provider
|
|
87
|
-
: undefined;
|
|
88
|
-
const listFailed = !connectionsQuery.data && connectionsQuery.isError;
|
|
89
|
-
const displayedError = connectMutation.isError
|
|
90
|
-
? "Could not start the connection. Try again."
|
|
91
|
-
: disconnectMutation.isError
|
|
92
|
-
? "Could not disconnect the account. Try again."
|
|
93
|
-
: listFailed
|
|
94
|
-
? "Could not load connected accounts. Try again."
|
|
95
|
-
: undefined;
|
|
96
|
-
|
|
97
|
-
return (
|
|
98
|
-
<div className={`${dashboardContainerClass} px-4 py-8 md:px-8`}>
|
|
99
|
-
<section className="mx-auto w-full max-w-3xl">
|
|
100
|
-
<h1 className="m-0 text-2xl font-bold">Connected accounts</h1>
|
|
101
|
-
<p className="mt-2 mb-0 max-w-2xl text-sm text-dashboard-text-muted">
|
|
102
|
-
Connect an account so {getDashboardAgentName()} can act as you in
|
|
103
|
-
conversations you start here.
|
|
104
|
-
</p>
|
|
105
|
-
|
|
106
|
-
<Card className="mb-0 mt-6" padding="md" variant="raised">
|
|
107
|
-
{displayedError ? (
|
|
108
|
-
<InlineError className={listFailed ? undefined : "mb-4"}>
|
|
109
|
-
{displayedError}
|
|
110
|
-
</InlineError>
|
|
111
|
-
) : null}
|
|
112
|
-
{listFailed ? null : (
|
|
113
|
-
<div className="space-y-2">
|
|
114
|
-
{connectionsQuery.isPending ? (
|
|
115
|
-
<p className="m-0 text-sm text-dashboard-text-muted">
|
|
116
|
-
Loading connected accounts…
|
|
117
|
-
</p>
|
|
118
|
-
) : connections.length === 0 ? (
|
|
119
|
-
<p className="m-0 text-sm text-dashboard-text-muted">
|
|
120
|
-
No installed plugins support account connections.
|
|
121
|
-
</p>
|
|
122
|
-
) : (
|
|
123
|
-
connections.map((connection) => (
|
|
124
|
-
<div
|
|
125
|
-
className="flex items-center gap-3 rounded-md border border-dashboard-border-strong p-3"
|
|
126
|
-
key={connection.provider}
|
|
127
|
-
>
|
|
128
|
-
<Plug
|
|
129
|
-
className="shrink-0 text-dashboard-accent"
|
|
130
|
-
size={16}
|
|
131
|
-
/>
|
|
132
|
-
<div className="min-w-0 flex-1">
|
|
133
|
-
<p className="m-0 truncate text-sm font-semibold">
|
|
134
|
-
{connection.displayName}
|
|
135
|
-
</p>
|
|
136
|
-
<p className="mt-1 mb-0 line-clamp-2 break-words text-xs text-dashboard-text-muted">
|
|
137
|
-
{connection.connected ? (
|
|
138
|
-
connection.account ? (
|
|
139
|
-
<>
|
|
140
|
-
Connected as{" "}
|
|
141
|
-
{connection.account.url ? (
|
|
142
|
-
<a
|
|
143
|
-
className="text-dashboard-text"
|
|
144
|
-
href={connection.account.url}
|
|
145
|
-
rel="noreferrer"
|
|
146
|
-
target="_blank"
|
|
147
|
-
>
|
|
148
|
-
{connection.account.label}
|
|
149
|
-
</a>
|
|
150
|
-
) : (
|
|
151
|
-
connection.account.label
|
|
152
|
-
)}
|
|
153
|
-
</>
|
|
154
|
-
) : (
|
|
155
|
-
"Connected"
|
|
156
|
-
)
|
|
157
|
-
) : (
|
|
158
|
-
connection.description
|
|
159
|
-
)}
|
|
160
|
-
</p>
|
|
161
|
-
</div>
|
|
162
|
-
{connection.connected ? (
|
|
163
|
-
<Button
|
|
164
|
-
aria-busy={pendingProvider === connection.provider}
|
|
165
|
-
aria-label={`Disconnect ${connection.displayName}`}
|
|
166
|
-
disabled={busy}
|
|
167
|
-
onClick={() => {
|
|
168
|
-
connectMutation.reset();
|
|
169
|
-
disconnectMutation.mutate(connection);
|
|
170
|
-
}}
|
|
171
|
-
variant="danger"
|
|
172
|
-
>
|
|
173
|
-
{pendingProvider === connection.provider
|
|
174
|
-
? "Disconnecting…"
|
|
175
|
-
: "Disconnect"}
|
|
176
|
-
</Button>
|
|
177
|
-
) : (
|
|
178
|
-
<Button
|
|
179
|
-
aria-busy={pendingProvider === connection.provider}
|
|
180
|
-
aria-label={`Connect ${connection.displayName}`}
|
|
181
|
-
disabled={busy}
|
|
182
|
-
onClick={() => {
|
|
183
|
-
disconnectMutation.reset();
|
|
184
|
-
// Open the tab during the click so popup blockers
|
|
185
|
-
// allow it, then point it at the provider.
|
|
186
|
-
const popup = window.open("about:blank", "_blank");
|
|
187
|
-
connectMutation.mutate({
|
|
188
|
-
provider: connection.provider,
|
|
189
|
-
popup,
|
|
190
|
-
});
|
|
191
|
-
}}
|
|
192
|
-
>
|
|
193
|
-
{pendingProvider === connection.provider
|
|
194
|
-
? "Connecting…"
|
|
195
|
-
: "Connect"}
|
|
196
|
-
</Button>
|
|
197
|
-
)}
|
|
198
|
-
</div>
|
|
199
|
-
))
|
|
200
|
-
)}
|
|
201
|
-
</div>
|
|
202
|
-
)}
|
|
203
|
-
</Card>
|
|
204
|
-
</section>
|
|
205
|
-
</div>
|
|
206
|
-
);
|
|
207
|
-
}
|
|
@@ -1,205 +0,0 @@
|
|
|
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 { dashboardContainerClass } from "../styles";
|
|
11
|
-
import { getDashboardAgentName } from "../agentName";
|
|
12
|
-
import { useDocumentTitle } from "../documentTitle";
|
|
13
|
-
import { Button } from "../components/ui/button";
|
|
14
|
-
import { Input } from "../components/ui/input";
|
|
15
|
-
import { InlineError } from "../components/InlineError";
|
|
16
|
-
import { Card } from "../components/layout/Card";
|
|
17
|
-
|
|
18
|
-
const personalTokensQueryKey = ["dashboard", "personal-tokens"] as const;
|
|
19
|
-
|
|
20
|
-
/** Create and revoke personal API tokens for local clients. */
|
|
21
|
-
export function PersonalTokensPage() {
|
|
22
|
-
useDocumentTitle("API tokens");
|
|
23
|
-
const queryClient = useQueryClient();
|
|
24
|
-
const createTokenStarted = useRef(false);
|
|
25
|
-
const [name, setName] = useState("Local agent");
|
|
26
|
-
const cancelTokenListRefetch = () =>
|
|
27
|
-
queryClient.cancelQueries({
|
|
28
|
-
exact: true,
|
|
29
|
-
queryKey: personalTokensQueryKey,
|
|
30
|
-
});
|
|
31
|
-
const tokensQuery = useQuery({
|
|
32
|
-
queryKey: personalTokensQueryKey,
|
|
33
|
-
queryFn: ({ signal }) =>
|
|
34
|
-
fetchDashboardJson(
|
|
35
|
-
personalTokenListSchema,
|
|
36
|
-
"/api/personal-tokens",
|
|
37
|
-
signal,
|
|
38
|
-
),
|
|
39
|
-
retry: false,
|
|
40
|
-
});
|
|
41
|
-
const createTokenMutation = useMutation({
|
|
42
|
-
gcTime: 0,
|
|
43
|
-
mutationFn: (tokenName: string) =>
|
|
44
|
-
post(createdPersonalTokenSchema, "/api/personal-tokens", {
|
|
45
|
-
name: tokenName,
|
|
46
|
-
}),
|
|
47
|
-
onMutate: () => cancelTokenListRefetch(),
|
|
48
|
-
onSuccess: async (created) => {
|
|
49
|
-
const { token: _token, ...metadata } = created;
|
|
50
|
-
await cancelTokenListRefetch();
|
|
51
|
-
queryClient.setQueryData<{ tokens: PersonalTokenMetadata[] }>(
|
|
52
|
-
personalTokensQueryKey,
|
|
53
|
-
(current) => ({
|
|
54
|
-
tokens: [
|
|
55
|
-
metadata,
|
|
56
|
-
...(current?.tokens ?? []).filter(
|
|
57
|
-
(token) => token.id !== metadata.id,
|
|
58
|
-
),
|
|
59
|
-
],
|
|
60
|
-
}),
|
|
61
|
-
);
|
|
62
|
-
},
|
|
63
|
-
onSettled: () => {
|
|
64
|
-
createTokenStarted.current = false;
|
|
65
|
-
},
|
|
66
|
-
});
|
|
67
|
-
const revokeTokenMutation = useMutation({
|
|
68
|
-
mutationFn: (token: PersonalTokenMetadata) =>
|
|
69
|
-
deleteDashboardResource(
|
|
70
|
-
`/api/personal-tokens/${encodeURIComponent(token.id)}`,
|
|
71
|
-
),
|
|
72
|
-
onMutate: () => cancelTokenListRefetch(),
|
|
73
|
-
onSuccess: async (_result, token) => {
|
|
74
|
-
await cancelTokenListRefetch();
|
|
75
|
-
queryClient.setQueryData<{ tokens: PersonalTokenMetadata[] }>(
|
|
76
|
-
personalTokensQueryKey,
|
|
77
|
-
(current) => ({
|
|
78
|
-
tokens: (current?.tokens ?? []).filter(
|
|
79
|
-
(item) => item.id !== token.id,
|
|
80
|
-
),
|
|
81
|
-
}),
|
|
82
|
-
);
|
|
83
|
-
if (createTokenMutation.data?.id === token.id) {
|
|
84
|
-
createTokenMutation.reset();
|
|
85
|
-
}
|
|
86
|
-
},
|
|
87
|
-
});
|
|
88
|
-
const createdToken = createTokenMutation.data;
|
|
89
|
-
const tokens = tokensQuery.data?.tokens ?? [];
|
|
90
|
-
const loading = tokensQuery.isPending;
|
|
91
|
-
const busy = createTokenMutation.isPending || revokeTokenMutation.isPending;
|
|
92
|
-
const displayedError =
|
|
93
|
-
(createTokenMutation.isError
|
|
94
|
-
? "Could not create the API token. Try again."
|
|
95
|
-
: revokeTokenMutation.isError
|
|
96
|
-
? "Could not revoke the API token. Try again."
|
|
97
|
-
: undefined) ??
|
|
98
|
-
(!tokensQuery.data && tokensQuery.isError
|
|
99
|
-
? "Could not load API tokens. Try again."
|
|
100
|
-
: undefined);
|
|
101
|
-
|
|
102
|
-
return (
|
|
103
|
-
<div className={`${dashboardContainerClass} px-4 py-8 md:px-8`}>
|
|
104
|
-
<section className="mx-auto w-full max-w-3xl">
|
|
105
|
-
<h1 className="m-0 text-2xl font-bold">Personal API Tokens</h1>
|
|
106
|
-
<p className="mt-2 mb-0 max-w-2xl text-sm text-dashboard-text-muted">
|
|
107
|
-
Use a token to read {getDashboardAgentName()} APIs from a local agent
|
|
108
|
-
or script. Tokens expire after 90 days.
|
|
109
|
-
</p>
|
|
110
|
-
|
|
111
|
-
<Card className="mb-0 mt-6" padding="md" variant="raised">
|
|
112
|
-
{createdToken ? (
|
|
113
|
-
<div className="mt-5 rounded-md border border-emerald-400/40 bg-emerald-400/5 p-3">
|
|
114
|
-
<p className="mt-0 mb-2 text-sm font-semibold">
|
|
115
|
-
Copy this token now. It won't be shown again.
|
|
116
|
-
</p>
|
|
117
|
-
<code className="block overflow-x-auto rounded-md bg-dashboard-ink p-2 text-xs text-emerald-300">
|
|
118
|
-
{createdToken.token}
|
|
119
|
-
</code>
|
|
120
|
-
<Button
|
|
121
|
-
className="mt-3"
|
|
122
|
-
onClick={() => {
|
|
123
|
-
createTokenMutation.reset();
|
|
124
|
-
setName("Local agent");
|
|
125
|
-
}}
|
|
126
|
-
>
|
|
127
|
-
Create another token
|
|
128
|
-
</Button>
|
|
129
|
-
</div>
|
|
130
|
-
) : (
|
|
131
|
-
<div className="mt-5 flex gap-2">
|
|
132
|
-
<Input
|
|
133
|
-
aria-label="Token name"
|
|
134
|
-
className="min-w-0 flex-1"
|
|
135
|
-
maxLength={80}
|
|
136
|
-
onChange={(event) => setName(event.target.value)}
|
|
137
|
-
value={name}
|
|
138
|
-
/>
|
|
139
|
-
<Button
|
|
140
|
-
disabled={loading || busy || !name.trim()}
|
|
141
|
-
onClick={() => {
|
|
142
|
-
if (createTokenStarted.current) return;
|
|
143
|
-
revokeTokenMutation.reset();
|
|
144
|
-
createTokenStarted.current = true;
|
|
145
|
-
createTokenMutation.mutate(name);
|
|
146
|
-
}}
|
|
147
|
-
>
|
|
148
|
-
Create token
|
|
149
|
-
</Button>
|
|
150
|
-
</div>
|
|
151
|
-
)}
|
|
152
|
-
|
|
153
|
-
{displayedError ? (
|
|
154
|
-
<InlineError className="mt-4">{displayedError}</InlineError>
|
|
155
|
-
) : null}
|
|
156
|
-
|
|
157
|
-
<div className="mt-5 space-y-2">
|
|
158
|
-
{loading ? (
|
|
159
|
-
<p className="text-sm text-dashboard-text-muted">
|
|
160
|
-
Loading API tokens…
|
|
161
|
-
</p>
|
|
162
|
-
) : tokens.length === 0 ? (
|
|
163
|
-
<p className="text-sm text-dashboard-text-muted">
|
|
164
|
-
No active API tokens.
|
|
165
|
-
</p>
|
|
166
|
-
) : (
|
|
167
|
-
tokens.map((token) => (
|
|
168
|
-
<div
|
|
169
|
-
className="flex items-center gap-3 rounded-md border border-dashboard-border-strong p-3"
|
|
170
|
-
key={token.id}
|
|
171
|
-
>
|
|
172
|
-
<KeyRound className="text-dashboard-accent" size={16} />
|
|
173
|
-
<div className="min-w-0 flex-1">
|
|
174
|
-
<p className="m-0 truncate text-sm font-semibold">
|
|
175
|
-
{token.name}
|
|
176
|
-
</p>
|
|
177
|
-
<p className="mt-1 mb-0 text-xs text-dashboard-text-muted">
|
|
178
|
-
Ends in {token.tokenSuffix} · Expires{" "}
|
|
179
|
-
{new Date(token.expiresAt).toLocaleDateString()}
|
|
180
|
-
</p>
|
|
181
|
-
</div>
|
|
182
|
-
<Button
|
|
183
|
-
aria-label={`Revoke ${token.name}`}
|
|
184
|
-
disabled={busy}
|
|
185
|
-
onClick={() => {
|
|
186
|
-
if (createTokenMutation.isError) {
|
|
187
|
-
createTokenMutation.reset();
|
|
188
|
-
}
|
|
189
|
-
revokeTokenMutation.mutate(token);
|
|
190
|
-
}}
|
|
191
|
-
size="icon"
|
|
192
|
-
variant="danger"
|
|
193
|
-
type="button"
|
|
194
|
-
>
|
|
195
|
-
<Trash2 size={16} />
|
|
196
|
-
</Button>
|
|
197
|
-
</div>
|
|
198
|
-
))
|
|
199
|
-
)}
|
|
200
|
-
</div>
|
|
201
|
-
</Card>
|
|
202
|
-
</section>
|
|
203
|
-
</div>
|
|
204
|
-
);
|
|
205
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# System pages
|
|
2
|
-
|
|
3
|
-
- The URL selects a Workspace recipe: `/system/workspaces/:id` opens
|
|
4
|
-
`WorkspaceDetails` beside the list.
|
|
5
|
-
- The list report has no snapshots. The detail read adds the current
|
|
6
|
-
snapshot. Keep snapshot loading and errors inside the Snapshot row.
|
|
7
|
-
- Edit and Delete live in the panel header. List rows have no actions.
|
|
8
|
-
- `WorkspaceDeleteDialog` keeps errors in the dialog and blocks dismissal
|
|
9
|
-
while the request runs.
|
|
10
|
-
- Close and Escape return focus to the selected row.
|
|
11
|
-
|
|
12
|
-
Browser coverage: `e2e/system.spec.ts`.
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import { Archive } from "lucide-react";
|
|
2
|
-
import type { ReactNode } from "react";
|
|
3
|
-
import { Link } from "react-router";
|
|
4
|
-
import type { ReviewItemPayload } from "@sentry/junior/api/schema";
|
|
5
|
-
|
|
6
|
-
import { StatusChip } from "../components/StatusChip";
|
|
7
|
-
import { DisplayText } from "../display-text/DisplayText";
|
|
8
|
-
import { conversationPath } from "../conversations/conversationRoutes";
|
|
9
|
-
import { automationPath, formatRelativeTime } from "../format";
|
|
10
|
-
import { ReviewItemActions } from "./ReviewItemActions";
|
|
11
|
-
import { useLinkedConversationTitles } from "./queries";
|
|
12
|
-
import { assigneeLabel, reviewItemStatusLabel } from "./reviewItemFormat";
|
|
13
|
-
|
|
14
|
-
function Fact(props: { children: ReactNode; label: string }) {
|
|
15
|
-
return (
|
|
16
|
-
<div className="grid content-start gap-0.5">
|
|
17
|
-
<dt className="text-xs font-medium text-dashboard-text-muted">
|
|
18
|
-
{props.label}
|
|
19
|
-
</dt>
|
|
20
|
-
<dd className="m-0 min-w-0 break-words text-sm">{props.children}</dd>
|
|
21
|
-
</div>
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Show one Review item with its facts and the viewer's actions. */
|
|
26
|
-
export function ReviewItemDetail(props: { item: ReviewItemPayload }) {
|
|
27
|
-
const { item } = props;
|
|
28
|
-
const subject = item.subject;
|
|
29
|
-
const conversationTitles = useLinkedConversationTitles(item.conversationIds);
|
|
30
|
-
const closed = item.status !== "open";
|
|
31
|
-
return (
|
|
32
|
-
<article className="grid min-w-0 content-start gap-5">
|
|
33
|
-
{closed ? (
|
|
34
|
-
// Search mixes open and archived items, so a closed item must be
|
|
35
|
-
// easy to tell apart at the top of the detail.
|
|
36
|
-
<p className="m-0 flex items-center gap-2 rounded-md bg-dashboard-fill-soft px-3 py-2 text-xs font-medium text-dashboard-text-muted">
|
|
37
|
-
<Archive aria-hidden="true" size={14} />
|
|
38
|
-
Archived · {reviewItemStatusLabel(item)}
|
|
39
|
-
</p>
|
|
40
|
-
) : null}
|
|
41
|
-
<header className="grid gap-1.5">
|
|
42
|
-
<div className="flex min-w-0 items-start justify-between gap-3">
|
|
43
|
-
<h2 className="m-0 min-w-0 break-words font-display text-xl font-medium leading-snug">
|
|
44
|
-
{item.title}
|
|
45
|
-
</h2>
|
|
46
|
-
{closed ? null : (
|
|
47
|
-
<StatusChip className="mt-1 shrink-0" size="compact" tone="accent">
|
|
48
|
-
{reviewItemStatusLabel(item)}
|
|
49
|
-
</StatusChip>
|
|
50
|
-
)}
|
|
51
|
-
</div>
|
|
52
|
-
<p className="m-0 text-xs text-dashboard-text-muted">
|
|
53
|
-
Added {formatRelativeTime(item.createdAt)}
|
|
54
|
-
{item.visibility === "private" ? " · Private" : null}
|
|
55
|
-
</p>
|
|
56
|
-
</header>
|
|
57
|
-
<DisplayText text={item.body} />
|
|
58
|
-
<section
|
|
59
|
-
aria-label={item.status === "open" ? "Actions" : "Outcome"}
|
|
60
|
-
className="border-t border-dashboard-border-subtle pt-5"
|
|
61
|
-
>
|
|
62
|
-
<ReviewItemActions item={item} />
|
|
63
|
-
</section>
|
|
64
|
-
<dl className="m-0 grid gap-4 border-t border-dashboard-border-subtle pt-5 sm:grid-cols-2">
|
|
65
|
-
<Fact label="Assignees">
|
|
66
|
-
{item.assignees.length > 0 || item.assignedToEveryone ? (
|
|
67
|
-
assigneeLabel(item)
|
|
68
|
-
) : (
|
|
69
|
-
<StatusChip size="compact" tone="warning">
|
|
70
|
-
Unassigned
|
|
71
|
-
</StatusChip>
|
|
72
|
-
)}
|
|
73
|
-
</Fact>
|
|
74
|
-
{subject ? (
|
|
75
|
-
<Fact label="Subject">
|
|
76
|
-
{subject.url ? (
|
|
77
|
-
<a href={subject.url} rel="noreferrer" target="_blank">
|
|
78
|
-
{subject.title ?? subject.url}
|
|
79
|
-
</a>
|
|
80
|
-
) : (
|
|
81
|
-
(subject.title ?? subject.id)
|
|
82
|
-
)}
|
|
83
|
-
</Fact>
|
|
84
|
-
) : null}
|
|
85
|
-
<Fact label="Added by">
|
|
86
|
-
<Link to={conversationPath(item.addedByConversationId)}>
|
|
87
|
-
Conversation
|
|
88
|
-
</Link>
|
|
89
|
-
{item.automationId ? (
|
|
90
|
-
<>
|
|
91
|
-
{" · "}
|
|
92
|
-
<Link to={automationPath(item.automationId)}>Automation</Link>
|
|
93
|
-
</>
|
|
94
|
-
) : null}
|
|
95
|
-
</Fact>
|
|
96
|
-
{item.conversationIds.length > 0 ? (
|
|
97
|
-
<Fact label="Linked Conversations">
|
|
98
|
-
<ul className="m-0 grid list-none gap-0.5 p-0">
|
|
99
|
-
{item.conversationIds.map((conversationId, index) => {
|
|
100
|
-
const title = conversationTitles[index];
|
|
101
|
-
return (
|
|
102
|
-
<li
|
|
103
|
-
className={
|
|
104
|
-
title ? "truncate" : "truncate font-mono text-xs"
|
|
105
|
-
}
|
|
106
|
-
key={conversationId}
|
|
107
|
-
>
|
|
108
|
-
<Link
|
|
109
|
-
title={conversationId}
|
|
110
|
-
to={conversationPath(conversationId)}
|
|
111
|
-
>
|
|
112
|
-
{title ?? conversationId}
|
|
113
|
-
</Link>
|
|
114
|
-
</li>
|
|
115
|
-
);
|
|
116
|
-
})}
|
|
117
|
-
</ul>
|
|
118
|
-
</Fact>
|
|
119
|
-
) : null}
|
|
120
|
-
</dl>
|
|
121
|
-
</article>
|
|
122
|
-
);
|
|
123
|
-
}
|