Skip to content

Commit 645056a

Browse files
committed
Support event attachments
1 parent 3b75448 commit 645056a

15 files changed

Lines changed: 669 additions & 62 deletions

File tree

frontend/src/api/client.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,32 @@ export async function apiRequest<T>(
6363
return response.json();
6464
}
6565

66+
async function apiRaw(
67+
path: string,
68+
options: ApiRequestOptions = {},
69+
): Promise<Response> {
70+
const token = localStorage.getItem("bugs_admin_token");
71+
const headers: Record<string, string> = {
72+
...(options.headers as Record<string, string>),
73+
};
74+
if (token) headers["Authorization"] = `Bearer ${token}`;
75+
76+
const response = await fetch(`${BASE_URL}${path}`, {
77+
...options,
78+
headers,
79+
signal: options.signal,
80+
});
81+
if (!response.ok) {
82+
if (response.status === 401) {
83+
triggerLogout();
84+
throw new ApiError(response.status, response.statusText, null);
85+
}
86+
const body = await response.json().catch(() => null);
87+
throw new ApiError(response.status, response.statusText, body);
88+
}
89+
return response;
90+
}
91+
6692
export const api = {
6793
get: <T>(path: string, signal?: AbortSignal) =>
6894
apiRequest<T>(path, { signal }),
@@ -72,4 +98,8 @@ export const api = {
7298
apiRequest<T>(path, { method: "PUT", body: JSON.stringify(body), signal }),
7399
delete: <T>(path: string, signal?: AbortSignal) =>
74100
apiRequest<T>(path, { method: "DELETE", signal }),
101+
text: async (path: string, signal?: AbortSignal) =>
102+
(await apiRaw(path, { signal })).text(),
103+
blob: async (path: string, signal?: AbortSignal) =>
104+
(await apiRaw(path, { signal })).blob(),
75105
};
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { createQuery } from "@tanstack/solid-query";
2+
import { createEffect, createSignal, For, Show } from "solid-js";
3+
import { api } from "~/api/client";
4+
import { queryKeys } from "~/queries/keys";
5+
import type { EventAttachment } from "~/lib/sentry-types";
6+
import Button from "~/components/ui/Button";
7+
import IconDownload from "~icons/lucide/download";
8+
import IconEye from "~icons/lucide/eye";
9+
import IconEyeOff from "~icons/lucide/eye-off";
10+
import IconPaperclip from "~icons/lucide/paperclip";
11+
12+
interface AttachmentsPanelProps {
13+
eventId: number;
14+
}
15+
16+
function formatBytes(bytes: number): string {
17+
if (bytes < 1024) return `${bytes} B`;
18+
const units = ["KB", "MB", "GB"];
19+
let value = bytes / 1024;
20+
let unit = 0;
21+
while (value >= 1024 && unit < units.length - 1) {
22+
value /= 1024;
23+
unit += 1;
24+
}
25+
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`;
26+
}
27+
28+
function downloadFilename(name: string): string {
29+
const sanitized = name.replace(/[\\/\0-\x1f\x7f]+/g, "_").trim();
30+
return sanitized || "attachment";
31+
}
32+
33+
export default function AttachmentsPanel(props: AttachmentsPanelProps) {
34+
const [previewId, setPreviewId] = createSignal<number | null>(null);
35+
const [downloadingId, setDownloadingId] = createSignal<number | null>(null);
36+
37+
createEffect(() => {
38+
props.eventId;
39+
setPreviewId(null);
40+
});
41+
42+
const attachmentsQuery = createQuery(() => ({
43+
queryKey: queryKeys.events.attachments(props.eventId),
44+
queryFn: ({ signal }) =>
45+
api.get<EventAttachment[]>(
46+
`/internal/events/${props.eventId}/attachments`,
47+
signal,
48+
),
49+
}));
50+
51+
const previewQuery = createQuery(() => ({
52+
queryKey: queryKeys.events.attachmentText(
53+
props.eventId,
54+
previewId() ?? "none",
55+
),
56+
queryFn: ({ signal }) =>
57+
api.text(
58+
`/internal/events/${props.eventId}/attachments/${previewId()}/text`,
59+
signal,
60+
),
61+
enabled: previewId() !== null,
62+
}));
63+
64+
const attachments = () => attachmentsQuery.data ?? [];
65+
const selectedAttachment = () =>
66+
attachments().find((attachment) => attachment.id === previewId()) ?? null;
67+
68+
const downloadAttachment = async (attachment: EventAttachment) => {
69+
setDownloadingId(attachment.id);
70+
try {
71+
const blob = await api.blob(
72+
`/internal/events/${props.eventId}/attachments/${attachment.id}/download`,
73+
);
74+
const url = URL.createObjectURL(blob);
75+
const link = document.createElement("a");
76+
link.href = url;
77+
link.download = downloadFilename(attachment.name);
78+
document.body.appendChild(link);
79+
link.click();
80+
link.remove();
81+
URL.revokeObjectURL(url);
82+
} finally {
83+
setDownloadingId(null);
84+
}
85+
};
86+
87+
return (
88+
<Show when={attachments().length > 0}>
89+
<div class="card attachments">
90+
<div class="card__header">
91+
<h3 class="inline-gap">
92+
<IconPaperclip /> Attachments
93+
</h3>
94+
<span class="text-xs text-secondary">
95+
{attachments().length} file{attachments().length === 1 ? "" : "s"}
96+
</span>
97+
</div>
98+
<table class="data-table data-table--compact attachments__table">
99+
<thead>
100+
<tr>
101+
<th>Name</th>
102+
<th>Type</th>
103+
<th>Size</th>
104+
<th data-align="right">Actions</th>
105+
</tr>
106+
</thead>
107+
<tbody>
108+
<For each={attachments()}>
109+
{(attachment) => (
110+
<tr>
111+
<td>
112+
<div class="attachments__name">{attachment.name}</div>
113+
<Show when={attachment.attachment_type}>
114+
<div class="text-secondary">
115+
{attachment.attachment_type}
116+
</div>
117+
</Show>
118+
</td>
119+
<td>{attachment.content_type ?? "application/octet-stream"}</td>
120+
<td>{formatBytes(attachment.size)}</td>
121+
<td data-align="right">
122+
<div class="attachments__actions">
123+
<Button
124+
variant="ghost"
125+
size="sm"
126+
onClick={() =>
127+
setPreviewId((current) =>
128+
current === attachment.id ? null : attachment.id,
129+
)
130+
}
131+
>
132+
{previewId() === attachment.id ? <IconEyeOff /> : <IconEye />} Plaintext
133+
</Button>
134+
<Button
135+
variant="ghost"
136+
size="sm"
137+
disabled={downloadingId() === attachment.id}
138+
onClick={() => void downloadAttachment(attachment)}
139+
>
140+
<IconDownload /> Download
141+
</Button>
142+
</div>
143+
</td>
144+
</tr>
145+
)}
146+
</For>
147+
</tbody>
148+
</table>
149+
<Show when={previewId() !== null}>
150+
<div class="attachment-preview">
151+
<div class="attachment-preview__header">
152+
<span>{selectedAttachment()?.name ?? "Attachment"}</span>
153+
<span class="text-secondary">Plaintext preview</span>
154+
</div>
155+
<pre class="attachment-preview__content">
156+
<Show
157+
when={!previewQuery.isError}
158+
fallback="Unable to load attachment preview."
159+
>
160+
<Show when={!previewQuery.isPending} fallback="Loading attachment...">
161+
{previewQuery.data ?? ""}
162+
</Show>
163+
</Show>
164+
</pre>
165+
</div>
166+
</Show>
167+
</div>
168+
</Show>
169+
);
170+
}

frontend/src/components/events/ContextPanels.tsx

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,9 @@ import { createSignal, For, Show } from "solid-js";
22
import { displayValue } from "~/lib/formatters";
33

44
interface ContextPanelsProps {
5-
contexts?: Record<string, Record<string, unknown>>;
6-
request?: {
7-
method?: string;
8-
url?: string;
9-
headers?: Record<string, string>;
10-
query_string?: string;
11-
data?: unknown;
12-
env?: Record<string, string>;
13-
};
14-
user?: Record<string, unknown>;
5+
contexts?: Record<string, unknown>;
6+
request?: Record<string, unknown> | null;
7+
user?: Record<string, unknown> | null;
158
}
169

1710
interface TabDef {
@@ -20,6 +13,16 @@ interface TabDef {
2013
data: Record<string, unknown>;
2114
}
2215

16+
function asRecord(value: unknown): Record<string, unknown> | null {
17+
return value && typeof value === "object" && !Array.isArray(value)
18+
? (value as Record<string, unknown>)
19+
: null;
20+
}
21+
22+
function asString(value: unknown): string | null {
23+
return typeof value === "string" ? value : null;
24+
}
25+
2326
export default function ContextPanels(props: ContextPanelsProps) {
2427
const tabs = (): TabDef[] => {
2528
const result: TabDef[] = [];
@@ -31,37 +34,44 @@ export default function ContextPanels(props: ContextPanelsProps) {
3134
if (props.contexts) {
3235
const order = ["browser", "os", "device", "runtime"];
3336
for (const key of order) {
34-
if (props.contexts[key] && Object.keys(props.contexts[key]).length > 0) {
37+
const value = asRecord(props.contexts[key]);
38+
if (value && Object.keys(value).length > 0) {
3539
result.push({
3640
key,
3741
label: key.charAt(0).toUpperCase() + key.slice(1),
38-
data: props.contexts[key],
42+
data: value,
3943
});
4044
}
4145
}
4246
for (const [key, value] of Object.entries(props.contexts)) {
43-
if (!order.includes(key) && value && Object.keys(value).length > 0) {
47+
const data = asRecord(value);
48+
if (!order.includes(key) && data && Object.keys(data).length > 0) {
4449
result.push({
4550
key,
4651
label: key.charAt(0).toUpperCase() + key.slice(1),
47-
data: value,
52+
data,
4853
});
4954
}
5055
}
5156
}
5257

5358
if (props.request) {
5459
const reqData: Record<string, unknown> = {};
55-
if (props.request.method) reqData["method"] = props.request.method;
56-
if (props.request.url) reqData["url"] = props.request.url;
57-
if (props.request.query_string) reqData["query_string"] = props.request.query_string;
58-
if (props.request.headers) {
59-
for (const [hk, hv] of Object.entries(props.request.headers)) {
60+
const method = asString(props.request.method);
61+
const url = asString(props.request.url);
62+
const queryString = asString(props.request.query_string);
63+
const headers = asRecord(props.request.headers);
64+
const env = asRecord(props.request.env);
65+
if (method) reqData["method"] = method;
66+
if (url) reqData["url"] = url;
67+
if (queryString) reqData["query_string"] = queryString;
68+
if (headers) {
69+
for (const [hk, hv] of Object.entries(headers)) {
6070
reqData[`header: ${hk}`] = hv;
6171
}
6272
}
63-
if (props.request.env) {
64-
for (const [ek, ev] of Object.entries(props.request.env)) {
73+
if (env) {
74+
for (const [ek, ev] of Object.entries(env)) {
6575
reqData[`env: ${ek}`] = ev;
6676
}
6777
}

frontend/src/components/events/EventDetailView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import BreadcrumbsTimeline from "./BreadcrumbsTimeline";
1818
import ContextPanels from "./ContextPanels";
1919
import TagsTable from "./TagsTable";
2020
import ThreadsDisplay from "./ThreadsDisplay";
21+
import AttachmentsPanel from "./AttachmentsPanel";
2122
import IconEye from "~icons/lucide/eye";
2223
import IconEyeOff from "~icons/lucide/eye-off";
2324
import type { ExceptionValue } from "./ExceptionDisplay";
@@ -106,6 +107,8 @@ export default function EventDetailView(props: EventDetailViewProps) {
106107

107108
<ContextPanels contexts={contexts()} request={request()} user={user()} />
108109

110+
<AttachmentsPanel eventId={props.event.id} />
111+
109112
<div class="raw-json">
110113
<button
111114
class="raw-json__toggle"

frontend/src/lib/sentry-types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ export interface EventListResponse {
6161
nextCursor: string | null;
6262
}
6363

64+
export interface EventAttachment {
65+
id: number;
66+
event_id: number;
67+
name: string;
68+
content_type: string | null;
69+
attachment_type: string | null;
70+
size: number;
71+
created_at: string;
72+
}
73+
6474
export interface SearchResponse {
6575
results: Event[];
6676
}

0 commit comments

Comments
 (0)