feat(pm): add bug-report button with screenshot annotation and admin panel

- New BugReport MongoDB model (comment, contact, userId, screenshot, pmState, status)
- POST /api/bug-reports — authenticated submit; emails emanuel@faktorxmensch.com on receipt
- GET/PATCH /api/admin/bug-reports + /[id] — admin list, detail with screenshot, status toggle
- /pm: "Bug" button in HUD captures viewport screenshot (html2canvas), shows annotation
  canvas where testers can draw arrows; submits comment + contact + state snapshot
- /admin: new "Bug Reports" tab with open-count badge, screenshot expand, "Erledigt" toggle,
  and "In /pm öffnen" link that restores captured engine state via ?restoreBugReport=<id>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-06-18 10:05:25 +02:00
parent 1c3c9ffaa6
commit 8a1d9e1ab6
6 changed files with 430 additions and 1 deletions

Binary file not shown.

View File

@@ -467,6 +467,15 @@
</div>
<div class="hud-right">
<button
type="button"
class="btn ghost"
title="Fehler melden"
@click="openBugReport"
>
<v-icon size="18">mdi-bug-outline</v-icon>
<span class="btn-label">Bug</span>
</button>
<NuxtLink class="btn ghost" to="/feedback" title="Share feedback or ideas">
<v-icon size="18">mdi-message-draw</v-icon>
<span class="btn-label">Feedback</span>
@@ -1198,6 +1207,107 @@
</v-card>
</v-dialog>
<!-- Bug Report Dialog -->
<v-dialog v-model="showBugReportDialog" max-width="680" scrollable>
<v-card class="bg-[#0b101d] border border-white/10 text-white">
<v-card-title class="flex items-center gap-2 text-base font-semibold pt-4 px-5">
<v-icon icon="mdi-bug-outline" color="#f87171" size="20" />
Fehler melden
</v-card-title>
<v-card-text class="space-y-4 px-5 pb-2">
<div v-if="bugReportSuccess" class="rounded-xl bg-emerald-500/10 border border-emerald-400/30 p-4 text-center">
<v-icon icon="mdi-check-circle-outline" color="emerald" size="32" class="mb-2" />
<p class="text-emerald-300 font-semibold">Danke! Bug Report wurde gesendet.</p>
</div>
<template v-else>
<div v-if="bugReportScreenshot" class="space-y-2">
<div class="flex items-center justify-between">
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Screenshot Pfeile einzeichnen</p>
<v-btn
v-if="bugReportArrows.length > 0"
size="x-small"
variant="text"
color="red"
prepend-icon="mdi-undo"
@click="undoLastArrow"
>
Rückgängig
</v-btn>
</div>
<div class="relative rounded-xl overflow-hidden border border-white/10" style="line-height:0">
<img
ref="bugReportImgRef"
:src="bugReportScreenshot"
class="w-full block"
alt="Screenshot"
@load="setupAnnotationCanvas"
/>
<canvas
ref="bugReportCanvasRef"
class="absolute inset-0 w-full h-full cursor-crosshair select-none"
style="touch-action:none"
@mousedown="onCanvasMouseDown"
@mousemove="onCanvasMouseMove"
@mouseup="onCanvasMouseUp"
@mouseleave="onCanvasMouseLeave"
/>
</div>
</div>
<div v-else class="rounded-xl border border-white/10 bg-white/5 p-4 text-center text-sm text-white/50">
Kein Screenshot verfügbar
</div>
<v-textarea
v-model="bugReportComment"
label="Was ist kaputt? Was sollte stattdessen passieren?"
variant="outlined"
color="red"
rows="3"
auto-grow
maxlength="2000"
counter="2000"
hide-details="auto"
:disabled="bugReportLoading"
/>
<v-text-field
v-model="bugReportContact"
label="Dein Name / Kontakt"
variant="outlined"
color="red"
density="comfortable"
hide-details
:disabled="bugReportLoading"
/>
<v-alert
v-if="bugReportError"
type="error"
density="compact"
variant="tonal"
class="bg-red-500/10 text-red-200"
>
{{ bugReportError }}
</v-alert>
</template>
</v-card-text>
<v-card-actions v-if="!bugReportSuccess" class="justify-end gap-2 px-5 pb-4">
<v-btn variant="text" color="grey" :disabled="bugReportLoading" @click="showBugReportDialog = false">
Abbrechen
</v-btn>
<v-btn
color="red"
variant="flat"
:loading="bugReportLoading"
:disabled="!bugReportComment.trim()"
@click="submitBugReport"
>
Bug melden
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Transmission issue dialog -->
<v-dialog v-model="showTransmissionIssueDialog" max-width="420">
<v-card class="bg-[#0b101d] border border-white/10 text-white">
@@ -1242,7 +1352,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import useCommunicationsEngine from "../../shared/utils/communicationsEngine";
import { normalizeRadioPhrase, normalizeAtisForSpeech, DEFAULT_AIRLINE_TELEPHONY } from '../../shared/utils/radioSpeech';
import { useAuthStore } from '~/stores/auth'
@@ -1292,6 +1402,7 @@ const engine = useCommunicationsEngine()
const auth = useAuthStore()
const api = useApi()
const router = useRouter()
const route = useRoute()
const radioBackend = useRadioBackend()
const config = useRuntimeConfig()
@@ -1992,6 +2103,168 @@ const radioEffectsEnabled = ref(true)
const readbackEnabled = ref(false)
const debugMode = ref(true)
// ── Bug Report ───────────────────────────────────────────────────────────────
const showBugReportDialog = ref(false)
const bugReportComment = ref('')
const bugReportContact = ref('')
const bugReportScreenshot = ref<string | null>(null)
const bugReportArrows = ref<Array<{ fx: number; fy: number; tx: number; ty: number }>>([])
const bugReportLoading = ref(false)
const bugReportError = ref('')
const bugReportSuccess = ref(false)
const bugReportCanvasRef = ref<HTMLCanvasElement | null>(null)
const bugReportImgRef = ref<HTMLImageElement | null>(null)
let _arrowDrawing = false
let _arrowStart = { x: 0, y: 0 }
function setupAnnotationCanvas() {
const canvas = bugReportCanvasRef.value
const img = bugReportImgRef.value
if (!canvas || !img) return
canvas.width = img.clientWidth
canvas.height = img.clientHeight
}
function _drawArrow(ctx: CanvasRenderingContext2D, fx: number, fy: number, tx: number, ty: number) {
const headLen = 14
const angle = Math.atan2(ty - fy, tx - fx)
ctx.strokeStyle = '#ef4444'
ctx.fillStyle = '#ef4444'
ctx.lineWidth = 3
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.beginPath()
ctx.moveTo(fx, fy)
ctx.lineTo(tx, ty)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(tx, ty)
ctx.lineTo(tx - headLen * Math.cos(angle - Math.PI / 6), ty - headLen * Math.sin(angle - Math.PI / 6))
ctx.lineTo(tx - headLen * Math.cos(angle + Math.PI / 6), ty - headLen * Math.sin(angle + Math.PI / 6))
ctx.closePath()
ctx.fill()
}
function _redrawAnnotations(preview?: { fx: number; fy: number; tx: number; ty: number }) {
const canvas = bugReportCanvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
for (const a of bugReportArrows.value) _drawArrow(ctx, a.fx, a.fy, a.tx, a.ty)
if (preview) {
ctx.globalAlpha = 0.55
_drawArrow(ctx, preview.fx, preview.fy, preview.tx, preview.ty)
ctx.globalAlpha = 1
}
}
function _canvasCoords(e: MouseEvent) {
const canvas = bugReportCanvasRef.value!
const rect = canvas.getBoundingClientRect()
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
function onCanvasMouseDown(e: MouseEvent) {
_arrowStart = _canvasCoords(e)
_arrowDrawing = true
}
function onCanvasMouseMove(e: MouseEvent) {
if (!_arrowDrawing) return
const { x, y } = _canvasCoords(e)
_redrawAnnotations({ fx: _arrowStart.x, fy: _arrowStart.y, tx: x, ty: y })
}
function onCanvasMouseUp(e: MouseEvent) {
if (!_arrowDrawing) return
_arrowDrawing = false
const { x, y } = _canvasCoords(e)
const dx = x - _arrowStart.x
const dy = y - _arrowStart.y
if (Math.sqrt(dx * dx + dy * dy) < 8) { _redrawAnnotations(); return }
bugReportArrows.value = [...bugReportArrows.value, { fx: _arrowStart.x, fy: _arrowStart.y, tx: x, ty: y }]
_redrawAnnotations()
}
function onCanvasMouseLeave() {
if (!_arrowDrawing) return
_arrowDrawing = false
_redrawAnnotations()
}
function undoLastArrow() {
bugReportArrows.value = bugReportArrows.value.slice(0, -1)
_redrawAnnotations()
}
async function openBugReport() {
bugReportError.value = ''
bugReportSuccess.value = false
bugReportComment.value = ''
bugReportArrows.value = []
bugReportScreenshot.value = null
bugReportContact.value = [auth.user?.name, auth.user?.email].filter(Boolean).join(' — ')
try {
const { default: html2canvas } = await import('html2canvas')
const c = await html2canvas(document.body, { scale: 0.55, useCORS: true, allowTaint: true, logging: false })
bugReportScreenshot.value = c.toDataURL('image/jpeg', 0.75)
} catch { /* Screenshot optional */ }
showBugReportDialog.value = true
}
async function submitBugReport() {
if (!bugReportComment.value.trim()) { bugReportError.value = 'Bitte einen Kommentar eingeben.'; return }
bugReportLoading.value = true
bugReportError.value = ''
try {
let finalScreenshot: string | undefined
if (bugReportScreenshot.value) {
const img = bugReportImgRef.value
const src = new Image()
await new Promise<void>((res) => { src.onload = () => res(); src.src = bugReportScreenshot.value! })
const out = document.createElement('canvas')
out.width = src.naturalWidth; out.height = src.naturalHeight
const ctx = out.getContext('2d')!
ctx.drawImage(src, 0, 0)
if (bugReportArrows.value.length > 0 && img) {
const sx = src.naturalWidth / img.clientWidth
const sy = src.naturalHeight / img.clientHeight
for (const a of bugReportArrows.value) _drawArrow(ctx, a.fx * sx, a.fy * sy, a.tx * sx, a.ty * sy)
}
finalScreenshot = out.toDataURL('image/jpeg', 0.8)
}
const pmState = {
flowSlug: activeScenario.value?.startFlow || '',
scenarioId: activeScenario.value?.id || '',
currentStateId: (currentState.value as any)?.id || '',
variables: (vars as any)?.value || {},
flags: (flags as any)?.value || {},
flightContext: (flightContext as any)?.value || {},
communicationLog: ((log as any)?.value || [] as any[]).slice(-20),
}
await api.post('/api/bug-reports', {
comment: bugReportComment.value.trim(),
contact: bugReportContact.value.trim(),
screenshot: finalScreenshot,
pmState,
})
bugReportSuccess.value = true
setTimeout(() => { showBugReportDialog.value = false; bugReportSuccess.value = false }, 2500)
} catch (err: any) {
bugReportError.value = err?.data?.statusMessage || err?.message || 'Fehler beim Senden.'
} finally {
bugReportLoading.value = false
}
}
// ────────────────────────────────────────────────────────────────────────────
// Pre-recording (rolling mic buffer) so the first ~1s of PTT speech isn't clipped
const prerecEnabled = ref(true)
const prerecSeconds = ref(1.0)
@@ -2151,6 +2424,27 @@ onMounted(async () => {
}
}
}
// Restore state from bug report (admin link: /pm?restoreBugReport=<id>)
const restoreId = route.query.restoreBugReport as string | undefined
if (restoreId) {
try {
const report = await api.get<any>(`/api/admin/bug-reports/${restoreId}`)
const state = report?.pmState
if (state) {
if (state.variables && Object.keys(state.variables).length) patchVariables(state.variables)
if (state.flags && Object.keys(state.flags).length) patchFlags(state.flags)
if (state.flowSlug) {
try {
await fetchRuntimeTree(state.flowSlug, config.public.radioBackendUrl as string)
} catch {}
}
error.value = `[Bug-Report-Restore] State: ${state.currentStateId || '?'} · Flow: ${state.flowSlug || '?'}`
}
} catch (err) {
console.warn('[PM] Bug report restore failed', err)
}
}
} finally {
restoringFromStorage = false
}

View File

@@ -22,6 +22,7 @@
"@pinia/nuxt": "^0.11.3",
"dotenv": "^17.3.1",
"fluent-ffmpeg": "^2.1.3",
"html2canvas": "^1.4.1",
"nodemailer": "^8.0.1",
"nuxt": "^4.3.1",
"nuxt-aos": "^1.2.6",

View File

@@ -0,0 +1,44 @@
import { defineEventHandler, readBody, createError } from 'h3'
import { requireUserSession } from '../../utils/auth'
import { BugReport } from '../../models/BugReport'
import { sendMail } from '../../utils/notifications'
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event)
const body = await readBody(event)
const comment = String(body?.comment ?? '').trim()
if (!comment) {
throw createError({ statusCode: 400, statusMessage: 'Kommentar ist erforderlich' })
}
const contact = String(
body?.contact || [user.name, user.email].filter(Boolean).join(' — ')
).slice(0, 200)
const report = await BugReport.create({
comment: comment.slice(0, 4000),
contact,
userId: user._id,
screenshot: body?.screenshot || undefined,
pmState: body?.pmState || undefined,
})
const adminUrl = `${process.env.APP_URL || 'https://app.opensquawk.de'}/admin`
const stateInfo = body?.pmState?.currentStateId
? `State: ${body.pmState.currentStateId} (Flow: ${body.pmState.flowSlug || '—'})`
: ''
await sendMail({
to: 'emanuel@faktorxmensch.com',
subject: `[OpenSquawk Bug] ${contact}`,
html: `<h2>Neuer Bug Report</h2>
<p><strong>Von:</strong> ${contact}</p>
<p><strong>Kommentar:</strong><br>${comment.replace(/\n/g, '<br>')}</p>
${stateInfo ? `<p><strong>${stateInfo}</strong></p>` : ''}
<p><a href="${adminUrl}">Im Admin-Panel ansehen →</a></p>`,
text: `Bug Report von ${contact}\n\n${comment}\n${stateInfo}\n\nAdmin: ${adminUrl}`,
}).catch(() => {})
return { success: true, id: String(report._id) }
})

View File

@@ -0,0 +1,45 @@
import mongoose from 'mongoose'
export type BugReportStatus = 'open' | 'resolved'
export interface PmStateSnapshot {
flowSlug?: string
scenarioId?: string
currentStateId?: string
variables?: Record<string, any>
flags?: Record<string, boolean>
flightContext?: Record<string, any>
communicationLog?: any[]
}
export interface BugReportDocument extends mongoose.Document {
comment: string
contact: string
userId?: mongoose.Types.ObjectId
screenshot?: string
pmState?: PmStateSnapshot
status: BugReportStatus
createdAt: Date
}
const bugReportSchema = new mongoose.Schema<BugReportDocument>({
comment: { type: String, required: true, trim: true, maxlength: 4000 },
contact: { type: String, required: true, trim: true, maxlength: 200 },
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true },
screenshot: { type: String },
pmState: {
flowSlug: { type: String },
scenarioId: { type: String },
currentStateId: { type: String },
variables: { type: mongoose.Schema.Types.Mixed, default: {} },
flags: { type: mongoose.Schema.Types.Mixed, default: {} },
flightContext: { type: mongoose.Schema.Types.Mixed, default: {} },
communicationLog: { type: [mongoose.Schema.Types.Mixed], default: [] },
},
status: { type: String, enum: ['open', 'resolved'], default: 'open', index: true },
createdAt: { type: Date, default: () => new Date(), index: true },
})
export const BugReport =
(mongoose.models.BugReport as mongoose.Model<BugReportDocument> | undefined) ||
mongoose.model<BugReportDocument>('BugReport', bugReportSchema)

View File

@@ -4211,6 +4211,13 @@ __metadata:
languageName: node
linkType: hard
"base64-arraybuffer@npm:^1.0.2":
version: 1.0.2
resolution: "base64-arraybuffer@npm:1.0.2"
checksum: 10c0/3acac95c70f9406e87a41073558ba85b6be9dbffb013a3d2a710e3f2d534d506c911847d5d9be4de458af6362c676de0a5c4c2d7bdf4def502d00b313368e72f
languageName: node
linkType: hard
"base64-js@npm:^1.3.1":
version: 1.5.1
resolution: "base64-js@npm:1.5.1"
@@ -4827,6 +4834,15 @@ __metadata:
languageName: node
linkType: hard
"css-line-break@npm:^2.1.0":
version: 2.1.0
resolution: "css-line-break@npm:2.1.0"
dependencies:
utrie: "npm:^1.0.2"
checksum: 10c0/b2222d99d5daf7861ecddc050244fdce296fad74b000dcff6bdfb1eb16dc2ef0b9ffe2c1c965e3239bd05ebe9eadb6d5438a91592fa8648d27a338e827cf9048
languageName: node
linkType: hard
"css-select@npm:^5.1.0":
version: 5.2.2
resolution: "css-select@npm:5.2.2"
@@ -6142,6 +6158,16 @@ __metadata:
languageName: node
linkType: hard
"html2canvas@npm:^1.4.1":
version: 1.4.1
resolution: "html2canvas@npm:1.4.1"
dependencies:
css-line-break: "npm:^2.1.0"
text-segmentation: "npm:^1.0.3"
checksum: 10c0/6de86f75762b00948edf2ea559f16da0a1ec3facc4a8a7d3f35fcec59bb0c5970463478988ae3d9082152e0173690d46ebf4082e7ac803dd4817bae1d355c0db
languageName: node
linkType: hard
"http-assert@npm:^1.3.0":
version: 1.5.0
resolution: "http-assert@npm:1.5.0"
@@ -7976,6 +8002,7 @@ __metadata:
"@types/three": "npm:^0.183.0"
dotenv: "npm:^17.3.1"
fluent-ffmpeg: "npm:^2.1.3"
html2canvas: "npm:^1.4.1"
nodemailer: "npm:^8.0.1"
nuxt: "npm:^4.3.1"
nuxt-aos: "npm:^1.2.6"
@@ -10007,6 +10034,15 @@ __metadata:
languageName: node
linkType: hard
"text-segmentation@npm:^1.0.3":
version: 1.0.3
resolution: "text-segmentation@npm:1.0.3"
dependencies:
utrie: "npm:^1.0.2"
checksum: 10c0/8b9ae8524e3a332371060d0ca62f10ad49a13e954719ea689a6c3a8b8c15c8a56365ede2bb91c322fb0d44b6533785f0da603e066b7554d052999967fb72d600
languageName: node
linkType: hard
"thenify-all@npm:^1.0.0":
version: 1.6.0
resolution: "thenify-all@npm:1.6.0"
@@ -10598,6 +10634,15 @@ __metadata:
languageName: node
linkType: hard
"utrie@npm:^1.0.2":
version: 1.0.2
resolution: "utrie@npm:1.0.2"
dependencies:
base64-arraybuffer: "npm:^1.0.2"
checksum: 10c0/eaffe645bd81a39e4bc3abb23df5895e9961dbdd49748ef3b173529e8b06ce9dd1163e9705d5309a1c61ee41ffcb825e2043bc0fd1659845ffbdf4b1515dfdb4
languageName: node
linkType: hard
"v-lazy-show@npm:^0.2.4":
version: 0.2.4
resolution: "v-lazy-show@npm:0.2.4"