diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz
index aad1e24..7f34283 100644
Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ
diff --git a/app/pages/pm.vue b/app/pages/pm.vue
index 52a5c7d..c08d2b1 100644
--- a/app/pages/pm.vue
+++ b/app/pages/pm.vue
@@ -471,10 +471,11 @@
type="button"
class="btn ghost"
title="Fehler melden"
+ :disabled="bugReportCapturing"
@click="openBugReport"
>
- mdi-bug-outline
- Bug
+ {{ bugReportCapturing ? 'mdi-loading mdi-spin' : 'mdi-bug-outline' }}
+ {{ bugReportCapturing ? '…' : 'Bug' }}
mdi-message-draw
@@ -2110,6 +2111,7 @@ const bugReportContact = ref('')
const bugReportScreenshot = ref(null)
const bugReportArrows = ref>([])
const bugReportLoading = ref(false)
+const bugReportCapturing = ref(false)
const bugReportError = ref('')
const bugReportSuccess = ref(false)
const bugReportCanvasRef = ref(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 , 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=`). 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(`/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 = {
+ 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=)
const restoreId = route.query.restoreBugReport as string | undefined
if (restoreId) {
- try {
- const report = await api.get(`/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
diff --git a/package.json b/package.json
index aba6822..aa8e10b 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/yarn.lock b/yarn.lock
index c8f1e6e..b1bfd78 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"