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"