bug report tool — screenshot, console logs, and context sent to discord
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -70,6 +70,7 @@
|
||||
"helpers": "workspace:*",
|
||||
"hono": "^4.11.1",
|
||||
"hooks": "workspace:*",
|
||||
"html-to-image": "^1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18n": "workspace:*",
|
||||
"idb-keyval": "^6.2.2",
|
||||
@@ -1556,6 +1557,8 @@
|
||||
|
||||
"hooks": ["hooks@workspace:src/workspaces/hooks"],
|
||||
|
||||
"html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
|
||||
|
||||
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"helpers": "workspace:*",
|
||||
"hono": "^4.11.1",
|
||||
"hooks": "workspace:*",
|
||||
"html-to-image": "^1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18n": "workspace:*",
|
||||
"idb-keyval": "^6.2.2",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Bug } from 'lucide-react';
|
||||
import { useBugReport } from './use-bug-report';
|
||||
import { BugReportDialog } from './BugReportDialog';
|
||||
|
||||
export const BugReportButton = () => {
|
||||
const bugReport = useBugReport();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={bugReport.handleOpen}
|
||||
className="fixed bottom-20 right-4 z-[90] flex h-10 w-10 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-lg transition-transform hover:scale-110"
|
||||
aria-label="Report a bug"
|
||||
>
|
||||
<Bug size={20} />
|
||||
</button>
|
||||
<BugReportDialog
|
||||
open={bugReport.open}
|
||||
capturing={bugReport.capturing}
|
||||
submitting={bugReport.submitting}
|
||||
screenshot={bugReport.screenshot}
|
||||
onClose={bugReport.handleClose}
|
||||
onSubmit={bugReport.handleSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
type BugReportDialogProps = {
|
||||
open: boolean;
|
||||
capturing: boolean;
|
||||
submitting: boolean;
|
||||
screenshot: Blob | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (description: string) => void;
|
||||
};
|
||||
|
||||
export const BugReportDialog = ({ open, capturing, submitting, screenshot, onClose, onSubmit }: BugReportDialogProps) => {
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit(description);
|
||||
setDescription('');
|
||||
};
|
||||
|
||||
const canSubmit = description.trim().length > 0 && !capturing && !submitting;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Report a Bug</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-md border bg-muted">
|
||||
{capturing ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : previewUrl ? (
|
||||
<img src={previewUrl} alt="Screenshot" className="max-h-48 w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
||||
No screenshot captured
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
placeholder="Describe what went wrong..."
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
rows={4}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Console logs, browser info, and recent API errors are collected automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit}>
|
||||
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Submit
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { toBlob } from 'html-to-image';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import type { ErrorDetails } from 'hooks/useClient';
|
||||
import { getConsoleBuffer } from '@/lib/console-buffer';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const useBugReport = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [screenshot, setScreenshot] = useState<Blob | null>(null);
|
||||
const [capturing, setCapturing] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const client = useClient();
|
||||
const { user } = useAuth();
|
||||
const [apiError] = useGlobal<ErrorDetails | null>('API_ERROR', null);
|
||||
|
||||
const handleOpen = useCallback(async () => {
|
||||
setCapturing(true);
|
||||
try {
|
||||
const blob = await toBlob(document.body, { pixelRatio: 0.75 });
|
||||
setScreenshot(blob);
|
||||
} catch (err) {
|
||||
console.error('Screenshot capture failed:', err);
|
||||
} finally {
|
||||
setCapturing(false);
|
||||
setOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
setScreenshot(null);
|
||||
setCapturing(false);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (description: string) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const context = {
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
timestamp: new Date().toISOString(),
|
||||
consoleLogs: getConsoleBuffer(),
|
||||
apiError,
|
||||
user: user ? { id: user.id, email: user.email, name: user.name } : null,
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('description', description);
|
||||
formData.append('context', JSON.stringify(context));
|
||||
if (screenshot) {
|
||||
formData.append('screenshot', screenshot, 'screenshot.png');
|
||||
}
|
||||
|
||||
await client.post('/bug-report', formData);
|
||||
toast.success('Bug report submitted');
|
||||
handleClose();
|
||||
} catch {
|
||||
toast.error('Failed to submit bug report');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[screenshot, client, user, apiError, handleClose],
|
||||
);
|
||||
|
||||
return { open, capturing, submitting, screenshot, handleOpen, handleClose, handleSubmit };
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { Background } from './Background';
|
||||
import { Header } from './Header';
|
||||
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
|
||||
import { useIsTouch } from './useIsTouch';
|
||||
import { BugReportButton } from './BugReport/BugReportButton';
|
||||
|
||||
type DashboardLayoutProps = {
|
||||
children?: React.ReactNode;
|
||||
@@ -21,6 +22,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
<BugReportButton />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import './lib/console-buffer';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
type LogLevel = 'log' | 'warn' | 'error';
|
||||
|
||||
type LogEntry = {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const MAX_ENTRIES = 100;
|
||||
const buffer: LogEntry[] = [];
|
||||
|
||||
const originalLog = console.log;
|
||||
const originalWarn = console.warn;
|
||||
const originalError = console.error;
|
||||
|
||||
function pushEntry(level: LogLevel, args: unknown[]) {
|
||||
const message = args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ');
|
||||
buffer.push({ level, message, timestamp: Date.now() });
|
||||
if (buffer.length > MAX_ENTRIES) buffer.shift();
|
||||
}
|
||||
|
||||
console.log = (...args: unknown[]) => {
|
||||
pushEntry('log', args);
|
||||
originalLog.apply(console, args);
|
||||
};
|
||||
|
||||
console.warn = (...args: unknown[]) => {
|
||||
pushEntry('warn', args);
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
|
||||
console.error = (...args: unknown[]) => {
|
||||
pushEntry('error', args);
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
|
||||
export const getConsoleBuffer = (): LogEntry[] => [...buffer];
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
|
||||
const DISCORD_WEBHOOK_URL = process.env.DISCORD_BUG_REPORT_WEBHOOK;
|
||||
|
||||
export const bugReportRouter = createRouter();
|
||||
|
||||
bugReportRouter.post('/', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.get('body') as Record<string, unknown>;
|
||||
|
||||
const description = body.description as string | undefined;
|
||||
const contextRaw = body.context as string | undefined;
|
||||
const screenshot = body.screenshot as File | undefined;
|
||||
|
||||
if (!description?.trim()) {
|
||||
return ctx.text('Description is required', 400);
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const uuid = crypto.randomUUID().slice(0, 8);
|
||||
const dirName = `${timestamp}-${uuid}`;
|
||||
const reportDir = join(DATA_PATH, 'bug-reports', dirName);
|
||||
|
||||
await mkdir(reportDir, { recursive: true });
|
||||
|
||||
const context = contextRaw ? JSON.parse(contextRaw) : null;
|
||||
const report = {
|
||||
description: description.trim(),
|
||||
context,
|
||||
reporter: { id: user.id, email: user.email, name: user.name },
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await Bun.write(join(reportDir, 'report.json'), JSON.stringify(report, null, 2));
|
||||
|
||||
let screenshotBuffer: Buffer | null = null;
|
||||
if (screenshot instanceof File) {
|
||||
screenshotBuffer = Buffer.from(await screenshot.arrayBuffer());
|
||||
await Bun.write(join(reportDir, 'screenshot.png'), screenshotBuffer);
|
||||
}
|
||||
|
||||
if (DISCORD_WEBHOOK_URL) {
|
||||
await sendToDiscord(report, screenshotBuffer);
|
||||
}
|
||||
|
||||
return ctx.json({ ok: true, id: dirName });
|
||||
});
|
||||
|
||||
type BugReport = {
|
||||
description: string;
|
||||
context: { url?: string; userAgent?: string; viewport?: { width: number; height: number }; apiError?: { status: number; message: string } | null } | null;
|
||||
reporter: { id: number; email: string; name: string };
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
async function sendToDiscord(report: BugReport, screenshot: Buffer | null) {
|
||||
const embed = {
|
||||
title: 'Bug Report',
|
||||
description: report.description,
|
||||
color: 0xed4245,
|
||||
fields: [
|
||||
{ name: 'Reporter', value: `${report.reporter.name} (${report.reporter.email})`, inline: true },
|
||||
{ name: 'URL', value: report.context?.url ?? 'N/A', inline: false },
|
||||
{ name: 'Viewport', value: report.context?.viewport ? `${report.context.viewport.width}x${report.context.viewport.height}` : 'N/A', inline: true },
|
||||
{ name: 'Browser', value: shortenUA(report.context?.userAgent), inline: true },
|
||||
],
|
||||
timestamp: report.createdAt,
|
||||
};
|
||||
|
||||
if (report.context?.apiError) {
|
||||
embed.fields.push({ name: 'Last API Error', value: `${report.context.apiError.status}: ${report.context.apiError.message}`, inline: false });
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append('payload_json', JSON.stringify({ embeds: [embed] }));
|
||||
|
||||
if (screenshot) {
|
||||
form.append('files[0]', new Blob([screenshot], { type: 'image/png' }), 'screenshot.png');
|
||||
}
|
||||
|
||||
const res = await fetch(DISCORD_WEBHOOK_URL!, { method: 'POST', body: form });
|
||||
if (!res.ok) {
|
||||
console.error('[bug-report] Discord webhook failed:', res.status, await res.text());
|
||||
}
|
||||
}
|
||||
|
||||
function shortenUA(ua?: string): string {
|
||||
if (!ua) return 'N/A';
|
||||
const browser = ua.match(/(Chrome|Firefox|Safari|Edge|Brave|OPR)\/[\d.]+/)?.[0] ?? '';
|
||||
const os = ua.match(/\(([^)]+)\)/)?.[1]?.split(';')[0] ?? '';
|
||||
return [browser, os].filter(Boolean).join(' — ') || ua.slice(0, 80);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { emailRouter } from './api/email/email';
|
||||
import { channelsRouter } from './channels/routes';
|
||||
import { browserRouter } from './api/browser/router';
|
||||
import { appsRouter, appServeRouter } from './api/apps';
|
||||
import { bugReportRouter } from './api/bug-report/bug-report';
|
||||
import { CustomError } from './custom-errors';
|
||||
import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares';
|
||||
|
||||
@@ -86,6 +87,7 @@ protectedRouter.route('/email', emailRouter);
|
||||
protectedRouter.route('/channels', channelsRouter);
|
||||
protectedRouter.route('/browser', browserRouter);
|
||||
protectedRouter.route('/apps', appsRouter);
|
||||
protectedRouter.route('/bug-report', bugReportRouter);
|
||||
protectedRouter.route('/', piRestRouter);
|
||||
|
||||
honoServer.route('/api', protectedRouter);
|
||||
|
||||
Reference in New Issue
Block a user