diff --git a/bun.lock b/bun.lock index 4f73a640..da859ab4 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index c1de6f22..6ac73aaa 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/BugReportButton.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/BugReportButton.tsx new file mode 100644 index 00000000..a93733e5 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/BugReportButton.tsx @@ -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 ( + <> + + + + + > + ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/BugReportDialog.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/BugReportDialog.tsx new file mode 100644 index 00000000..b9952ced --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/BugReportDialog.tsx @@ -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 ( + + + + Report a Bug + + + + + {capturing ? ( + + + + ) : previewUrl ? ( + + ) : ( + + No screenshot captured + + )} + + + setDescription(ev.target.value)} + rows={4} + autoFocus + /> + + + Console logs, browser info, and recent API errors are collected automatically. + + + + + + Cancel + + + {submitting ? : null} + Submit + + + + + ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/use-bug-report.ts b/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/use-bug-report.ts new file mode 100644 index 00000000..abc486a7 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/use-bug-report.ts @@ -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(null); + const [capturing, setCapturing] = useState(false); + const [submitting, setSubmitting] = useState(false); + const client = useClient(); + const { user } = useAuth(); + const [apiError] = useGlobal('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 }; +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx index 82f90fe3..c158362b 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx @@ -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} + ); }; diff --git a/src/apps/officer-web/frontend.tsx b/src/apps/officer-web/frontend.tsx index 49f89528..bfdcdd6e 100644 --- a/src/apps/officer-web/frontend.tsx +++ b/src/apps/officer-web/frontend.tsx @@ -1,3 +1,4 @@ +import './lib/console-buffer'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App'; diff --git a/src/apps/officer-web/lib/console-buffer.ts b/src/apps/officer-web/lib/console-buffer.ts new file mode 100644 index 00000000..0970a7b2 --- /dev/null +++ b/src/apps/officer-web/lib/console-buffer.ts @@ -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]; diff --git a/src/servers/api/bug-report/bug-report.ts b/src/servers/api/bug-report/bug-report.ts new file mode 100644 index 00000000..2687a885 --- /dev/null +++ b/src/servers/api/bug-report/bug-report.ts @@ -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; + + 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); +} diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 69b7c86d..0372888a 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -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);
+ Console logs, browser info, and recent API errors are collected automatically. +