mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 08:55:54 +08:00
Merge branch 'main' of github.com:OpenSquawk/OpenSquawk
This commit is contained in:
9
.githooks/pre-push
Executable file
9
.githooks/pre-push
Executable 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.
@@ -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
@@ -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’re actively building this part of the Bridge experience. There isn’t a hosted version
|
||||
yet, but we’re lining everything up for launch in the coming weeks and months.
|
||||
We’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: 'We’re 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',
|
||||
},
|
||||
{
|
||||
|
||||
652
app/pages/pm.vue
652
app/pages/pm.vue
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
3
public/img/bridge/logo.png
Normal file
3
public/img/bridge/logo.png
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:10cff57568acb96ca4d3ba7219f3fbcdce22012a953c863f8b32ac92449c8a41
|
||||
size 1305604
|
||||
27
server/api/admin/bug-reports/[id].get.ts
Normal file
27
server/api/admin/bug-reports/[id].get.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
27
server/api/admin/bug-reports/[id].patch.ts
Normal file
27
server/api/admin/bug-reports/[id].patch.ts
Normal 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 }
|
||||
})
|
||||
78
server/api/admin/bug-reports/index.get.ts
Normal file
78
server/api/admin/bug-reports/index.get.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
46
server/api/bridge/ptt.post.ts
Normal file
46
server/api/bridge/ptt.post.ts
Normal 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
77
server/api/bridge/ws.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
44
server/api/bug-reports/index.post.ts
Normal file
44
server/api/bug-reports/index.post.ts
Normal 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) }
|
||||
})
|
||||
45
server/models/BugReport.ts
Normal file
45
server/models/BugReport.ts
Normal 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
31
server/utils/pttBus.ts
Normal 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()
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
114
tests/server/bugReport.test.ts
Normal file
114
tests/server/bugReport.test.ts
Normal 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`)
|
||||
}
|
||||
})
|
||||
})
|
||||
47
tests/smoke/apiHandlers.smoke.test.ts
Normal file
47
tests/smoke/apiHandlers.smoke.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user