Merge branch 'main' of github.com:OpenSquawk/OpenSquawk

This commit is contained in:
leubeem
2026-06-21 21:00:20 +02:00
21 changed files with 1912 additions and 747 deletions

9
.githooks/pre-push Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/sh
# Runs automatically before every git push (installed via postinstall → git config core.hooksPath).
# Keeps broken TypeScript off main without any manual developer setup.
set -e
echo "→ TypeScript check..."
yarn typecheck
echo "✓ Pre-push checks passed"

Binary file not shown.

View File

@@ -43,6 +43,10 @@
<v-tab value="invitations">Invitations</v-tab>
<v-tab value="waitlist">Waitlist</v-tab>
<v-tab value="logs">Transmissions</v-tab>
<v-tab value="bug-reports">
Bug Reports
<v-badge v-if="bugReportOpenCount > 0" :content="bugReportOpenCount" color="red" inline class="ml-1" />
</v-tab>
</v-tabs>
<v-window v-model="activeTab" class="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
@@ -932,6 +936,145 @@
</section>
</v-window-item>
<!-- Bug Reports Tab -->
<v-window-item value="bug-reports">
<section class="space-y-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-2xl font-semibold">Bug Reports</h2>
<p class="text-sm text-white/70">Fehlermeldungen von Testern aus der /pm-Seite.</p>
</div>
<div class="flex items-center gap-3">
<v-select
v-model="bugReportStatusFilter"
:items="[{ title: 'Offen', value: 'open' }, { title: 'Erledigt', value: 'resolved' }]"
label="Status"
density="comfortable"
variant="outlined"
color="cyan"
hide-details
class="w-40"
/>
<v-btn color="cyan" variant="tonal" :loading="bugReportLoading" @click="fetchBugReports(true)">
Laden
</v-btn>
</div>
</div>
<v-alert v-if="bugReportError" type="warning" variant="tonal" density="comfortable" class="bg-red-500/10 text-red-100">
{{ bugReportError }}
</v-alert>
<div v-if="bugReportLoading && !bugReports.length" class="py-12 text-center text-white/70">
<v-progress-circular indeterminate color="cyan" class="mb-4" />
<p>Bug Reports laden</p>
</div>
<div v-else class="space-y-4">
<div v-if="bugReports.length" class="space-y-4">
<v-card
v-for="report in bugReports"
:key="report.id"
class="border border-white/10 bg-black/40"
>
<v-card-text class="space-y-3">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="space-y-1 min-w-0">
<div class="flex flex-wrap items-center gap-2">
<v-chip size="x-small" :color="report.status === 'open' ? 'red' : 'green'" variant="tonal">
{{ report.status === 'open' ? 'Offen' : 'Erledigt' }}
</v-chip>
<span class="text-xs text-white/40">{{ formatRelative(report.createdAt) }}</span>
<span v-if="report.hasScreenshot" class="text-xs text-cyan-300/60">
<v-icon size="12">mdi-image</v-icon> Screenshot
</span>
</div>
<p class="font-semibold text-white text-sm">{{ report.contact }}</p>
<p v-if="report.user" class="text-xs text-white/50">{{ report.user.email }}</p>
<p class="text-sm text-white/80 whitespace-pre-line">{{ report.comment }}</p>
<div v-if="report.pmState?.currentStateId" class="text-xs text-white/40 font-mono">
State: {{ report.pmState.currentStateId }} · Flow: {{ report.pmState.flowSlug || '' }}
</div>
</div>
<div class="flex flex-col gap-2 items-start md:items-end shrink-0">
<v-btn
v-if="report.status === 'open'"
size="small"
color="green"
variant="tonal"
prepend-icon="mdi-check"
:loading="bugReportResolving === report.id"
@click="resolveBugReport(report.id)"
>
Erledigt
</v-btn>
<v-btn
v-else
size="small"
color="orange"
variant="text"
prepend-icon="mdi-refresh"
:loading="bugReportResolving === report.id"
@click="reopenBugReport(report.id)"
>
Wieder öffnen
</v-btn>
<v-btn
v-if="report.hasScreenshot"
size="small"
variant="outlined"
color="cyan"
prepend-icon="mdi-image"
@click="loadBugReportDetail(report.id)"
>
Screenshot
</v-btn>
<v-btn
v-if="report.pmState?.flowSlug"
size="small"
variant="text"
color="cyan"
prepend-icon="mdi-play-circle-outline"
:href="`/pm?restoreBugReport=${report.id}`"
target="_blank"
>
In /pm öffnen
</v-btn>
</div>
</div>
<!-- Screenshot detail (loaded on demand) -->
<div v-if="bugReportDetailId === report.id">
<div v-if="bugReportDetailLoading" class="py-4 text-center text-white/60">
<v-progress-circular indeterminate color="cyan" size="24" />
</div>
<img
v-else-if="bugReportDetail?.screenshot"
:src="bugReportDetail.screenshot"
class="w-full rounded-xl border border-white/10"
alt="Screenshot"
/>
</div>
</v-card-text>
</v-card>
</div>
<p v-else class="py-12 text-center text-sm text-white/60">Keine Bug Reports vorhanden.</p>
<div class="flex flex-col items-center justify-between gap-3 sm:flex-row">
<div class="text-xs text-white/50">
Seite {{ bugReportPagination.page }} von {{ bugReportPagination.pages }} · {{ bugReportPagination.total }} Reports
</div>
<div class="flex items-center gap-2">
<v-btn variant="text" color="cyan" :disabled="bugReportPagination.page <= 1" @click="changeBugReportPage(bugReportPagination.page - 1)">Zurück</v-btn>
<v-btn variant="text" color="cyan" :disabled="bugReportPagination.page >= bugReportPagination.pages" @click="changeBugReportPage(bugReportPagination.page + 1)">Weiter</v-btn>
</div>
</div>
</div>
</section>
</v-window-item>
</v-window>
</div>
@@ -1280,13 +1423,33 @@ interface CreateInviteResponse {
}
}
interface BugReportItem {
id: string
comment: string
contact: string
user?: { id: string; email: string; name?: string }
pmState?: { flowSlug?: string; scenarioId?: string; currentStateId?: string }
status: 'open' | 'resolved'
createdAt: string | null
hasScreenshot: boolean
}
interface BugReportDetail extends BugReportItem {
screenshot: string | null
}
interface BugReportsResponse {
items: BugReportItem[]
pagination: { total: number; page: number; pageSize: number; pages: number }
}
definePageMeta({ middleware: 'require-admin' })
useHead({ title: 'Admin • OpenSquawk' })
const auth = useAuthStore()
const api = useApi()
const activeTab = ref<'overview' | 'users' | 'invitations' | 'waitlist' | 'logs'>('overview')
const activeTab = ref<'overview' | 'users' | 'invitations' | 'waitlist' | 'logs' | 'bug-reports'>('overview')
const refreshing = ref(false)
const overview = ref<OverviewData | null>(null)
@@ -1890,11 +2053,104 @@ watch(activeTab, (tab) => {
fetchWaitlist(true)
} else if (tab === 'logs' && !sessionsLoaded.value) {
fetchSessions(true)
} else if (tab === 'bug-reports' && !bugReportsLoaded.value) {
fetchBugReports(true)
}
})
// ── Bug Reports ──────────────────────────────────────────────────────────────
const bugReports = ref<BugReportItem[]>([])
const bugReportPagination = reactive({ total: 0, page: 1, pages: 1, pageSize: 20 })
const bugReportLoading = ref(false)
const bugReportError = ref('')
const bugReportsLoaded = ref(false)
const bugReportStatusFilter = ref<'open' | 'resolved'>('open')
const bugReportOpenCount = ref(0)
const bugReportResolving = ref<string | null>(null)
const bugReportDetailId = ref<string | null>(null)
const bugReportDetail = ref<BugReportDetail | null>(null)
const bugReportDetailLoading = ref(false)
async function fetchBugReports(resetPage = false) {
if (resetPage) bugReportPagination.page = 1
bugReportLoading.value = true
bugReportError.value = ''
try {
const response = await api.get<BugReportsResponse>('/api/admin/bug-reports', {
query: { status: bugReportStatusFilter.value, page: bugReportPagination.page },
})
bugReports.value = response.items
Object.assign(bugReportPagination, response.pagination)
bugReportsLoaded.value = true
if (bugReportStatusFilter.value === 'open') bugReportOpenCount.value = response.pagination.total
} catch (error) {
bugReportError.value = extractErrorMessage(error, 'Bug Reports konnten nicht geladen werden.')
} finally {
bugReportLoading.value = false
}
}
async function loadBugReportDetail(id: string) {
if (bugReportDetailId.value === id) {
bugReportDetailId.value = null
bugReportDetail.value = null
return
}
bugReportDetailId.value = id
bugReportDetail.value = null
bugReportDetailLoading.value = true
try {
bugReportDetail.value = await api.get<BugReportDetail>(`/api/admin/bug-reports/${id}`)
} catch {
bugReportDetailId.value = null
} finally {
bugReportDetailLoading.value = false
}
}
async function resolveBugReport(id: string) {
bugReportResolving.value = id
try {
await api.request(`/api/admin/bug-reports/${id}`, { method: 'PATCH', body: { status: 'resolved' } })
const report = bugReports.value.find((r) => r.id === id)
if (report) { report.status = 'resolved'; bugReportOpenCount.value = Math.max(0, bugReportOpenCount.value - 1) }
if (bugReportStatusFilter.value === 'open') bugReports.value = bugReports.value.filter((r) => r.id !== id)
} catch (error) {
bugReportError.value = extractErrorMessage(error, 'Status konnte nicht geändert werden.')
} finally {
bugReportResolving.value = null
}
}
async function reopenBugReport(id: string) {
bugReportResolving.value = id
try {
await api.request(`/api/admin/bug-reports/${id}`, { method: 'PATCH', body: { status: 'open' } })
const report = bugReports.value.find((r) => r.id === id)
if (report) { report.status = 'open'; bugReportOpenCount.value++ }
if (bugReportStatusFilter.value === 'resolved') bugReports.value = bugReports.value.filter((r) => r.id !== id)
} catch (error) {
bugReportError.value = extractErrorMessage(error, 'Status konnte nicht geändert werden.')
} finally {
bugReportResolving.value = null
}
}
function changeBugReportPage(page: number) {
if (page < 1 || page > bugReportPagination.pages) return
bugReportPagination.page = page
fetchBugReports()
}
watch(bugReportStatusFilter, () => fetchBugReports(true))
// ────────────────────────────────────────────────────────────────────────────
onMounted(() => {
loadOverview(true)
// Load open bug report count for the badge
api.get<BugReportsResponse>('/api/admin/bug-reports', { query: { status: 'open', page: 1 } })
.then((r) => { bugReportOpenCount.value = r.pagination.total })
.catch(() => {})
})
</script>
<style scoped>

File diff suppressed because it is too large Load Diff

View File

@@ -34,8 +34,8 @@
</h2>
<NuxtImg src="/img/bridge/hangar_sleeping.jpeg" alt="Bridge app screenshot" style="aspect-ratio: 2.7/1;object-fit: cover" class="rounded-2xl border border-white/10 shadow-[0_20px_60px_rgba(4,8,24,0.45)]" format="webp" />
<p class="text-sm text-white/75 sm:text-base">
We&rsquo;re actively building this part of the Bridge experience. There isn&rsquo;t a hosted version
yet, but we&rsquo;re lining everything up for launch in the coming weeks and months.
We&rsquo;re actively building this part of the Bridge experience. Not every feature works yet,
and some areas still use static dummy data, but you can already look around and send us feedback.
</p>
</div>
@@ -249,20 +249,20 @@ const downloads = [
id: 'msfs2020',
title: 'Microsoft Flight Simulator 2020',
description: 'One-click installer for the current simulator with live status built in.',
status: 'In development',
status: 'Developer preview',
badgeClass: 'bg-[#F59E0B]/15 text-[#F59E0B]',
state: 'preview',
href: 'https://github.com/itsrubberduck/OpenSquawk-MSFS-Bridge/',
href: 'https://github.com/OpenSquawk/OpenSquawk-Python-Bridge',
icon: 'mdi-microsoft',
},
{
id: 'msfs2024',
title: 'Microsoft Flight Simulator 2024',
description: 'Were updating the Bridge for the new sim launch. Watch this space.',
status: 'Planned',
badgeClass: 'bg-white/10 text-white/55',
state: 'planned',
href: '#',
description: 'Developer preview for the new simulator with live status built in.',
status: 'Developer preview',
badgeClass: 'bg-[#F59E0B]/15 text-[#F59E0B]',
state: 'preview',
href: 'https://github.com/OpenSquawk/OpenSquawk-Python-Bridge',
icon: 'mdi-microsoft',
},
{

View File

@@ -467,6 +467,29 @@
</div>
<div class="hud-right">
<v-tooltip
v-if="bridgeConnected"
:text="bridgeSimActiveFreq ? `SimBridge connected · COM1 ${bridgeSimActiveFreq}` : 'SimBridge connected'"
location="bottom"
>
<template #activator="{ props }">
<span class="bridge-badge" v-bind="props" role="status" aria-label="SimBridge connected">
<span class="bridge-badge-dot" aria-hidden="true"></span>
<v-icon size="15">mdi-bridge</v-icon>
<span class="bridge-badge-label">Bridge</span>
</span>
</template>
</v-tooltip>
<button
type="button"
class="btn ghost"
title="Fehler melden"
:disabled="bugReportCapturing"
@click="openBugReport"
>
<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>
<span class="btn-label">Feedback</span>
@@ -599,6 +622,17 @@
>
{{ isRecording ? 'Transmitting' : 'Hold to transmit' }}
</p>
<p
v-if="bridgePttConnected"
class="text-[10px] uppercase tracking-[0.25em] flex items-center justify-center gap-1.5"
:class="isRecording ? 'text-red-300' : 'text-cyan-300/70'"
>
<span
class="inline-block h-1.5 w-1.5 rounded-full"
:class="isRecording ? 'bg-red-400 animate-pulse' : 'bg-cyan-300/60'"
/>
{{ isRecording ? 'Hotkey transmitting' : 'Hotkey armed' }}
</p>
<p class="pt-2 text-4xl font-bold font-mono tracking-tight">{{ frequencies.active || '---' }}</p>
<p class="text-xs text-white/45">Active frequency</p>
</div>
@@ -1198,6 +1232,126 @@
</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-start justify-between gap-3">
<div class="flex items-start gap-2">
<v-icon size="18" color="red" class="mt-0.5">mdi-gesture-tap-button</v-icon>
<div>
<p class="text-sm font-semibold text-white/90">Wo ist der Fehler? Zeichne einen Pfeil hin.</p>
<p class="text-xs text-white/55 leading-snug">
Klicke auf die Stelle und ziehe mit gedrückter Maustaste (am Handy: mit dem Finger)
zur Problemstelle. Du kannst mehrere Pfeile setzen.
</p>
</div>
</div>
<v-btn
v-if="bugReportArrows.length > 0"
size="x-small"
variant="text"
color="red"
prepend-icon="mdi-undo"
class="shrink-0"
@click="undoLastArrow"
>
Pfeil zurück
</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"
@pointerdown="onCanvasMouseDown"
@pointermove="onCanvasMouseMove"
@pointerup="onCanvasMouseUp"
@pointerleave="onCanvasMouseLeave"
@pointercancel="onCanvasMouseLeave"
/>
<span
v-if="bugReportArrows.length === 0"
class="pointer-events-none absolute inset-0 flex items-center justify-center text-center px-4"
>
<span class="rounded-full bg-black/55 px-3 py-1 text-xs text-white/85 backdrop-blur">
✏️ Hier ziehen, um einen Pfeil zur Fehlerstelle zu zeichnen
</span>
</span>
</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 +1396,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 +1446,7 @@ const engine = useCommunicationsEngine()
const auth = useAuthStore()
const api = useApi()
const router = useRouter()
const route = useRoute()
const radioBackend = useRadioBackend()
const config = useRuntimeConfig()
@@ -1992,6 +2147,263 @@ 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 bugReportCapturing = 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(' — ')
// 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 {
// 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
}
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
}
}
/**
* 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
const prerecEnabled = ref(true)
const prerecSeconds = ref(1.0)
@@ -2096,12 +2508,19 @@ const scheduleAirportDataRefresh = () => {
}, delay)
}
// Send unauthenticated visitors to login while preserving where they were
// headed (e.g. /pm?token=… so the bridge link survives the round-trip),
// instead of dropping them on the classroom fallback after sign-in.
const redirectToLogin = () => {
router.push({ path: '/login', query: { redirect: route.fullPath } })
}
onMounted(async () => {
try {
if (!auth.accessToken) {
const refreshed = await auth.tryRefresh()
if (!refreshed) {
router.push('/login')
redirectToLogin()
return
}
}
@@ -2109,7 +2528,7 @@ onMounted(async () => {
if (!auth.user) {
await auth.fetchUser().catch((err) => {
console.error('Session initialisation failed', err)
router.push('/login')
redirectToLogin()
})
}
@@ -2151,6 +2570,12 @@ onMounted(async () => {
}
}
}
// Restore state from bug report (admin link: /pm?restoreBugReport=<id>)
const restoreId = route.query.restoreBugReport as string | undefined
if (restoreId) {
await restoreBugReportState(restoreId)
}
} finally {
restoringFromStorage = false
}
@@ -2161,7 +2586,7 @@ watch(
(token) => {
if (!token) {
persistSelectedPlan(null)
router.push('/login')
redirectToLogin()
}
}
)
@@ -4409,10 +4834,193 @@ watch(prerecEnabled, (val) => {
}
})
// --- SimBridge live frequency sync -----------------------------------------
// When /pm is opened with ?token=<bridge-token> and that bridge is actively
// posting telemetry, mirror the sim's COM1 radio panel (active + standby) into
// the radio and surface a "Bridge connected" indicator. The bridge counts as
// connected only while fresh telemetry keeps arriving — if it goes quiet we
// drop the badge.
const bridgeToken = computed(() => {
const value = route.query.token
const raw = Array.isArray(value) ? value[0] : value
return typeof raw === 'string' ? raw.trim() : ''
})
const bridgeConnected = ref(false)
const bridgeSimActiveFreq = ref<string | null>(null)
// Telemetry older than this means the bridge stopped posting.
const BRIDGE_TELEMETRY_STALE_MS = 12_000
const BRIDGE_POLL_INTERVAL_MS = 3_000
let bridgePoller: ReturnType<typeof setInterval> | null = null
// Last sim active frequency we pushed into the radio — only re-tune active when
// the sim value actually changes, so manual/flow tuning isn't constantly
// overridden. Standby has no such anchor: while connected it strictly mirrors
// the sim's standby radio.
let lastSyncedSimActive: string | null = null
function normalizeSimFreq(value: unknown): string | null {
const num = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(num) || num < 118 || num >= 137) return null
return num.toFixed(3)
}
async function pollBridgeTelemetry() {
const token = bridgeToken.value
if (!token) return
try {
const res = await $fetch<{ connected: boolean; lastTelemetryAt: string | null; telemetry: any }>(
'/api/bridge/live',
{ headers: { 'x-bridge-token': token } },
)
const ts = res.lastTelemetryAt ? Date.parse(res.lastTelemetryAt) : null
const fresh = Boolean(res.connected && ts && Date.now() - ts < BRIDGE_TELEMETRY_STALE_MS)
bridgeConnected.value = fresh
if (!fresh) {
// Bridge went quiet — drop the active sync anchor so reconnecting re-tunes.
lastSyncedSimActive = null
bridgeSimActiveFreq.value = null
return
}
const simActive = normalizeSimFreq(res.telemetry?.COM_ACTIVE_FREQUENCY)
const simStandby = normalizeSimFreq(res.telemetry?.COM_STANDBY_FREQUENCY)
bridgeSimActiveFreq.value = simActive
// Mirror COM1 active: tuning away cuts in-progress ATC speech on the old
// channel, same as a manual tune.
if (simActive && simActive !== lastSyncedSimActive) {
lastSyncedSimActive = simActive
if (frequencies.value.active !== simActive) {
stopCurrentSpeech()
frequencies.value.active = simActive
}
}
// Mirror COM1 standby (no audio side effects — it's just the staged
// channel). While connected the standby always reflects the sim's standby
// radio, never the previously tuned channel.
if (simStandby && frequencies.value.standby !== simStandby) {
frequencies.value.standby = simStandby
}
} catch {
bridgeConnected.value = false
}
}
function startBridgeSync() {
stopBridgeSync()
if (!bridgeToken.value) return
void pollBridgeTelemetry()
bridgePoller = setInterval(pollBridgeTelemetry, BRIDGE_POLL_INTERVAL_MS)
}
function stopBridgeSync() {
if (bridgePoller) {
clearInterval(bridgePoller)
bridgePoller = null
}
}
// --- Remote push-to-talk over the bridge link --------------------------------
// The OpenSquawk Bridge captures a global hotkey on the PC and POSTs each edge
// to /api/bridge/ptt; the backend relays it here over WebSocket so PTT works
// while the sim (not this tab) is focused. We reuse the on-screen pad's
// startRecording/stopRecording, so behaviour is identical to holding the pad.
let pttSocket: WebSocket | null = null
let pttReconnectTimer: ReturnType<typeof setTimeout> | null = null
let pttClosedByUs = false
const bridgePttConnected = ref(false)
async function handleRemotePtt(state: 'down' | 'up') {
if (state === 'down') {
// A backgrounded tab can suspend the prerec AudioContext; resume it so the
// ring buffer + live capture are running when the edge arrives from the sim.
if (prerecCtx && prerecCtx.state === 'suspended') {
try { await prerecCtx.resume() } catch {}
}
void startRecording(false)
} else {
stopRecording()
}
}
function schedulePttReconnect() {
if (pttReconnectTimer || pttClosedByUs) return
pttReconnectTimer = setTimeout(() => {
pttReconnectTimer = null
connectPttSocket()
}, 3_000)
}
function connectPttSocket() {
disconnectPttSocket()
const token = bridgeToken.value
if (!token || typeof window === 'undefined') return
pttClosedByUs = false
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${proto}//${window.location.host}/api/bridge/ws`
let socket: WebSocket
try {
socket = new WebSocket(url)
} catch {
schedulePttReconnect()
return
}
pttSocket = socket
socket.onopen = () => {
bridgePttConnected.value = true
try { socket.send(JSON.stringify({ type: 'subscribe', token })) } catch {}
}
socket.onmessage = (ev) => {
let data: any
try { data = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data)) } catch { return }
if (data?.type === 'ptt' && (data.state === 'down' || data.state === 'up')) {
void handleRemotePtt(data.state)
}
}
socket.onclose = () => {
bridgePttConnected.value = false
if (pttSocket === socket) pttSocket = null
if (!pttClosedByUs) schedulePttReconnect()
}
socket.onerror = () => {
try { socket.close() } catch {}
}
}
function disconnectPttSocket() {
pttClosedByUs = true
if (pttReconnectTimer) {
clearTimeout(pttReconnectTimer)
pttReconnectTimer = null
}
if (pttSocket) {
try { pttSocket.close() } catch {}
pttSocket = null
}
bridgePttConnected.value = false
}
onMounted(() => {
startBridgeSync()
connectPttSocket()
})
watch(bridgeToken, () => {
bridgeConnected.value = false
bridgeSimActiveFreq.value = null
lastSyncedSimActive = null
startBridgeSync()
connectPttSocket()
})
onUnmounted(() => {
stopAtisLoop()
stopPrerecCapture()
cancelAirportDataRefresh()
stopBridgeSync()
disconnectPttSocket()
})
</script>
@@ -4518,6 +5126,42 @@ onUnmounted(() => {
.hud-right .btn-label {
white-space: nowrap;
}
.bridge-badge {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 40px;
padding: 8px 12px;
border-radius: 12px;
border: 1px solid rgba(52, 211, 153, 0.4);
background: rgba(16, 185, 129, 0.14);
color: rgba(167, 243, 208, 0.95);
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
cursor: default;
}
.bridge-badge-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #34d399;
box-shadow: 0 0 8px rgba(52, 211, 153, 0.8);
animation: bridge-badge-pulse 1.8s ease-in-out infinite;
}
.bridge-badge-label {
white-space: nowrap;
}
@keyframes bridge-badge-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@media (max-width: 900px) {
.bridge-badge-label { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.bridge-badge-dot { animation: none; }
}
.hud-logo {
display: inline-flex;
align-items: center;

View File

@@ -10,7 +10,7 @@
"generate": "nuxt generate",
"preview": "nuxt preview",
"start": "node .output/server/index.mjs",
"postinstall": "nuxt prepare",
"postinstall": "nuxt prepare && (git config core.hooksPath .githooks 2>/dev/null || true)",
"typecheck": "vue-tsc --build",
"sharp:rebuild": "SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn rebuild sharp",
"import:decision": "tsx --tsconfig tsconfig.scripts.json scripts/import-decision-tree.ts",
@@ -22,6 +22,7 @@
"@pinia/nuxt": "^0.11.3",
"dotenv": "^17.3.1",
"fluent-ffmpeg": "^2.1.3",
"modern-screenshot": "^4.7.0",
"nodemailer": "^8.0.1",
"nuxt": "^4.3.1",
"nuxt-aos": "^1.2.6",

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:10cff57568acb96ca4d3ba7219f3fbcdce22012a953c863f8b32ac92449c8a41
size 1305604

View File

@@ -0,0 +1,27 @@
import { defineEventHandler, getRouterParam, createError } from 'h3'
import { requireAdmin } from '../../../utils/auth'
import { BugReport } from '../../../models/BugReport'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const id = getRouterParam(event, 'id')
const doc = await BugReport.findById(id).populate('userId', 'email name role').lean() as any
if (!doc) {
throw createError({ statusCode: 404, statusMessage: 'Bug report not found' })
}
return {
id: String(doc._id),
comment: doc.comment,
contact: doc.contact,
user: doc.userId
? { id: String((doc.userId as any)._id), email: (doc.userId as any).email, name: (doc.userId as any).name }
: undefined,
screenshot: doc.screenshot || null,
pmState: doc.pmState || null,
status: doc.status,
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : null,
}
})

View File

@@ -0,0 +1,27 @@
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
import { requireAdmin } from '../../../utils/auth'
import { BugReport } from '../../../models/BugReport'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const id = getRouterParam(event, 'id')
const body = await readBody(event)
const status = body?.status
if (status !== 'open' && status !== 'resolved') {
throw createError({ statusCode: 400, statusMessage: 'status must be "open" or "resolved"' })
}
const doc = await BugReport.findByIdAndUpdate(
id,
{ $set: { status } },
{ new: true },
).lean() as any
if (!doc) {
throw createError({ statusCode: 404, statusMessage: 'Bug report not found' })
}
return { id: String(doc._id), status: doc.status }
})

View File

@@ -0,0 +1,78 @@
import { defineEventHandler, getQuery } from 'h3'
import { requireAdmin } from '../../../utils/auth'
import { BugReport } from '../../../models/BugReport'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const query = getQuery(event)
const page = Math.max(parseInt(String(query.page ?? '1'), 10) || 1, 1)
const pageSize = 20
const skip = (page - 1) * pageSize
const status = String(query.status ?? 'open')
const filter: Record<string, any> = {}
if (status === 'open' || status === 'resolved') {
filter.status = status
}
const [docs, total] = await Promise.all([
BugReport.aggregate([
{ $match: filter },
{ $sort: { createdAt: -1 } },
{ $skip: skip },
{ $limit: pageSize },
{
$lookup: {
from: 'users',
localField: 'userId',
foreignField: '_id',
as: 'userDoc',
pipeline: [{ $project: { email: 1, name: 1, role: 1 } }],
},
},
{
$project: {
comment: 1,
contact: 1,
userId: 1,
userDoc: 1,
status: 1,
createdAt: 1,
'pmState.flowSlug': 1,
'pmState.scenarioId': 1,
'pmState.currentStateId': 1,
hasScreenshot: { $gt: [{ $strLenCP: { $ifNull: ['$screenshot', ''] } }, 0] },
},
},
]).exec(),
BugReport.countDocuments(filter),
])
const items = docs.map((doc: any) => ({
id: String(doc._id),
comment: doc.comment,
contact: doc.contact,
user: doc.userDoc?.[0]
? { id: String(doc.userDoc[0]._id), email: doc.userDoc[0].email, name: doc.userDoc[0].name }
: undefined,
pmState: {
flowSlug: doc.pmState?.flowSlug,
scenarioId: doc.pmState?.scenarioId,
currentStateId: doc.pmState?.currentStateId,
},
status: doc.status,
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : null,
hasScreenshot: Boolean(doc.hasScreenshot),
}))
return {
items,
pagination: {
total,
page,
pageSize,
pages: Math.ceil(total / pageSize) || 1,
},
}
})

View File

@@ -57,6 +57,11 @@ function mapBridgeTelemetry(raw: Record<string, any>): FlightLabTelemetryState {
TRANSPONDER_CODE: raw.transponder_code ?? raw.TRANSPONDER_CODE ?? 0,
ADF_ACTIVE_FREQUENCY: raw.adf_active_freq ?? raw.ADF_ACTIVE_FREQUENCY ?? 0,
ADF_STANDBY_FREQUENCY: raw.adf_standby_freq_hz ?? raw.ADF_STANDBY_FREQUENCY ?? 0,
COM_ACTIVE_FREQUENCY: raw.com_active_frequency ?? raw.COM_ACTIVE_FREQUENCY ?? 0,
COM_STANDBY_FREQUENCY: raw.com_standby_frequency ?? raw.COM_STANDBY_FREQUENCY ?? 0,
PLANE_LATITUDE: raw.latitude_deg ?? raw.PLANE_LATITUDE ?? 0,
PLANE_LONGITUDE: raw.longitude_deg ?? raw.PLANE_LONGITUDE ?? 0,
PLANE_HEADING_DEGREES_TRUE: raw.heading_deg ?? raw.PLANE_HEADING_DEGREES_TRUE ?? 0,
}
}

View File

@@ -0,0 +1,46 @@
import { createError, readBody } from 'h3'
import { BridgeToken } from '../../models/BridgeToken'
import { getBridgeTokenFromHeader } from '../../utils/bridge'
import { logBridgeEvent } from '../../utils/bridgeLog'
import { pttBus, type PttState } from '../../utils/pttBus'
interface PttBody {
state?: PttState
}
export default defineEventHandler(async (event) => {
const token = getBridgeTokenFromHeader(event)
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'x-bridge-token header fehlt oder ist ungültig.' })
}
const body = await readBody<PttBody>(event)
const state = body?.state
if (state !== 'down' && state !== 'up') {
throw createError({ statusCode: 400, statusMessage: "state muss 'down' oder 'up' sein." })
}
// Only relay for a linked token; an unknown/unlinked token is a no-op so the
// bus is never driven by an unauthenticated caller.
const exists = await BridgeToken.exists({ token })
if (!exists) {
throw createError({ statusCode: 404, statusMessage: 'Bridge-Token ist nicht verknüpft.' })
}
console.info(
`\x1b[33m[bridge:ptt]\x1b[0m token=\x1b[96m${token.slice(0, 6)}...\x1b[0m state=\x1b[92m${state}\x1b[0m`,
)
pttBus.publish(token, state)
logBridgeEvent(token, {
endpoint: '/api/bridge/ptt',
method: 'POST',
statusCode: 200,
color: '#eab308',
summary: `ptt=${state}`,
data: { state },
})
return { ok: true, state }
})

77
server/api/bridge/ws.ts Normal file
View File

@@ -0,0 +1,77 @@
// server/api/bridge/ws.ts
//
// Low-latency push channel for push-to-talk. A /pm tab opens this socket and
// sends { type: 'subscribe', token } using its `?token=` bridge link. The
// Bridge POSTs key edges to /api/bridge/ptt, which drives pttBus; we relay each
// edge to every peer subscribed to that token.
import { defineWebSocketHandler } from 'h3'
import { normalizeBridgeToken } from '../../utils/bridge'
import { pttBus } from '../../utils/pttBus'
// token → set of connected /pm peers
const subscribers = new Map<string, Set<any>>()
// peerId → token, so close() can clean up without scanning every set
const peerTokens = new Map<string, string>()
function peerId(peer: any): string {
return peer?.id ?? String(peer)
}
// Relay every PTT edge to the peers listening on that token.
pttBus.subscribe((token, state) => {
const peers = subscribers.get(token)
if (!peers) return
const payload = JSON.stringify({ type: 'ptt', state })
for (const peer of peers) {
try { peer.send(payload) } catch {}
}
})
function unsubscribe(peer: any) {
const id = peerId(peer)
const token = peerTokens.get(id)
if (!token) return
peerTokens.delete(id)
const peers = subscribers.get(token)
if (!peers) return
peers.delete(peer)
if (peers.size === 0) subscribers.delete(token)
}
export default defineWebSocketHandler({
message(peer, msg) {
let data: any
try {
data = JSON.parse(typeof msg === 'string' ? msg : msg.toString())
} catch {
peer.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }))
return
}
if (data.type === 'subscribe') {
const token = normalizeBridgeToken(data.token)
if (!token) {
peer.send(JSON.stringify({ type: 'error', message: 'Invalid token' }))
return
}
// A peer only ever listens to one token; drop any previous binding.
unsubscribe(peer)
let peers = subscribers.get(token)
if (!peers) {
peers = new Set()
subscribers.set(token, peers)
}
peers.add(peer)
peerTokens.set(peerId(peer), token)
peer.send(JSON.stringify({ type: 'subscribed' }))
}
},
close(peer) {
unsubscribe(peer)
},
error(peer) {
unsubscribe(peer)
},
})

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)

31
server/utils/pttBus.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* In-memory push-to-talk event bus.
*
* Flow: Bridge → POST /api/bridge/ptt → pttBus → WS (/api/bridge/ws) → /pm
*
* Keyed by bridge token: the Bridge POSTs with its token, and the /pm tab
* subscribes over WebSocket with the same token (from its `?token=` link), so
* an edge is delivered only to the matching browser.
*/
export type PttState = 'down' | 'up'
type PttListener = (token: string, state: PttState) => void
class PttBus {
private listeners = new Set<PttListener>()
publish(token: string, state: PttState) {
for (const listener of this.listeners) {
try { listener(token, state) } catch {}
}
}
subscribe(listener: PttListener) {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
}
// Singleton — shared across all server handlers
export const pttBus = new PttBus()

View File

@@ -38,6 +38,11 @@ export interface FlightLabTelemetryState {
TRANSPONDER_CODE: number // squawk code (0-7777 octal)
ADF_ACTIVE_FREQUENCY: number // Hz
ADF_STANDBY_FREQUENCY: number // Hz
COM_ACTIVE_FREQUENCY?: number // MHz (e.g. 121.900), COM1 active radio
COM_STANDBY_FREQUENCY?: number // MHz, COM1 standby radio
PLANE_LATITUDE?: number // degrees, WGS84 (N+)
PLANE_LONGITUDE?: number // degrees, WGS84 (E+)
PLANE_HEADING_DEGREES_TRUE?: number // degrees, 0..360
timestamp?: number
}

View File

@@ -0,0 +1,114 @@
/**
* Unit tests for BugReport logic: validation, contact-string building,
* and model schema integrity. No database connection required.
*/
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { BugReport } from '~~/server/models/BugReport'
// ── Contact-string helpers (mirror of index.post.ts logic) ───────────────────
function buildContact(user: { name?: string; email: string }, override?: string): string {
return String(
override || [user.name, user.email].filter(Boolean).join(' — ')
).slice(0, 200)
}
function validateComment(raw: unknown): string {
const comment = String(raw ?? '').trim()
if (!comment) throw new Error('comment_required')
return comment.slice(0, 4000)
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('BugReport — comment validation', () => {
it('accepts a non-empty comment', () => {
assert.equal(validateComment('App crashed when I pressed PTT'), 'App crashed when I pressed PTT')
})
it('trims whitespace before validating', () => {
assert.equal(validateComment(' hello '), 'hello')
})
it('rejects empty string', () => {
assert.throws(() => validateComment(''), { message: 'comment_required' })
})
it('rejects whitespace-only string', () => {
assert.throws(() => validateComment(' '), { message: 'comment_required' })
})
it('rejects null/undefined', () => {
assert.throws(() => validateComment(null), { message: 'comment_required' })
assert.throws(() => validateComment(undefined), { message: 'comment_required' })
})
it('truncates comment at 4000 chars', () => {
const long = 'x'.repeat(5000)
assert.equal(validateComment(long).length, 4000)
})
})
describe('BugReport — contact string', () => {
it('joins name and email with em dash separator', () => {
assert.equal(buildContact({ name: 'Max', email: 'max@example.com' }), 'Max — max@example.com')
})
it('omits name when absent', () => {
assert.equal(buildContact({ email: 'max@example.com' }), 'max@example.com')
})
it('uses override string when provided', () => {
assert.equal(buildContact({ email: 'x@y.com' }, 'Custom Name — x@y.com'), 'Custom Name — x@y.com')
})
it('truncates contact at 200 chars', () => {
const long = 'x'.repeat(300)
assert.equal(buildContact({ email: 'a@b.com' }, long).length, 200)
})
})
describe('BugReport — model schema', () => {
it('model can be imported without a DB connection', () => {
assert.ok(BugReport, 'BugReport model must be importable')
})
it('schema defines expected fields', () => {
const paths = BugReport.schema.paths
assert.ok('comment' in paths, 'schema must have comment')
assert.ok('contact' in paths, 'schema must have contact')
assert.ok('status' in paths, 'schema must have status')
assert.ok('screenshot' in paths, 'schema must have screenshot')
assert.ok('createdAt' in paths, 'schema must have createdAt')
})
it('status field only allows open or resolved', () => {
const statusPath = BugReport.schema.path('status') as any
const enumValues: string[] = statusPath?.options?.enum ?? []
assert.deepEqual(enumValues.sort(), ['open', 'resolved'])
})
it('status defaults to open', () => {
const statusPath = BugReport.schema.path('status') as any
assert.equal(statusPath?.options?.default, 'open')
})
})
describe('BugReport — patch status validation', () => {
const validStatuses = ['open', 'resolved']
const invalidStatuses = ['', 'done', 'closed', 'pending', undefined, null]
it('accepts valid statuses', () => {
for (const s of validStatuses) {
assert.ok(s === 'open' || s === 'resolved', `${s} should be valid`)
}
})
it('rejects invalid statuses', () => {
for (const s of invalidStatuses) {
assert.ok(s !== 'open' && s !== 'resolved', `${s} should be invalid`)
}
})
})

View File

@@ -0,0 +1,47 @@
/**
* Smoke tests: verify that API handler files can be imported and export a
* default function. Catches broken imports, missing exports, and top-level
* syntax/runtime errors without needing a running server or database.
*/
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
describe('API handler imports — bug reports', async () => {
it('POST /api/bug-reports exports a handler', async () => {
const mod = await import('~~/server/api/bug-reports/index.post')
assert.equal(typeof mod.default, 'function', 'handler must be a function')
})
it('GET /api/admin/bug-reports exports a handler', async () => {
const mod = await import('~~/server/api/admin/bug-reports/index.get')
assert.equal(typeof mod.default, 'function', 'handler must be a function')
})
it('GET /api/admin/bug-reports/[id] exports a handler', async () => {
// dynamic import with bracket filename
const mod = await import('~~/server/api/admin/bug-reports/[id].get')
assert.equal(typeof mod.default, 'function', 'handler must be a function')
})
it('PATCH /api/admin/bug-reports/[id] exports a handler', async () => {
const mod = await import('~~/server/api/admin/bug-reports/[id].patch')
assert.equal(typeof mod.default, 'function', 'handler must be a function')
})
})
describe('API handler imports — admin core', async () => {
it('GET /api/admin/overview exports a handler', async () => {
const mod = await import('~~/server/api/admin/overview.get')
assert.equal(typeof mod.default, 'function')
})
it('GET /api/admin/users exports a handler', async () => {
const mod = await import('~~/server/api/admin/users.get')
assert.equal(typeof mod.default, 'function')
})
it('GET /api/admin/invitations exports a handler', async () => {
const mod = await import('~~/server/api/admin/invitations.get')
assert.equal(typeof mod.default, 'function')
})
})

View File

@@ -7313,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"
@@ -7976,6 +7983,7 @@ __metadata:
"@types/three": "npm:^0.183.0"
dotenv: "npm:^17.3.1"
fluent-ffmpeg: "npm:^2.1.3"
modern-screenshot: "npm:^4.7.0"
nodemailer: "npm:^8.0.1"
nuxt: "npm:^4.3.1"
nuxt-aos: "npm:^1.2.6"