fix(pm): make bug-report screenshots and state restore actually work

Screenshots: html2canvas 1.4.1 only parses rgb/hsl and throws on color-mix(),
which the app uses app-wide; the throw was swallowed so no screenshot was ever
captured. Swap to modern-screenshot (native SVG foreignObject — supports
color-mix/oklch), capture before opening the dialog, surface failures instead of
hiding them, and show a capture spinner on the Bug button.

State restore: the old handler only patched local vars/flags and loaded the tree
— it never entered the monitor screen or created a backend session, so nothing
happened. New restoreBugReportState() reuses startMonitoring() to spin up a real
session for the same flight/scenario from the snapshot, overlays saved
variables/flags, and replays the captured comm log. The Python backend has no
mid-session resume, so the session restarts at the flow start and the captured
state id is surfaced in a banner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-06-18 10:43:55 +02:00
parent ccab0e7116
commit cc24e80511
4 changed files with 112 additions and 68 deletions

Binary file not shown.

View File

@@ -471,10 +471,11 @@
type="button"
class="btn ghost"
title="Fehler melden"
:disabled="bugReportCapturing"
@click="openBugReport"
>
<v-icon size="18">mdi-bug-outline</v-icon>
<span class="btn-label">Bug</span>
<v-icon size="18">{{ bugReportCapturing ? 'mdi-loading mdi-spin' : 'mdi-bug-outline' }}</v-icon>
<span class="btn-label">{{ bugReportCapturing ? '…' : 'Bug' }}</span>
</button>
<NuxtLink class="btn ghost" to="/feedback" title="Share feedback or ideas">
<v-icon size="18">mdi-message-draw</v-icon>
@@ -2110,6 +2111,7 @@ 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 bugReportCapturing = ref(false)
const bugReportError = ref('')
const bugReportSuccess = ref(false)
const bugReportCanvasRef = ref<HTMLCanvasElement | null>(null)
@@ -2206,11 +2208,28 @@ async function openBugReport() {
bugReportScreenshot.value = null
bugReportContact.value = [auth.user?.name, auth.user?.email].filter(Boolean).join(' — ')
// Capture the screenshot BEFORE opening the dialog, otherwise the open
// dialog overlay would appear in the shot instead of the actual bug state.
bugReportCapturing.value = true
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 */ }
// modern-screenshot renders via a native SVG <foreignObject>, so modern CSS
// such as color-mix()/oklch() (used throughout the app) is supported.
// html2canvas could not parse those and silently produced no screenshot.
const { domToJpeg } = await import('modern-screenshot')
bugReportScreenshot.value = await domToJpeg(document.body, {
quality: 0.75,
scale: 0.55,
// Skip assets we cannot read (cross-origin tiles/avatars) instead of failing.
filter: (node) => !(node instanceof Element && node.getAttribute?.('data-no-screenshot') === 'true'),
})
} catch (err) {
// Screenshot is optional — keep the report flow usable, but surface why.
console.warn('[PM] Bug report screenshot capture failed', err)
bugReportScreenshot.value = null
bugReportError.value = 'Screenshot konnte nicht erstellt werden Bug-Report ohne Bild möglich.'
} finally {
bugReportCapturing.value = false
}
showBugReportDialog.value = true
}
@@ -2263,6 +2282,83 @@ async function submitBugReport() {
bugReportLoading.value = false
}
}
/**
* Restore a /pm session from a saved bug-report snapshot (admin link
* `/pm?restoreBugReport=<id>`). The Python backend has no "resume mid-session"
* endpoint, so we recreate a real, working session for the SAME flight and
* scenario via startMonitoring(), then overlay the saved variables/flags and
* the captured conversation so the admin can reproduce and try out the bug.
*/
async function restoreBugReportState(restoreId: string) {
try {
const report = await api.get<any>(`/api/admin/bug-reports/${restoreId}`)
const state = report?.pmState
if (!state) {
error.value = 'Bug-Report enthält keinen gespeicherten State.'
return
}
// Locate the scenario the report was captured in.
const scenario =
SCENARIOS.find(s => s.id === state.scenarioId) ||
SCENARIOS.find(s => s.startFlow === state.flowSlug)
if (!scenario) {
error.value = `Bug-Report-Restore: Szenario "${state.scenarioId || state.flowSlug || '?'}" nicht gefunden.`
return
}
// Reconstruct a flight plan from the snapshot so startMonitoring resolves the
// correct airport/frequencies and creates a backend session for the same flight.
const v = state.variables || {}
const fc = state.flightContext || {}
const dep = v.dep || fc.dep
const dest = v.dest || fc.dest
const flightPlan: Record<string, any> = {
callsign: v.callsign || fc.callsign || 'UNKNOWN',
aircraft: v.acf_type || fc.acf_type || 'A320',
dep,
departure: dep,
arr: dest,
arrival: dest,
route: fc.route || v.route || '',
assignedsquawk: v.squawk,
}
// Spin up a real session (loads tree, fetches frequencies, creates backend session).
await startMonitoring(flightPlan, scenario)
// startMonitoring bails out on error without entering the monitor screen.
if (currentScreen.value !== 'monitor') return
// Overlay the exact saved values over the freshly generated ones (stand, SID, …).
if (state.variables && Object.keys(state.variables).length) patchVariables(state.variables)
if (state.flags && Object.keys(state.flags).length) patchFlags(state.flags)
// Restore the captured conversation for context.
clearCommunicationLog?.()
if (Array.isArray(state.communicationLog)) {
for (const e of state.communicationLog) {
if (!e?.message) continue
appendLogEntry(e.speaker || 'system', e.message, e.state || '', {
frequency: e.frequency,
flow: e.flow,
radioCheck: e.radioCheck,
offSchema: e.offSchema,
})
}
}
// A fresh backend session always starts at the flow's start state, so we
// can't fake the local cursor onto the captured mid-flow state without
// desyncing transmits. Tell the admin where the bug was captured instead.
error.value =
`Bug-Report wiederhergestellt: ${scenario.name} · ${flightPlan.callsign} (${dep || '?'}${dest || '?'}). ` +
`Erfasster State: ${state.currentStateId || '?'} (Flow ${state.flowSlug || '?'}).`
} catch (err) {
console.warn('[PM] Bug report restore failed', err)
error.value = 'Bug-Report konnte nicht wiederhergestellt werden.'
}
}
// ────────────────────────────────────────────────────────────────────────────
// Pre-recording (rolling mic buffer) so the first ~1s of PTT speech isn't clipped
@@ -2428,22 +2524,7 @@ 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)
}
await restoreBugReportState(restoreId)
}
} finally {
restoringFromStorage = false

View File

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

View File

@@ -4211,13 +4211,6 @@ __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"
@@ -4834,15 +4827,6 @@ __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"
@@ -6158,16 +6142,6 @@ __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"
@@ -7339,6 +7313,13 @@ __metadata:
languageName: node
linkType: hard
"modern-screenshot@npm:^4.7.0":
version: 4.7.0
resolution: "modern-screenshot@npm:4.7.0"
checksum: 10c0/9c5fbe4f4c73d3dcf0ba7fb76c1671ad148fdedffc84700e0b8ca1c1640a6ab1930718ae1424b0a6517b98d8f18c898ea0e62ee0ca57a25bfce7b914df721a63
languageName: node
linkType: hard
"mongodb-connection-string-url@npm:^3.0.2":
version: 3.0.2
resolution: "mongodb-connection-string-url@npm:3.0.2"
@@ -8002,7 +7983,7 @@ __metadata:
"@types/three": "npm:^0.183.0"
dotenv: "npm:^17.3.1"
fluent-ffmpeg: "npm:^2.1.3"
html2canvas: "npm:^1.4.1"
modern-screenshot: "npm:^4.7.0"
nodemailer: "npm:^8.0.1"
nuxt: "npm:^4.3.1"
nuxt-aos: "npm:^1.2.6"
@@ -10034,15 +10015,6 @@ __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"
@@ -10634,15 +10606,6 @@ __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"