feat: global bug report button — floating widget on every page
Adds a small bug icon (bottom-right) that opens a modal to collect errors, device info, comments, and optional screenshots, then emails the report to jeff@jeffemmett.com via the existing SMTP transport. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
83267a2209
commit
85cf54b811
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* Bug Report API — accepts user-submitted bug reports and emails them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { getSmtpTransport } from "./notification-service";
|
||||||
|
|
||||||
|
export const bugReportRouter = new Hono();
|
||||||
|
|
||||||
|
const MAX_SCREENSHOT_BYTES = 3 * 1024 * 1024; // 3 MB
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
bugReportRouter.post("/", async (c) => {
|
||||||
|
let body: any;
|
||||||
|
try {
|
||||||
|
body = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Invalid JSON" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userAgent, url, errors, comments, screenshot } = body as {
|
||||||
|
userAgent?: string;
|
||||||
|
url?: string;
|
||||||
|
errors?: string[];
|
||||||
|
comments?: string;
|
||||||
|
screenshot?: string; // data URL
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!comments?.trim() && (!errors || errors.length === 0)) {
|
||||||
|
return c.json({ error: "Please provide comments or error details" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screenshot handling
|
||||||
|
let attachments: any[] = [];
|
||||||
|
let screenshotCid = "";
|
||||||
|
if (screenshot && typeof screenshot === "string" && screenshot.startsWith("data:image/")) {
|
||||||
|
// Validate size (base64 is ~4/3 of raw, so check decoded)
|
||||||
|
const base64Part = screenshot.split(",")[1];
|
||||||
|
if (!base64Part) return c.json({ error: "Invalid screenshot data URL" }, 400);
|
||||||
|
const rawBytes = Buffer.from(base64Part, "base64");
|
||||||
|
if (rawBytes.length > MAX_SCREENSHOT_BYTES) {
|
||||||
|
return c.json({ error: "Screenshot exceeds 3 MB limit" }, 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine extension from mime
|
||||||
|
const mimeMatch = screenshot.match(/^data:image\/(png|jpeg|jpg|gif|webp);/);
|
||||||
|
const ext = mimeMatch ? mimeMatch[1].replace("jpeg", "jpg") : "png";
|
||||||
|
const filename = `bugreport-${Date.now()}.${ext}`;
|
||||||
|
|
||||||
|
// Save to generated files
|
||||||
|
const savePath = `/data/files/generated/${filename}`;
|
||||||
|
try {
|
||||||
|
await Bun.write(savePath, rawBytes);
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — still send email without saved file
|
||||||
|
}
|
||||||
|
|
||||||
|
screenshotCid = `screenshot-${Date.now()}`;
|
||||||
|
attachments = [{
|
||||||
|
filename,
|
||||||
|
content: rawBytes,
|
||||||
|
cid: screenshotCid,
|
||||||
|
contentType: `image/${ext === "jpg" ? "jpeg" : ext}`,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build HTML email
|
||||||
|
const errorList = (errors && errors.length > 0)
|
||||||
|
? errors.map(e => `<li style="margin:4px 0;font-size:13px;color:#fca5a5;">${escapeHtml(e)}</li>`).join("")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:600px;margin:0 auto;padding:24px;">
|
||||||
|
<div style="background:#1e293b;border-radius:10px;padding:20px;color:#e2e8f0;">
|
||||||
|
<h2 style="margin:0 0 12px;font-size:18px;color:#f87171;">Bug Report</h2>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:13px;color:#94a3b8;">
|
||||||
|
<tr><td style="padding:4px 8px 4px 0;font-weight:600;color:#cbd5e1;white-space:nowrap;">Page URL</td><td style="padding:4px 0;">${escapeHtml(url || "unknown")}</td></tr>
|
||||||
|
<tr><td style="padding:4px 8px 4px 0;font-weight:600;color:#cbd5e1;white-space:nowrap;">Device</td><td style="padding:4px 0;">${escapeHtml(userAgent || "unknown")}</td></tr>
|
||||||
|
</table>
|
||||||
|
${errorList ? `<div style="margin-top:12px;"><strong style="color:#fca5a5;font-size:13px;">Recent Errors:</strong><ul style="margin:4px 0 0;padding-left:20px;">${errorList}</ul></div>` : ""}
|
||||||
|
${comments?.trim() ? `<div style="margin-top:12px;"><strong style="color:#cbd5e1;font-size:13px;">Comments:</strong><p style="margin:4px 0 0;font-size:14px;color:#e2e8f0;white-space:pre-wrap;">${escapeHtml(comments)}</p></div>` : ""}
|
||||||
|
${screenshotCid ? `<div style="margin-top:16px;"><strong style="color:#cbd5e1;font-size:13px;">Screenshot:</strong><br><img src="cid:${screenshotCid}" style="margin-top:8px;max-width:100%;border-radius:6px;border:1px solid #334155;"></div>` : ""}
|
||||||
|
</div>
|
||||||
|
<p style="margin:12px 0 0;font-size:11px;color:#64748b;text-align:center;">Sent from rSpace Bug Reporter</p>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transport = await getSmtpTransport();
|
||||||
|
if (!transport) {
|
||||||
|
console.error("[bug-report] No SMTP transport available");
|
||||||
|
return c.json({ error: "Email service unavailable" }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transport.sendMail({
|
||||||
|
from: "rSpace Bugs <bugs@rspace.online>",
|
||||||
|
to: "jeff@jeffemmett.com",
|
||||||
|
subject: `[Bug] ${url || "rSpace"} — ${(comments || "").slice(0, 60) || "Error report"}`,
|
||||||
|
html,
|
||||||
|
attachments,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[bug-report] Report sent for ${url}`);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[bug-report] Failed to send:", err.message);
|
||||||
|
return c.json({ error: "Failed to send report" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -107,6 +107,7 @@ import { registerUserConnection, unregisterUserConnection, notify } from "./noti
|
||||||
import { SystemClock } from "./clock-service";
|
import { SystemClock } from "./clock-service";
|
||||||
import type { ClockPayload } from "./clock-service";
|
import type { ClockPayload } from "./clock-service";
|
||||||
import { miRoutes } from "./mi-routes";
|
import { miRoutes } from "./mi-routes";
|
||||||
|
import { bugReportRouter } from "./bug-report-routes";
|
||||||
|
|
||||||
// ── Process-level error safety net (prevent crash on unhandled socket errors) ──
|
// ── Process-level error safety net (prevent crash on unhandled socket errors) ──
|
||||||
process.on('uncaughtException', (err) => {
|
process.on('uncaughtException', (err) => {
|
||||||
|
|
@ -524,6 +525,9 @@ app.route("/api/mi", miRoutes);
|
||||||
app.route("/rtasks/check", checklistCheckRoutes);
|
app.route("/rtasks/check", checklistCheckRoutes);
|
||||||
app.route("/api/rtasks", checklistApiRoutes);
|
app.route("/api/rtasks", checklistApiRoutes);
|
||||||
|
|
||||||
|
// ── Bug Report API ──
|
||||||
|
app.route("/api/bug-report", bugReportRouter);
|
||||||
|
|
||||||
// ── Magic Link Responses (top-level, bypasses space auth) ──
|
// ── Magic Link Responses (top-level, bypasses space auth) ──
|
||||||
app.route("/respond", magicLinkRoutes);
|
app.route("/respond", magicLinkRoutes);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ const SMTP_PASS = process.env.SMTP_PASS || "";
|
||||||
|
|
||||||
let _smtpTransport: any = null;
|
let _smtpTransport: any = null;
|
||||||
|
|
||||||
async function getSmtpTransport() {
|
export async function getSmtpTransport() {
|
||||||
if (_smtpTransport) return _smtpTransport;
|
if (_smtpTransport) return _smtpTransport;
|
||||||
const isInternal = SMTP_HOST.includes('mailcow') || SMTP_HOST.includes('postfix');
|
const isInternal = SMTP_HOST.includes('mailcow') || SMTP_HOST.includes('postfix');
|
||||||
if (!SMTP_PASS && !isInternal) return null;
|
if (!SMTP_PASS && !isInternal) return null;
|
||||||
|
|
|
||||||
153
server/shell.ts
153
server/shell.ts
|
|
@ -289,6 +289,7 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
<style>${INFO_PANEL_CSS}</style>
|
<style>${INFO_PANEL_CSS}</style>
|
||||||
<style>${SUBNAV_CSS}</style>
|
<style>${SUBNAV_CSS}</style>
|
||||||
<style>${TABBAR_CSS}</style>
|
<style>${TABBAR_CSS}</style>
|
||||||
|
<style>${BUG_REPORT_CSS}</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-module-id="${escapeAttr(moduleId)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}">
|
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-module-id="${escapeAttr(moduleId)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}">
|
||||||
<div class="rspace-banner rspace-banner--install" id="pwa-install-banner" style="display:none">
|
<div class="rspace-banner rspace-banner--install" id="pwa-install-banner" style="display:none">
|
||||||
|
|
@ -1566,6 +1567,93 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
${renderBugReportWidget()}
|
||||||
|
<script>(function(){
|
||||||
|
// ── Bug Report: error ring buffer ──
|
||||||
|
var _bugErrors = [];
|
||||||
|
window.addEventListener('error', function(e) {
|
||||||
|
_bugErrors.push(e.message + (e.filename ? ' (' + e.filename + ':' + e.lineno + ')' : ''));
|
||||||
|
if (_bugErrors.length > 5) _bugErrors.shift();
|
||||||
|
});
|
||||||
|
window.addEventListener('unhandledrejection', function(e) {
|
||||||
|
_bugErrors.push('Promise: ' + (e.reason?.message || e.reason || 'unknown'));
|
||||||
|
if (_bugErrors.length > 5) _bugErrors.shift();
|
||||||
|
});
|
||||||
|
|
||||||
|
var btn = document.getElementById('rspace-bug-btn');
|
||||||
|
var overlay = document.getElementById('rspace-bug-overlay');
|
||||||
|
var modal = document.getElementById('rspace-bug-modal');
|
||||||
|
var errField = document.getElementById('bug-errors');
|
||||||
|
var deviceField = document.getElementById('bug-device');
|
||||||
|
var commentsField = document.getElementById('bug-comments');
|
||||||
|
var fileInput = document.getElementById('bug-screenshot');
|
||||||
|
var submitBtn = document.getElementById('rspace-bug-submit');
|
||||||
|
var statusEl = document.getElementById('rspace-bug-status');
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
errField.value = _bugErrors.length ? _bugErrors.join('\\n') : '';
|
||||||
|
deviceField.value = navigator.userAgent;
|
||||||
|
commentsField.value = '';
|
||||||
|
fileInput.value = '';
|
||||||
|
statusEl.textContent = '';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
overlay.classList.add('open');
|
||||||
|
}
|
||||||
|
function closeModal() { overlay.classList.remove('open'); }
|
||||||
|
|
||||||
|
btn.addEventListener('click', openModal);
|
||||||
|
overlay.addEventListener('click', function(e) { if (e.target === overlay) closeModal(); });
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape' && overlay.classList.contains('open')) closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
submitBtn.addEventListener('click', function() {
|
||||||
|
var file = fileInput.files && fileInput.files[0];
|
||||||
|
if (file && file.size > 3 * 1024 * 1024) {
|
||||||
|
statusEl.textContent = 'Screenshot exceeds 3 MB limit';
|
||||||
|
statusEl.style.color = 'var(--rs-error, #f87171)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
statusEl.textContent = 'Sending...';
|
||||||
|
statusEl.style.color = 'var(--rs-text-muted, #94a3b8)';
|
||||||
|
|
||||||
|
function send(screenshotData) {
|
||||||
|
fetch('/api/bug-report', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
url: location.href,
|
||||||
|
errors: _bugErrors.slice(),
|
||||||
|
comments: commentsField.value,
|
||||||
|
screenshot: screenshotData || undefined,
|
||||||
|
}),
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) {
|
||||||
|
statusEl.textContent = 'Report sent — thank you!';
|
||||||
|
statusEl.style.color = 'var(--rs-primary, #14b8a6)';
|
||||||
|
setTimeout(closeModal, 1500);
|
||||||
|
} else {
|
||||||
|
return r.json().then(function(d) { throw new Error(d.error || 'Failed'); });
|
||||||
|
}
|
||||||
|
}).catch(function(err) {
|
||||||
|
statusEl.textContent = err.message || 'Failed to send';
|
||||||
|
statusEl.style.color = 'var(--rs-error, #f87171)';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function() { send(reader.result); };
|
||||||
|
reader.onerror = function() { send(null); };
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
} else {
|
||||||
|
send(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})()</script>
|
||||||
${scripts}
|
${scripts}
|
||||||
</body>
|
</body>
|
||||||
</html>`);
|
</html>`);
|
||||||
|
|
@ -2196,6 +2284,71 @@ const TABBAR_CSS = `
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// ── Bug report floating button + modal ──
|
||||||
|
|
||||||
|
const BUG_REPORT_CSS = `
|
||||||
|
#rspace-bug-btn {
|
||||||
|
position: fixed; bottom: 20px; right: 20px; z-index: 9990;
|
||||||
|
width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, #334155);
|
||||||
|
color: var(--rs-text-muted, #94a3b8); cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
opacity: 0.45; transition: opacity 0.2s, transform 0.2s;
|
||||||
|
padding: 0; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
#rspace-bug-btn:hover { opacity: 1; transform: scale(1.1); }
|
||||||
|
#rspace-bug-btn svg { width: 18px; height: 18px; }
|
||||||
|
html.rspace-embedded #rspace-bug-btn { display: none !important; }
|
||||||
|
#rspace-bug-overlay {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 10002;
|
||||||
|
background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
#rspace-bug-overlay.open { display: flex; align-items: center; justify-content: center; }
|
||||||
|
#rspace-bug-modal {
|
||||||
|
z-index: 10003; width: 90vw; max-width: 480px; max-height: 85vh; overflow-y: auto;
|
||||||
|
background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, #334155);
|
||||||
|
border-radius: 12px; padding: 20px; color: var(--rs-text, #e2e8f0);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
#rspace-bug-modal h3 { margin: 0 0 16px; font-size: 16px; color: var(--rs-error, #f87171); }
|
||||||
|
#rspace-bug-modal label { display: block; font-size: 12px; font-weight: 600; color: var(--rs-text-muted, #94a3b8); margin: 10px 0 4px; }
|
||||||
|
#rspace-bug-modal textarea,
|
||||||
|
#rspace-bug-modal input[type="text"] {
|
||||||
|
width: 100%; box-sizing: border-box; padding: 8px; font-size: 13px;
|
||||||
|
background: var(--rs-bg, #0f172a); color: var(--rs-text, #e2e8f0);
|
||||||
|
border: 1px solid var(--rs-border, #334155); border-radius: 6px; resize: vertical;
|
||||||
|
}
|
||||||
|
#rspace-bug-modal input[type="file"] { font-size: 12px; color: var(--rs-text-muted, #94a3b8); margin-top: 2px; }
|
||||||
|
#rspace-bug-submit {
|
||||||
|
margin-top: 14px; width: 100%; padding: 10px; font-size: 14px; font-weight: 600;
|
||||||
|
background: var(--rs-primary, #14b8a6); color: #fff; border: none; border-radius: 6px; cursor: pointer;
|
||||||
|
}
|
||||||
|
#rspace-bug-submit:hover { filter: brightness(1.1); }
|
||||||
|
#rspace-bug-submit:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
#rspace-bug-status { margin-top: 8px; font-size: 12px; text-align: center; min-height: 1.2em; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
function renderBugReportWidget(): string {
|
||||||
|
return `<button id="rspace-bug-btn" title="Report a bug" aria-label="Report a bug">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2l1.88 1.88"/><path d="M14.12 3.88 16 2"/><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6"/><path d="M12 20v-9"/><path d="M6.53 9C4.6 8.8 3 7.1 3 5"/><path d="M6 13H2"/><path d="M3 21c0-2.1 1.7-3.9 3.8-4"/><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"/><path d="M22 13h-4"/><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4"/></svg>
|
||||||
|
</button>
|
||||||
|
<div id="rspace-bug-overlay">
|
||||||
|
<div id="rspace-bug-modal">
|
||||||
|
<h3>Report a Bug</h3>
|
||||||
|
<label for="bug-errors">Recent errors</label>
|
||||||
|
<textarea id="bug-errors" rows="3" readonly placeholder="No errors captured"></textarea>
|
||||||
|
<label for="bug-device">Device info</label>
|
||||||
|
<input type="text" id="bug-device" readonly>
|
||||||
|
<label for="bug-comments">What happened?</label>
|
||||||
|
<textarea id="bug-comments" rows="4" placeholder="Describe what you were doing when the issue occurred..."></textarea>
|
||||||
|
<label for="bug-screenshot">Screenshot (optional, max 3 MB)</label>
|
||||||
|
<input type="file" id="bug-screenshot" accept="image/*">
|
||||||
|
<button id="rspace-bug-submit">Send Report</button>
|
||||||
|
<div id="rspace-bug-status"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderTabBar(tabs: Array<{ id: string; label: string; icon?: string }>, activeTab: string | undefined, basePath: string): string {
|
function renderTabBar(tabs: Array<{ id: string; label: string; icon?: string }>, activeTab: string | undefined, basePath: string): string {
|
||||||
if (tabs.length === 0) return '';
|
if (tabs.length === 0) return '';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue