mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-07-31 13:55:34 +08:00
feat(bug-reports): trace reports by code and tell Live ATC from Classroom
Every report now gets a UUID that the notification mail quotes as "Nenne den Fehlercode <uuid> beim Commit", so a fix can be tied back to the report it closes. The mail also names the area it came from, and the comment field is relabelled "Fehlerbeschreibung/Featurewunsch" since it collects feature wishes just as often as bugs. Classroom loses its plain feedback link and gets the same reporter as Live ATC — screenshot, arrow annotation and all. `useBugReport` takes an options object so the communications-engine snapshot stays optional; the dialog moves out of `live-atc/cockpit` now that both pages use it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ const {
|
||||
bugReportLoading,
|
||||
bugReportError,
|
||||
bugReportSuccess,
|
||||
bugReportCode,
|
||||
bugReportCanvasRef,
|
||||
bugReportImgRef,
|
||||
setupAnnotationCanvas,
|
||||
@@ -43,6 +44,9 @@ const {
|
||||
<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>
|
||||
<p v-if="bugReportCode" class="mt-2 text-xs text-emerald-200/70">
|
||||
Fehlercode <span class="font-mono">{{ bugReportCode }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="bugReportScreenshot" class="space-y-2">
|
||||
@@ -103,7 +107,9 @@ const {
|
||||
|
||||
<v-textarea
|
||||
v-model="bugReportComment"
|
||||
label="Was ist kaputt? Was sollte stattdessen passieren?"
|
||||
label="Fehlerbeschreibung / Featurewunsch"
|
||||
placeholder="Was ist kaputt? Was sollte stattdessen passieren?"
|
||||
persistent-placeholder
|
||||
variant="outlined"
|
||||
color="red"
|
||||
rows="3"
|
||||
@@ -5,19 +5,24 @@ import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
|
||||
|
||||
export type BugReportArrow = { fx: number; fy: number; tx: number; ty: number }
|
||||
|
||||
export interface BugReportDeps {
|
||||
/** Which part of the app the report is filed from — reported in the mail. */
|
||||
export type BugReportSource = 'live-atc' | 'classroom'
|
||||
|
||||
export interface BugReportOptions {
|
||||
source: BugReportSource
|
||||
/**
|
||||
* Live ATC hands over its communications engine so the report carries the
|
||||
* flow state. Classroom has no such engine — the snapshot is simply omitted.
|
||||
*/
|
||||
engine?: ReturnType<typeof useCommunicationsEngine>
|
||||
/** Scenario the report is captured in — stored alongside the state snapshot. */
|
||||
activeScenario: Ref<{ id?: string; startFlow?: string } | null | undefined>
|
||||
activeScenario?: Ref<{ id?: string; startFlow?: string } | null | undefined>
|
||||
}
|
||||
|
||||
export function useBugReport(
|
||||
engine: ReturnType<typeof useCommunicationsEngine>,
|
||||
deps: BugReportDeps,
|
||||
) {
|
||||
export function useBugReport(options: BugReportOptions) {
|
||||
const api = useApi()
|
||||
const auth = useAuthStore()
|
||||
const { currentState, variables: vars, flags, flightContext, communicationLog: log } = engine
|
||||
const { activeScenario } = deps
|
||||
const { source, engine, activeScenario } = options
|
||||
|
||||
const showBugReportDialog = ref(false)
|
||||
const bugReportComment = ref('')
|
||||
@@ -28,6 +33,7 @@ export function useBugReport(
|
||||
const bugReportCapturing = ref(false)
|
||||
const bugReportError = ref('')
|
||||
const bugReportSuccess = ref(false)
|
||||
const bugReportCode = ref('')
|
||||
const bugReportCanvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const bugReportImgRef = ref<HTMLImageElement | null>(null)
|
||||
let _arrowDrawing = false
|
||||
@@ -115,6 +121,7 @@ export function useBugReport(
|
||||
async function openBugReport() {
|
||||
bugReportError.value = ''
|
||||
bugReportSuccess.value = false
|
||||
bugReportCode.value = ''
|
||||
bugReportComment.value = ''
|
||||
bugReportArrows.value = []
|
||||
bugReportScreenshot.value = null
|
||||
@@ -147,7 +154,7 @@ export function useBugReport(
|
||||
}
|
||||
|
||||
async function submitBugReport() {
|
||||
if (!bugReportComment.value.trim()) { bugReportError.value = 'Bitte einen Kommentar eingeben.'; return }
|
||||
if (!bugReportComment.value.trim()) { bugReportError.value = 'Bitte eine Fehlerbeschreibung eingeben.'; return }
|
||||
bugReportLoading.value = true
|
||||
bugReportError.value = ''
|
||||
|
||||
@@ -169,25 +176,29 @@ export function useBugReport(
|
||||
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),
|
||||
}
|
||||
const pmState = engine
|
||||
? {
|
||||
flowSlug: activeScenario?.value?.startFlow || '',
|
||||
scenarioId: activeScenario?.value?.id || '',
|
||||
currentStateId: (engine.currentState.value as any)?.id || '',
|
||||
variables: (engine.variables as any)?.value || {},
|
||||
flags: (engine.flags as any)?.value || {},
|
||||
flightContext: (engine.flightContext as any)?.value || {},
|
||||
communicationLog: ((engine.communicationLog as any)?.value || [] as any[]).slice(-20),
|
||||
}
|
||||
: undefined
|
||||
|
||||
await api.post('/api/bug-reports', {
|
||||
const res = await api.post<{ code?: string }>('/api/bug-reports', {
|
||||
source,
|
||||
comment: bugReportComment.value.trim(),
|
||||
contact: bugReportContact.value.trim(),
|
||||
screenshot: finalScreenshot,
|
||||
pmState,
|
||||
})
|
||||
|
||||
bugReportCode.value = res?.code || ''
|
||||
bugReportSuccess.value = true
|
||||
setTimeout(() => { showBugReportDialog.value = false; bugReportSuccess.value = false }, 2500)
|
||||
setTimeout(() => { showBugReportDialog.value = false; bugReportSuccess.value = false }, 5000)
|
||||
} catch (err: any) {
|
||||
bugReportError.value = err?.data?.statusMessage || err?.message || 'Fehler beim Senden.'
|
||||
} finally {
|
||||
@@ -205,6 +216,7 @@ export function useBugReport(
|
||||
bugReportCapturing,
|
||||
bugReportError,
|
||||
bugReportSuccess,
|
||||
bugReportCode,
|
||||
bugReportCanvasRef,
|
||||
bugReportImgRef,
|
||||
setupAnnotationCanvas,
|
||||
|
||||
@@ -129,10 +129,15 @@
|
||||
</div>
|
||||
|
||||
<div class="hud-right">
|
||||
<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>
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="btn ghost"
|
||||
title="Report an issue or request a feature"
|
||||
:disabled="bugReportCapturing"
|
||||
@click="openBugReport"
|
||||
>
|
||||
<v-icon size="18">{{ bugReportCapturing ? 'mdi-loading mdi-spin' : 'mdi-bug-outline' }}</v-icon>
|
||||
<span class="btn-label">{{ bugReportCapturing ? '…' : 'Report issue' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- ATC Einstellungen -->
|
||||
<button class="btn ghost" @click="showSettings=true" title="Settings">
|
||||
@@ -1251,7 +1256,9 @@
|
||||
‹ Visit home</a>
|
||||
</span>
|
||||
·
|
||||
<NuxtLink to="/feedback" target="_blank" class="link">Give feedback ›</NuxtLink>
|
||||
<button type="button" class="link link-button" :disabled="bugReportCapturing" @click="openBugReport">
|
||||
Report an issue ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -1420,6 +1427,9 @@
|
||||
</div>
|
||||
</v-dialog>
|
||||
|
||||
<!-- BUG REPORT -->
|
||||
<BugReportDialog :bug="bugReport" theme="dark" />
|
||||
|
||||
<!-- TOAST -->
|
||||
<v-snackbar v-model="toast.show" timeout="2200" location="top" color="#22d3ee">
|
||||
<v-icon class="mr-2">mdi-trophy</v-icon>
|
||||
@@ -1457,6 +1467,8 @@ import {
|
||||
classroomVoiceFor,
|
||||
} from '~~/shared/utils/voicePool'
|
||||
import {DEFAULT_AIRLINE_TELEPHONY, normalizeRadioPhrase, normalizeMetarPhrase} from '~~/shared/utils/radioSpeech'
|
||||
import {useBugReport} from '~/composables/useBugReport'
|
||||
import BugReportDialog from '~/components/BugReportDialog.vue'
|
||||
|
||||
definePageMeta({middleware: ['require-auth', 'require-classroom-intro']})
|
||||
|
||||
@@ -2798,6 +2810,11 @@ const showOnlineTtsSuggestion = ref(false)
|
||||
const api = useApi()
|
||||
const isClient = typeof window !== 'undefined'
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Bug reports work exactly as in Live ATC (screenshot + arrow annotation), just
|
||||
// without a communications engine to snapshot — the source tells the mail apart.
|
||||
const bugReport = useBugReport({source: 'classroom'})
|
||||
const {bugReportCapturing, openBugReport} = bugReport
|
||||
const browserTtsAvailable = computed(() => isClient && 'speechSynthesis' in window)
|
||||
|
||||
// STT (Speech-to-Text) for the readback — pilot speaks the readback into a mic,
|
||||
@@ -6192,6 +6209,17 @@ onBeforeUnmount(() => {
|
||||
gap: 6px
|
||||
}
|
||||
|
||||
.link-button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0
|
||||
}
|
||||
|
||||
.link-button:disabled {
|
||||
opacity: .6;
|
||||
cursor: default
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 8px
|
||||
|
||||
@@ -185,7 +185,7 @@ import { useBugReport } from '~/composables/useBugReport'
|
||||
import { useSimBridgeSync } from '~/composables/useSimBridgeSync'
|
||||
import FlightInfoSheet from '~/components/live-atc/cockpit/FlightInfoSheet.vue'
|
||||
import SettingsSheet from '~/components/live-atc/cockpit/SettingsSheet.vue'
|
||||
import BugReportDialog from '~/components/live-atc/cockpit/BugReportDialog.vue'
|
||||
import BugReportDialog from '~/components/BugReportDialog.vue'
|
||||
import TransmissionIssueDialog from '~/components/live-atc/cockpit/TransmissionIssueDialog.vue'
|
||||
import HelpDialog from '~/components/live-atc/cockpit/HelpDialog.vue'
|
||||
import DebugPanel from '~/components/live-atc/cockpit/DebugPanel.vue'
|
||||
@@ -320,7 +320,7 @@ const aiTrafficEnabled = ref(false)
|
||||
// ── Bug Report ───────────────────────────────────────────────────────────────
|
||||
// Owned here rather than by the dialog: the HUD button starts the screenshot
|
||||
// capture before the dialog ever renders. The dialog gets the whole handle.
|
||||
const bugReport = useBugReport(engine, { activeScenario })
|
||||
const bugReport = useBugReport({ source: 'live-atc', engine, activeScenario })
|
||||
const { bugReportCapturing, openBugReport } = bugReport
|
||||
|
||||
// Layout / view state. The tab, HUD menu and mode-switch state live inside
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
import { defineEventHandler, readBody, createError } from 'h3'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { requireUserSession } from '../../utils/auth'
|
||||
import { BugReport } from '../../models/BugReport'
|
||||
import { BugReport, type BugReportSource } from '../../models/BugReport'
|
||||
import { sendMail } from '../../utils/notifications'
|
||||
|
||||
const SOURCE_LABELS: Record<BugReportSource, string> = {
|
||||
'live-atc': 'Live ATC',
|
||||
classroom: 'Classroom',
|
||||
}
|
||||
|
||||
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' })
|
||||
throw createError({ statusCode: 400, statusMessage: 'Fehlerbeschreibung ist erforderlich' })
|
||||
}
|
||||
|
||||
const contact = String(
|
||||
body?.contact || [user.name, user.email].filter(Boolean).join(' — ')
|
||||
).slice(0, 200)
|
||||
|
||||
const source: BugReportSource = body?.source === 'classroom' ? 'classroom' : 'live-atc'
|
||||
// The code is what ties a report to the commit that fixes it — it goes into
|
||||
// the mail so it can be pasted straight into the commit message.
|
||||
const code = randomUUID()
|
||||
|
||||
const report = await BugReport.create({
|
||||
code,
|
||||
source,
|
||||
comment: comment.slice(0, 4000),
|
||||
contact,
|
||||
userId: user._id,
|
||||
@@ -25,20 +38,32 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
|
||||
const adminUrl = `${process.env.APP_URL || 'https://app.opensquawk.de'}/admin`
|
||||
const sourceLabel = SOURCE_LABELS[source]
|
||||
const stateInfo = body?.pmState?.currentStateId
|
||||
? `State: ${body.pmState.currentStateId} (Flow: ${body.pmState.flowSlug || '—'})`
|
||||
: ''
|
||||
|
||||
await sendMail({
|
||||
to: 'emanuel@faktorxmensch.com',
|
||||
subject: `[OpenSquawk Bug] ${contact}`,
|
||||
subject: `[OpenSquawk Bug · ${sourceLabel}] ${contact}`,
|
||||
html: `<h2>Neuer Bug Report</h2>
|
||||
<p><strong>Bereich:</strong> ${sourceLabel}</p>
|
||||
<p><strong>Von:</strong> ${contact}</p>
|
||||
<p><strong>Kommentar:</strong><br>${comment.replace(/\n/g, '<br>')}</p>
|
||||
<p><strong>Fehlerbeschreibung/Featurewunsch:</strong><br>${comment.replace(/\n/g, '<br>')}</p>
|
||||
<p><strong>Nenne den Fehlercode ${code} beim Commit.</strong></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}`,
|
||||
text: `Bug Report von ${contact}
|
||||
Bereich: ${sourceLabel}
|
||||
|
||||
Fehlerbeschreibung/Featurewunsch:
|
||||
${comment}
|
||||
|
||||
Nenne den Fehlercode ${code} beim Commit.
|
||||
${stateInfo}
|
||||
|
||||
Admin: ${adminUrl}`,
|
||||
}).catch(() => {})
|
||||
|
||||
return { success: true, id: String(report._id) }
|
||||
return { success: true, id: String(report._id), code }
|
||||
})
|
||||
|
||||
@@ -2,6 +2,10 @@ import mongoose from 'mongoose'
|
||||
|
||||
export type BugReportStatus = 'open' | 'resolved'
|
||||
|
||||
/** Where the report was filed. Shown in the notification mail so we know which
|
||||
* part of the app to reproduce it in. */
|
||||
export type BugReportSource = 'live-atc' | 'classroom'
|
||||
|
||||
export interface PmStateSnapshot {
|
||||
flowSlug?: string
|
||||
scenarioId?: string
|
||||
@@ -13,6 +17,9 @@ export interface PmStateSnapshot {
|
||||
}
|
||||
|
||||
export interface BugReportDocument extends mongoose.Document {
|
||||
/** Stable UUID quoted in commit messages so a fix can be traced back here. */
|
||||
code: string
|
||||
source: BugReportSource
|
||||
comment: string
|
||||
contact: string
|
||||
userId?: mongoose.Types.ObjectId
|
||||
@@ -23,6 +30,10 @@ export interface BugReportDocument extends mongoose.Document {
|
||||
}
|
||||
|
||||
const bugReportSchema = new mongoose.Schema<BugReportDocument>({
|
||||
// Sparse: reports created before the code existed simply have none, and a
|
||||
// plain unique index would reject more than one of them.
|
||||
code: { type: String, required: true, unique: true, sparse: true },
|
||||
source: { type: String, enum: ['live-atc', 'classroom'], default: 'live-atc', index: true },
|
||||
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 },
|
||||
|
||||
Reference in New Issue
Block a user