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:*",
|
"helpers": "workspace:*",
|
||||||
"hono": "^4.11.1",
|
"hono": "^4.11.1",
|
||||||
"hooks": "workspace:*",
|
"hooks": "workspace:*",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"i18n": "workspace:*",
|
"i18n": "workspace:*",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
@@ -1556,6 +1557,8 @@
|
|||||||
|
|
||||||
"hooks": ["hooks@workspace:src/workspaces/hooks"],
|
"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-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=="],
|
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
"helpers": "workspace:*",
|
"helpers": "workspace:*",
|
||||||
"hono": "^4.11.1",
|
"hono": "^4.11.1",
|
||||||
"hooks": "workspace:*",
|
"hooks": "workspace:*",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"i18n": "workspace:*",
|
"i18n": "workspace:*",
|
||||||
"idb-keyval": "^6.2.2",
|
"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 { Header } from './Header';
|
||||||
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
|
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
|
||||||
import { useIsTouch } from './useIsTouch';
|
import { useIsTouch } from './useIsTouch';
|
||||||
|
import { BugReportButton } from './BugReport/BugReportButton';
|
||||||
|
|
||||||
type DashboardLayoutProps = {
|
type DashboardLayoutProps = {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -21,6 +22,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<BugReportButton />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import './lib/console-buffer';
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { App } from './App';
|
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 { channelsRouter } from './channels/routes';
|
||||||
import { browserRouter } from './api/browser/router';
|
import { browserRouter } from './api/browser/router';
|
||||||
import { appsRouter, appServeRouter } from './api/apps';
|
import { appsRouter, appServeRouter } from './api/apps';
|
||||||
|
import { bugReportRouter } from './api/bug-report/bug-report';
|
||||||
import { CustomError } from './custom-errors';
|
import { CustomError } from './custom-errors';
|
||||||
import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares';
|
import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares';
|
||||||
|
|
||||||
@@ -86,6 +87,7 @@ protectedRouter.route('/email', emailRouter);
|
|||||||
protectedRouter.route('/channels', channelsRouter);
|
protectedRouter.route('/channels', channelsRouter);
|
||||||
protectedRouter.route('/browser', browserRouter);
|
protectedRouter.route('/browser', browserRouter);
|
||||||
protectedRouter.route('/apps', appsRouter);
|
protectedRouter.route('/apps', appsRouter);
|
||||||
|
protectedRouter.route('/bug-report', bugReportRouter);
|
||||||
protectedRouter.route('/', piRestRouter);
|
protectedRouter.route('/', piRestRouter);
|
||||||
|
|
||||||
honoServer.route('/api', protectedRouter);
|
honoServer.route('/api', protectedRouter);
|
||||||
|
|||||||
Reference in New Issue
Block a user