Client-Logging, getrennte Rate-Limits und Ladezustand für Bilder
Auf dem Testserver lief die App beim Scrollen langer Timelines in ein 429: nach jeder Scroll-Pause ging sofort ein PUT /settings raus, und jedes sichtbare Vorschaubild zählte auf dasselbe Limit von 300/min. Danach blieben Bilder leer und "Einstellungen konnten nicht gespeichert werden" erschien. Backend: Medien-Reads bekommen einen eigenen Limiter (media, 1200/min), POST /client-logs nimmt Warnungen und Fehler der App an (client-logs, 30/min) und schreibt sie in den täglichen Kanal `client`; Exceptions tragen user_id, path und method im Kontext. Frontend: Einstellungen gehen frühestens 1,5 s nach der letzten Änderung und nur bei geändertem Inhalt zum Server, 429/Netzfehler werden still mit Backoff wiederholt, nur eine Ablehnung wird gemeldet. Bilder laufen durch ein Gate (6 parallel, Retry-After, 3 Versuche) und fallen auf einen Platzhalter statt auf einen kaputten Pfad zurück. Neuer clientLog-Service mit IndexedDB-Puffer, Dedupe und Batch-Versand, globaler Fehlerfang im Boot, Protokoll im Entwickler-Tab. Drei pulsierende Punkte statt weißer Fläche in Timeline-Punkt, Key Image und Medienkacheln. Dokumentation in documentation/logging.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
522deffa1a
commit
ab23c9f4ef
28 changed files with 1689 additions and 78 deletions
|
|
@ -11,7 +11,7 @@ export default defineConfig((ctx) => {
|
|||
// app boot file (/src/boot)
|
||||
// --> boot files are part of "main.js"
|
||||
// https://v2.quasar.dev/quasar-cli-vite/boot-files
|
||||
boot: [],
|
||||
boot: ['errorLog'],
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
|
||||
css: ['app.scss'],
|
||||
|
|
|
|||
54
frontend/src/boot/errorLog.js
Normal file
54
frontend/src/boot/errorLog.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { defineBoot } from '#q-app/wrappers'
|
||||
import { apiJson } from 'src/services/apiClient'
|
||||
import { clientLog, restoreClientLog, setClientLogTransport } from 'src/services/clientLog'
|
||||
import { APP_VERSION } from 'src/config/appVersion'
|
||||
import { useAuthStore } from 'stores/auth'
|
||||
|
||||
/**
|
||||
* Catches what nobody else caught and connects the client log to the API.
|
||||
* Runs once at start-up, before the router mounts anything.
|
||||
*/
|
||||
export default defineBoot(({ app }) => {
|
||||
app.config.errorHandler = (error, instance, info) => {
|
||||
clientLog.error('vue', error?.message || String(error), {
|
||||
error: error?.stack ? error.stack.split('\n').slice(0, 5).join(' | ') : undefined,
|
||||
component: instance?.$options?.name || instance?.$?.type?.__name || null,
|
||||
info
|
||||
})
|
||||
if (import.meta.env.DEV) console.error(error)
|
||||
}
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
// Failed <img>/<script> loads arrive here as well, without an error object.
|
||||
if (event.target && event.target !== window) {
|
||||
const target = event.target
|
||||
clientLog.warn('resource', `Ressource konnte nicht geladen werden: ${target.tagName?.toLowerCase()}`, {
|
||||
src: target.currentSrc || target.src || target.href || null
|
||||
})
|
||||
return
|
||||
}
|
||||
clientLog.error('window', event.message || 'Unbekannter Fehler', {
|
||||
error: event.error?.stack ? event.error.stack.split('\n').slice(0, 5).join(' | ') : undefined,
|
||||
source: event.filename ? `${event.filename}:${event.lineno}:${event.colno}` : null
|
||||
})
|
||||
}, true)
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const reason = event.reason
|
||||
clientLog.error('promise', reason?.message || String(reason), reason instanceof Error ? reason : { reason })
|
||||
})
|
||||
|
||||
setClientLogTransport({
|
||||
appVersion: APP_VERSION,
|
||||
// Demo users have no server account; their log stays in the browser.
|
||||
enabled: () => useAuthStore().currentUser?.mode === 'remote',
|
||||
send: (batch, { keepalive } = {}) => apiJson('/client-logs', {
|
||||
method: 'POST',
|
||||
body: batch,
|
||||
keepalive,
|
||||
log: false
|
||||
})
|
||||
})
|
||||
|
||||
restoreClientLog()
|
||||
})
|
||||
|
|
@ -140,6 +140,38 @@
|
|||
<div class="settings-row__hint">{{ APP_VERSION }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section__divider" />
|
||||
|
||||
<!-- Protokoll -->
|
||||
<div class="settings-row">
|
||||
<div>
|
||||
<div class="settings-row__label">Protokoll</div>
|
||||
<div class="settings-row__hint">
|
||||
{{ logSummary }}
|
||||
{{ logsAreShipped ? 'Warnungen und Fehler werden an den Server übertragen.' : 'Bleibt in diesem Browser.' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row__control settings-log__actions">
|
||||
<q-btn flat dense no-caps size="sm" icon="content_copy" label="Kopieren" :disable="!logEntries.length" @click="copyLog" />
|
||||
<q-btn flat dense no-caps size="sm" icon="delete_sweep" label="Leeren" :disable="!logEntries.length" @click="clearLog" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-log" :class="{ 'settings-log--dark': isDark }">
|
||||
<div v-if="!recentLogEntries.length" class="settings-log__empty">Keine Einträge in dieser Sitzung.</div>
|
||||
<div
|
||||
v-for="entry in recentLogEntries"
|
||||
:key="entry.id"
|
||||
class="settings-log__entry"
|
||||
:class="`settings-log__entry--${entry.level}`"
|
||||
>
|
||||
<span class="settings-log__time">{{ formatLogTime(entry.lastAt) }}</span>
|
||||
<span class="settings-log__scope">{{ entry.scope }}</span>
|
||||
<span class="settings-log__message">
|
||||
{{ entry.message }}<span v-if="entry.count > 1" class="settings-log__count">×{{ entry.count }}</span>
|
||||
</span>
|
||||
<span v-if="entry.context" class="settings-log__context">{{ formatLogContext(entry.context) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCard>
|
||||
</template>
|
||||
|
|
@ -149,7 +181,9 @@ import { ref, computed } from 'vue'
|
|||
import { useQuasar } from 'quasar'
|
||||
import ModalCard from 'components/ModalCard.vue'
|
||||
import { useSettingsStore, ACCENT_COLORS } from 'stores/settings'
|
||||
import { useAuthStore } from 'stores/auth'
|
||||
import { APP_VERSION } from 'src/config/appVersion'
|
||||
import { clearClientLog, logEntries } from 'src/services/clientLog'
|
||||
|
||||
defineProps({ open: { type: Boolean, default: false } })
|
||||
defineEmits(['close'])
|
||||
|
|
@ -189,9 +223,123 @@ function selectAccent(value) {
|
|||
function onAppearanceChange(e) {
|
||||
settingsStore.appearance = e.target.value
|
||||
}
|
||||
|
||||
// --- Protokoll -------------------------------------------------------------
|
||||
const authStore = useAuthStore()
|
||||
const LOG_VIEW_LIMIT = 50
|
||||
|
||||
const logsAreShipped = computed(() => authStore.currentUser?.mode === 'remote')
|
||||
const recentLogEntries = computed(() => [...logEntries.value].slice(-LOG_VIEW_LIMIT).reverse())
|
||||
const logSummary = computed(() => {
|
||||
const errors = logEntries.value.filter(entry => entry.level === 'error').length
|
||||
const warnings = logEntries.value.filter(entry => entry.level === 'warning').length
|
||||
if (!errors && !warnings) return 'Keine Warnungen oder Fehler.'
|
||||
return `${errors} Fehler, ${warnings} Warnungen.`
|
||||
})
|
||||
|
||||
function formatLogTime(timestamp) {
|
||||
return new Date(timestamp).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
function formatLogContext(context) {
|
||||
return Object.entries(context)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(' · ')
|
||||
}
|
||||
|
||||
async function copyLog() {
|
||||
const text = JSON.stringify({
|
||||
appVersion: APP_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
entries: logEntries.value
|
||||
}, null, 2)
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
$q.notify({ type: 'positive', message: 'Protokoll kopiert.', timeout: 2000 })
|
||||
} catch {
|
||||
$q.notify({ type: 'warning', message: 'Zwischenablage nicht verfügbar.', timeout: 3000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function clearLog() {
|
||||
await clearClientLog()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-log__actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-log {
|
||||
margin-top: 10px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.settings-log--dark {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.settings-log__empty {
|
||||
padding: 10px 12px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.settings-log__entry {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
column-gap: 8px;
|
||||
padding: 5px 10px;
|
||||
border-left: 3px solid rgba(128, 128, 128, 0.35);
|
||||
}
|
||||
|
||||
.settings-log__entry + .settings-log__entry {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.settings-log__entry--warning {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.settings-log__entry--error {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.settings-log__time {
|
||||
opacity: 0.55;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-log__scope {
|
||||
opacity: 0.75;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-log__message {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.settings-log__count {
|
||||
margin-left: 6px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.settings-log__context {
|
||||
grid-column: 1 / -1;
|
||||
opacity: 0.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.settings-section__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
|
|
|
|||
|
|
@ -75,8 +75,8 @@
|
|||
<img :src="keyImageSrc" class="event-panel__image" alt="" />
|
||||
</template>
|
||||
<!-- Shown while the image of the newly opened event is loading -->
|
||||
<div v-else class="event-panel__image-skeleton" aria-hidden="true">
|
||||
<q-spinner size="28px" />
|
||||
<div v-else class="event-panel__image-skeleton">
|
||||
<LoadingDots size="8px" label="Bild wird geladen" />
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
|
|
@ -384,8 +384,18 @@
|
|||
playsinline
|
||||
preload="metadata"
|
||||
/>
|
||||
<div v-else class="event-panel__media-fallback">
|
||||
<q-icon :name="item.type === 'video' ? 'movie' : 'image'" size="28px" />
|
||||
<div v-else-if="mediaLoadState[item.id] === 'loading'" class="event-panel__media-fallback">
|
||||
<LoadingDots size="6px" label="Bild wird geladen" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="event-panel__media-fallback"
|
||||
:title="mediaLoadState[item.id] === 'failed' ? 'Bild konnte nicht geladen werden' : undefined"
|
||||
>
|
||||
<q-icon
|
||||
:name="mediaLoadState[item.id] === 'failed' ? 'broken_image' : (item.type === 'video' ? 'movie' : 'image')"
|
||||
size="28px"
|
||||
/>
|
||||
</div>
|
||||
<!-- Library-style type marker -->
|
||||
<span v-if="item.type === 'video'" class="event-panel__media-play" aria-hidden="true">
|
||||
|
|
@ -530,6 +540,7 @@ import { formatCalendarDate } from 'src/utils/calendarDate'
|
|||
import { EVENT_CATEGORIES, findEventCategory } from 'src/config/eventCategories'
|
||||
import ConfirmDialog from 'components/ConfirmDialog.vue'
|
||||
import VoiceNotesCard from 'components/VoiceNotesCard.vue'
|
||||
import LoadingDots from 'components/LoadingDots.vue'
|
||||
import { formatDuration } from 'composables/useVoiceRecorder'
|
||||
|
||||
const $q = useQuasar()
|
||||
|
|
@ -675,6 +686,8 @@ const mediaGalleryOpen = ref(false)
|
|||
const mediaGalleryIndex = ref(0)
|
||||
// Poster / image sources per item (resolved through the image cache).
|
||||
const mediaSrcById = ref({})
|
||||
// 'loading' while the source is fetched, 'failed' when it could not be.
|
||||
const mediaLoadState = ref({})
|
||||
// Full video sources per remote item, fetched only when the gallery shows them.
|
||||
const videoSrcById = ref({})
|
||||
|
||||
|
|
@ -734,6 +747,11 @@ watch(
|
|||
// For videos this is the poster frame; a video without one has no image
|
||||
// source and the tile falls back to an icon or its own first frame.
|
||||
const pending = items.filter(item => !mediaSrcById.value[item.id])
|
||||
mediaLoadState.value = Object.fromEntries(
|
||||
pending
|
||||
.filter(item => item.blob ? item.type !== 'video' : Boolean(item.previewUrl || item.thumbnailUrl || item.src))
|
||||
.map(item => [item.id, 'loading'])
|
||||
)
|
||||
const entries = await Promise.all(pending.map(async (item) => [
|
||||
item.id,
|
||||
item.blob && item.type !== 'video'
|
||||
|
|
@ -745,6 +763,11 @@ watch(
|
|||
...mediaSrcById.value,
|
||||
...Object.fromEntries(entries.filter(([, src]) => Boolean(src)))
|
||||
}
|
||||
mediaLoadState.value = Object.fromEntries(
|
||||
entries
|
||||
.filter(([id, src]) => !src && mediaLoadState.value[id] === 'loading')
|
||||
.map(([id]) => [id, 'failed'])
|
||||
)
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@
|
|||
class="glow-dot__image"
|
||||
alt=""
|
||||
/>
|
||||
<!-- The photo is on its way: pulsing dots instead of a blank disc -->
|
||||
<span v-else-if="imageLoading" class="glow-dot__loading">
|
||||
<LoadingDots :size="loadingDotSize" label="Bild wird geladen" />
|
||||
</span>
|
||||
</div>
|
||||
<!-- An event without a title still needs a label; the date stands in -->
|
||||
<div v-if="!isGhost" class="glow-dot__label" :style="labelStyle">
|
||||
|
|
@ -38,6 +42,7 @@ import { computed } from 'vue'
|
|||
import { useEventsStore } from 'stores/events'
|
||||
import { useSettingsStore } from 'stores/settings'
|
||||
import { useImageCache } from 'composables/useImageCache'
|
||||
import LoadingDots from 'components/LoadingDots.vue'
|
||||
import { emotionToYPercent, isLowerHalf } from 'src/utils/timelinePosition'
|
||||
import { findEventCategory } from 'src/config/eventCategories'
|
||||
|
||||
|
|
@ -55,7 +60,7 @@ const settingsStore = useSettingsStore()
|
|||
// Resolve image: cached thumbnail from IndexedDB or fetch & cache
|
||||
const imageUrl = computed(() => props.event.image || null)
|
||||
const eventId = computed(() => props.event.id)
|
||||
const { resolvedSrc: imageSrc } = useImageCache(imageUrl, eventId)
|
||||
const { resolvedSrc: imageSrc, loading: imageLoading } = useImageCache(imageUrl, eventId)
|
||||
|
||||
const fl = computed(() => settingsStore.floatingLines)
|
||||
const glowColor = computed(() => eventsStore.getGlowColor(props.event))
|
||||
|
|
@ -63,6 +68,8 @@ const glowColor = computed(() => eventsStore.getGlowColor(props.event))
|
|||
// CSS diameter derived from settings (circleRadius is half-size in px)
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const dotSize = computed(() => 2 * fl.value.circleRadius / dpr)
|
||||
// Three dots have to fit inside the disc with room to spare.
|
||||
const loadingDotSize = computed(() => `${Math.max(2, Math.min(5, Math.round(dotSize.value / 8)))}px`)
|
||||
|
||||
// Y position from emotion; shared with the shader points, see utils/timelinePosition
|
||||
const yPercent = computed(() => emotionToYPercent(props.event.emotion))
|
||||
|
|
@ -195,6 +202,15 @@ function onSelect() {
|
|||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.glow-dot__loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* States */
|
||||
.glow-dot--ghost {
|
||||
opacity: 1;
|
||||
|
|
|
|||
59
frontend/src/components/LoadingDots.vue
Normal file
59
frontend/src/components/LoadingDots.vue
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<template>
|
||||
<span
|
||||
class="loading-dots"
|
||||
:style="{ '--dot-size': size }"
|
||||
role="status"
|
||||
:aria-label="label"
|
||||
>
|
||||
<i /><i /><i />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* Three pulsing dots for spots where an image or a file is on its way and a
|
||||
* blank frame would look broken. Uses the current text colour.
|
||||
*/
|
||||
defineProps({
|
||||
size: { type: String, default: '6px' },
|
||||
label: { type: String, default: 'Wird geladen' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--dot-size) * 0.8);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.loading-dots i {
|
||||
width: var(--dot-size);
|
||||
height: var(--dot-size);
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.25;
|
||||
animation: loading-dots-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-dots i:nth-child(2) {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
|
||||
.loading-dots i:nth-child(3) {
|
||||
animation-delay: 0.36s;
|
||||
}
|
||||
|
||||
@keyframes loading-dots-pulse {
|
||||
0%, 80%, 100% { opacity: 0.25; transform: scale(0.85); }
|
||||
40% { opacity: 0.9; transform: scale(1); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.loading-dots i {
|
||||
animation: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -113,6 +113,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|||
import { useQuasar } from 'quasar'
|
||||
import { useEventsStore } from 'stores/events'
|
||||
import { resolveFullRes } from 'composables/useImageCache'
|
||||
import { clientLog } from 'src/services/clientLog'
|
||||
import {
|
||||
MAX_VOICE_NOTE_MS,
|
||||
extensionForAudioType,
|
||||
|
|
@ -237,7 +238,7 @@ async function saveRecording({ blob, mimeType, durationMs }) {
|
|||
})
|
||||
eventsStore.saveGhostNow()
|
||||
} catch (error) {
|
||||
console.warn('Voice note save failed:', error)
|
||||
clientLog.warn('voice', 'Voice note save failed', error)
|
||||
$q.notify({ type: 'negative', message: 'Die Sprachnotiz konnte nicht gespeichert werden.' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
|
|
@ -267,7 +268,7 @@ async function confirmDelete() {
|
|||
await eventsStore.deleteGhostVoiceNote(note.id)
|
||||
eventsStore.saveGhostNow()
|
||||
} catch (error) {
|
||||
console.warn('Voice note delete failed:', error)
|
||||
clientLog.warn('voice', 'Voice note delete failed', error)
|
||||
$q.notify({ type: 'negative', message: 'Die Sprachnotiz konnte nicht gelöscht werden.' })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import { ref, unref, watch } from 'vue'
|
||||
import { db } from 'src/db'
|
||||
import { API_BASE, apiFetch } from 'src/services/apiClient'
|
||||
import { API_BASE, apiFetch, retryAfterMs } from 'src/services/apiClient'
|
||||
import { clientLog } from 'src/services/clientLog'
|
||||
|
||||
const THUMB_SIZE = 200
|
||||
|
||||
// How many protected images may be in flight at once. A long timeline asks
|
||||
// for one thumbnail per visible event; without a cap they all hit the API in
|
||||
// the same instant.
|
||||
const MAX_PARALLEL_FETCHES = 6
|
||||
// Attempts per image before the caller is told it failed.
|
||||
const MAX_ATTEMPTS = 3
|
||||
const RETRY_BASE_MS = 1500
|
||||
|
||||
// In-memory cache: avoids repeated IndexedDB reads and blob URL creation.
|
||||
// Shared across all component instances, therefore scoped by user — the URL
|
||||
// alone is not a safe key, because two users' images can share a path and a
|
||||
|
|
@ -30,7 +39,7 @@ export function setImageCacheScope(userId) {
|
|||
.where('userId')
|
||||
.equals(previousScope)
|
||||
.delete()
|
||||
.catch(e => console.warn('Image cache purge failed:', e))
|
||||
.catch(e => clientLog.warn('image', 'Bild-Cache konnte nicht geleert werden', e))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,10 +59,112 @@ function cacheKey(imageUrl) {
|
|||
return `${API_BASE}|${scopeUserId ?? 'anonymous'}|${imageUrl}`
|
||||
}
|
||||
|
||||
function isProtectedApiImageUrl(imageUrl) {
|
||||
return imageUrl.startsWith('/event-media/') || imageUrl.startsWith('/settings/media/')
|
||||
export function isProtectedApiImageUrl(imageUrl) {
|
||||
return typeof imageUrl === 'string'
|
||||
&& (imageUrl.startsWith('/event-media/') || imageUrl.startsWith('/settings/media/'))
|
||||
}
|
||||
|
||||
// --- Request gate ----------------------------------------------------------
|
||||
|
||||
let inFlight = 0
|
||||
const waiting = []
|
||||
// After a 429 every image request waits until this time before continuing.
|
||||
let pausedUntil = 0
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function acquireSlot() {
|
||||
if (inFlight < MAX_PARALLEL_FETCHES) {
|
||||
inFlight += 1
|
||||
return
|
||||
}
|
||||
await new Promise(resolve => waiting.push(resolve))
|
||||
inFlight += 1
|
||||
}
|
||||
|
||||
function releaseSlot() {
|
||||
inFlight -= 1
|
||||
const next = waiting.shift()
|
||||
if (next) next()
|
||||
}
|
||||
|
||||
class ImageFetchError extends Error {
|
||||
constructor(status, retryAfter = null, cause = null) {
|
||||
super(cause?.message ? `Fetch failed: ${cause.message}` : `Fetch failed: ${status}`)
|
||||
this.name = 'ImageFetchError'
|
||||
this.status = status
|
||||
this.retryAfterMs = retryAfter
|
||||
this.cause = cause
|
||||
}
|
||||
}
|
||||
|
||||
function isTransientStatus(status) {
|
||||
return status === 0 || status === 408 || status === 429 || status >= 500
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one image through the gate: bounded concurrency, and after a 429
|
||||
* everyone waits for the Retry-After before the next attempt.
|
||||
*/
|
||||
async function fetchImage(imageUrl) {
|
||||
const protectedUrl = isProtectedApiImageUrl(imageUrl)
|
||||
if (!protectedUrl) {
|
||||
const response = await fetch(imageUrl)
|
||||
if (!response.ok) throw new ImageFetchError(response.status)
|
||||
return response
|
||||
}
|
||||
|
||||
await acquireSlot()
|
||||
try {
|
||||
const wait = pausedUntil - Date.now()
|
||||
if (wait > 0) await sleep(wait)
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await apiFetch(imageUrl, { log: false })
|
||||
} catch (error) {
|
||||
throw new ImageFetchError(0, null, error)
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
const retryAfter = retryAfterMs(response) ?? RETRY_BASE_MS * 2
|
||||
pausedUntil = Math.max(pausedUntil, Date.now() + retryAfter)
|
||||
throw new ImageFetchError(429, retryAfter)
|
||||
}
|
||||
if (!response.ok) throw new ImageFetchError(response.status)
|
||||
return response
|
||||
} finally {
|
||||
releaseSlot()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `fetchImage` with retries for the temporary failures. Logs once per
|
||||
* image when it gives up.
|
||||
*/
|
||||
async function fetchImageWithRetry(imageUrl) {
|
||||
let lastError = null
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await fetchImage(imageUrl)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (!isTransientStatus(error.status ?? 0) || attempt === MAX_ATTEMPTS) break
|
||||
await sleep(Math.max(error.retryAfterMs ?? 0, RETRY_BASE_MS * attempt))
|
||||
}
|
||||
}
|
||||
|
||||
clientLog.warn('image', `Bild konnte nicht geladen werden (${lastError?.status || 'keine Antwort'})`, {
|
||||
status: lastError?.status ?? 0,
|
||||
url: imageUrl.split('?')[0]
|
||||
})
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// --- Thumbnails ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a thumbnail (THUMB_SIZE x THUMB_SIZE) from a source image blob.
|
||||
* Returns a new Blob (JPEG, quality 0.8).
|
||||
|
|
@ -98,10 +209,7 @@ function createThumbnail(blob) {
|
|||
* Fetch an image from URL, cache thumbnail in IndexedDB, return blob URL.
|
||||
*/
|
||||
async function fetchAndCache(imageUrl, eventId) {
|
||||
const response = isProtectedApiImageUrl(imageUrl)
|
||||
? await apiFetch(imageUrl)
|
||||
: await fetch(imageUrl)
|
||||
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
|
||||
const response = await fetchImageWithRetry(imageUrl)
|
||||
const blob = await response.blob()
|
||||
|
||||
// Create thumbnail
|
||||
|
|
@ -142,7 +250,7 @@ async function getCachedImage(imageUrl) {
|
|||
return blobUrl
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Image cache read failed:', e)
|
||||
clientLog.warn('image', 'Bild-Cache konnte nicht gelesen werden', e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
@ -150,15 +258,20 @@ async function getCachedImage(imageUrl) {
|
|||
/**
|
||||
* Composable: resolves an event's image to a displayable src.
|
||||
* - Checks memory cache → IndexedDB cache → fetches & caches thumbnail.
|
||||
* - Returns reactive `resolvedSrc` ref.
|
||||
* - `resolvedSrc` is null while loading and after a failure, so the
|
||||
* template can show a placeholder instead of a broken image.
|
||||
* - `loading` is true from the first look-up until the answer, `failed`
|
||||
* once every attempt is used up.
|
||||
*/
|
||||
export function useImageCache(imageUrl, eventId) {
|
||||
const resolvedSrc = ref(null)
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
let requestId = 0
|
||||
|
||||
async function resolve(nextUrl = unref(imageUrl)) {
|
||||
const currentRequestId = ++requestId
|
||||
failed.value = false
|
||||
|
||||
if (!nextUrl) {
|
||||
resolvedSrc.value = null
|
||||
|
|
@ -174,26 +287,34 @@ export function useImageCache(imageUrl, eventId) {
|
|||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
// 2. IndexedDB cache
|
||||
const cached = await getCachedImage(nextUrl)
|
||||
if (currentRequestId !== requestId) return
|
||||
if (cached) {
|
||||
if (currentRequestId !== requestId) return
|
||||
resolvedSrc.value = cached
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Fetch, create thumbnail, cache
|
||||
loading.value = true
|
||||
try {
|
||||
const blobUrl = await fetchAndCache(nextUrl, unref(eventId))
|
||||
if (currentRequestId !== requestId) return
|
||||
resolvedSrc.value = blobUrl
|
||||
} catch (e) {
|
||||
// Fallback: use original URL directly (works when online)
|
||||
console.warn('Image cache failed, using direct URL:', e)
|
||||
if (currentRequestId !== requestId) return
|
||||
resolvedSrc.value = nextUrl
|
||||
if (isProtectedApiImageUrl(nextUrl)) {
|
||||
// The path alone is useless as an <img> src (it needs the token),
|
||||
// so the tile stays empty rather than showing a broken image.
|
||||
resolvedSrc.value = null
|
||||
failed.value = true
|
||||
} else {
|
||||
// Public URL: let the browser try directly.
|
||||
clientLog.warn('image', 'Bild-Cache übersprungen, direkte URL wird verwendet', e)
|
||||
resolvedSrc.value = nextUrl
|
||||
}
|
||||
} finally {
|
||||
if (currentRequestId === requestId) loading.value = false
|
||||
}
|
||||
|
|
@ -203,13 +324,15 @@ export function useImageCache(imageUrl, eventId) {
|
|||
resolve(nextUrl)
|
||||
}, { immediate: true })
|
||||
|
||||
return { resolvedSrc, loading }
|
||||
return { resolvedSrc, loading, failed, retry: () => resolve() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve full-res image for EventPanel (no thumbnail, just cache check).
|
||||
* Returns the original URL — browser Cache-Control handles caching.
|
||||
* When offline, falls back to cached thumbnail.
|
||||
* Returns a blob URL for protected images and the original URL for public
|
||||
* ones. When the image cannot be fetched it falls back to the cached
|
||||
* thumbnail, and to null when there is none — never to a path the
|
||||
* <img> could not load anyway.
|
||||
*/
|
||||
export async function resolveFullRes(imageUrl) {
|
||||
if (!imageUrl) return null
|
||||
|
|
@ -219,13 +342,12 @@ export async function resolveFullRes(imageUrl) {
|
|||
if (memoryCache.has(key)) return memoryCache.get(key)
|
||||
|
||||
try {
|
||||
const response = await apiFetch(imageUrl)
|
||||
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
|
||||
const response = await fetchImageWithRetry(imageUrl)
|
||||
const blobUrl = URL.createObjectURL(await response.blob())
|
||||
memoryCache.set(key, blobUrl)
|
||||
return blobUrl
|
||||
} catch (error) {
|
||||
console.warn('Full-res image load failed:', error)
|
||||
} catch {
|
||||
return await getCachedImage(imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -251,6 +373,12 @@ export async function clearEventImages(eventId) {
|
|||
}
|
||||
await db.scopedImageCache.where('eventId').equals(eventId).delete()
|
||||
} catch (e) {
|
||||
console.warn('Clear event images failed:', e)
|
||||
clientLog.warn('image', 'Bild-Cache des Events konnte nicht geleert werden', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Test hook: forget every cached blob and the 429 pause. */
|
||||
export function resetImageCacheForTests() {
|
||||
purgeMemoryCache()
|
||||
pausedUntil = 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,3 +45,14 @@ db.version(4).stores({
|
|||
eventMedia: 'id, eventId, userId, createdAt',
|
||||
meta: 'key'
|
||||
})
|
||||
|
||||
db.version(5).stores({
|
||||
events: 'id, userId, [userId+date], updatedAt, syncStatus',
|
||||
syncQueue: '++queueId, userId, eventId, action, createdAt',
|
||||
scopedImageCache: 'key, userId, eventId, type, cachedAt',
|
||||
eventMedia: 'id, eventId, userId, createdAt',
|
||||
// Warnings and errors from the browser, kept until they were shipped to
|
||||
// the server and for the developer view (see services/clientLog.js).
|
||||
clientLogs: 'id, occurredAt, shipped',
|
||||
meta: 'key'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { db } from 'src/db'
|
||||
import { clientLog } from 'src/services/clientLog'
|
||||
|
||||
function normalizeApiBase(base) {
|
||||
const trimmedBase = String(base).replace(/\/+$/, '')
|
||||
|
|
@ -33,11 +34,12 @@ function resolveApiBase() {
|
|||
export const API_BASE = resolveApiBase()
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message, { status = 0, errors = null } = {}) {
|
||||
constructor(message, { status = 0, errors = null, retryAfterMs = null } = {}) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.errors = errors
|
||||
this.retryAfterMs = retryAfterMs
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -103,8 +105,32 @@ async function readResponsePayload(response) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the Retry-After header of a 429/503 as milliseconds, or null.
|
||||
*/
|
||||
export function retryAfterMs(response) {
|
||||
const header = response?.headers?.get?.('Retry-After')
|
||||
if (!header) return null
|
||||
const seconds = Number(header)
|
||||
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
|
||||
const at = Date.parse(header)
|
||||
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null
|
||||
}
|
||||
|
||||
function logFailedResponse(path, options, response, payload) {
|
||||
// 401 ends the session elsewhere and is not worth a log line per request.
|
||||
if (response.status === 401) return
|
||||
const method = options.method || 'GET'
|
||||
const level = response.status === 429 || response.status >= 500 ? 'error' : 'warning'
|
||||
clientLog[level === 'error' ? 'error' : 'warn']('api', `${method} ${path.split('?')[0]} → ${response.status}`, {
|
||||
status: response.status,
|
||||
message: typeof payload?.message === 'string' ? payload.message : undefined,
|
||||
retryAfter: retryAfterMs(response) ?? undefined
|
||||
})
|
||||
}
|
||||
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const { auth = true, body, headers = {}, ...fetchOptions } = options
|
||||
const { auth = true, body, headers = {}, log = true, ...fetchOptions } = options
|
||||
const token = auth ? await getToken() : null
|
||||
|
||||
if (auth && !token) {
|
||||
|
|
@ -122,16 +148,29 @@ export async function apiFetch(path, options = {}) {
|
|||
console.debug('API request:', requestUrl)
|
||||
}
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
...fetchOptions,
|
||||
body: body && !isFormData && typeof body !== 'string' ? JSON.stringify(body) : body,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(!isFormData ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...headers
|
||||
let response
|
||||
try {
|
||||
response = await fetch(requestUrl, {
|
||||
...fetchOptions,
|
||||
body: body && !isFormData && typeof body !== 'string' ? JSON.stringify(body) : body,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(!isFormData ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...headers
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// No response at all: offline, DNS, CORS, aborted. The caller decides
|
||||
// whether to retry; here it is only recorded.
|
||||
if (log) {
|
||||
clientLog.warn('api', `${fetchOptions.method || 'GET'} ${path.split('?')[0]} → keine Antwort`, {
|
||||
error: error?.message,
|
||||
online: typeof navigator !== 'undefined' ? navigator.onLine : undefined
|
||||
})
|
||||
}
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
if (response.status === 401 && auth) {
|
||||
await handleUnauthorized(token)
|
||||
|
|
@ -145,9 +184,11 @@ export async function apiJson(path, options = {}) {
|
|||
const payload = await readResponsePayload(response)
|
||||
|
||||
if (!response.ok) {
|
||||
if (options.log !== false) logFailedResponse(path, options, response, payload)
|
||||
throw new ApiError(payload?.message || 'Die API-Anfrage ist fehlgeschlagen.', {
|
||||
status: response.status,
|
||||
errors: payload?.errors ?? null
|
||||
errors: payload?.errors ?? null,
|
||||
retryAfterMs: retryAfterMs(response)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
269
frontend/src/services/clientLog.js
Normal file
269
frontend/src/services/clientLog.js
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { ref } from 'vue'
|
||||
import { db } from 'src/db'
|
||||
|
||||
/**
|
||||
* Browser-side log: warnings and errors the app runs into, kept in a ring
|
||||
* buffer for the developer view, mirrored to IndexedDB so they survive a
|
||||
* reload, and shipped in batches to POST /client-logs once a transport is
|
||||
* wired up (see boot/errorLog.js). Info entries stay local.
|
||||
*
|
||||
* Keep this module free of store imports: apiClient logs through it, and the
|
||||
* stores import apiClient.
|
||||
*/
|
||||
|
||||
export const LOG_LEVELS = ['info', 'warning', 'error']
|
||||
const SHIPPED_LEVELS = new Set(['warning', 'error'])
|
||||
|
||||
const MEMORY_LIMIT = 200
|
||||
const STORED_LIMIT = 300
|
||||
const SHIP_BATCH_SIZE = 50
|
||||
const SHIP_DELAY_MS = 5000
|
||||
const SHIP_RETRY_MS = 30000
|
||||
// The same message from the same place within this window bumps a counter
|
||||
// instead of adding a line: one scroll through a long timeline can fail a
|
||||
// hundred thumbnails for the same reason.
|
||||
const DEDUPE_WINDOW_MS = 10000
|
||||
|
||||
export const SESSION_ID = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
|
||||
/** Most recent entries, newest last. Reactive for the developer view. */
|
||||
export const logEntries = ref([])
|
||||
|
||||
let nextId = 1
|
||||
let transport = null
|
||||
let shipTimer = null
|
||||
let shipping = false
|
||||
let shipBlocked = false
|
||||
let storageReady = false
|
||||
|
||||
function currentUrl() {
|
||||
if (typeof window === 'undefined') return null
|
||||
return `${window.location.pathname}${window.location.hash}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens whatever the caller hands over (an Error, an ApiError, a plain
|
||||
* object) into one level of scalars the server accepts.
|
||||
*/
|
||||
export function normalizeContext(detail) {
|
||||
if (detail === undefined || detail === null) return null
|
||||
|
||||
if (detail instanceof Error || (typeof detail === 'object' && 'message' in detail && 'stack' in detail)) {
|
||||
return compactContext({
|
||||
error: detail.name && detail.name !== 'Error' ? `${detail.name}: ${detail.message}` : detail.message,
|
||||
status: detail.status,
|
||||
stack: typeof detail.stack === 'string' ? detail.stack.split('\n').slice(0, 4).join(' | ') : undefined
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof detail !== 'object') return { detail: String(detail) }
|
||||
|
||||
return compactContext(Object.fromEntries(
|
||||
Object.entries(detail).map(([key, value]) => [key, scalar(value)])
|
||||
))
|
||||
}
|
||||
|
||||
function scalar(value) {
|
||||
if (value === null || value === undefined) return undefined
|
||||
if (typeof value === 'string') return value.length > 500 ? `${value.slice(0, 497)}…` : value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return value
|
||||
if (value instanceof Error) return value.message
|
||||
try {
|
||||
const json = JSON.stringify(value)
|
||||
return json.length > 500 ? `${json.slice(0, 497)}…` : json
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function compactContext(context) {
|
||||
const entries = Object.entries(context).filter(([, value]) => value !== undefined && value !== '')
|
||||
return entries.length ? Object.fromEntries(entries.slice(0, 20)) : null
|
||||
}
|
||||
|
||||
function sameEntry(a, b) {
|
||||
return a.level === b.level && a.scope === b.scope && a.message === b.message
|
||||
}
|
||||
|
||||
/**
|
||||
* Records one entry. Returns it (or the entry whose counter it bumped).
|
||||
*/
|
||||
export function logEvent(level, scope, message, detail) {
|
||||
if (!LOG_LEVELS.includes(level)) level = 'warning'
|
||||
|
||||
const now = Date.now()
|
||||
const entries = logEntries.value
|
||||
const last = entries[entries.length - 1]
|
||||
|
||||
if (last && sameEntry(last, { level, scope, message }) && now - last.lastAt < DEDUPE_WINDOW_MS) {
|
||||
last.count += 1
|
||||
last.lastAt = now
|
||||
logEntries.value = [...entries]
|
||||
persistEntry(last).catch(() => {})
|
||||
return last
|
||||
}
|
||||
|
||||
const entry = {
|
||||
id: nextId++,
|
||||
level,
|
||||
scope,
|
||||
message: String(message),
|
||||
context: normalizeContext(detail),
|
||||
url: currentUrl(),
|
||||
occurredAt: now,
|
||||
lastAt: now,
|
||||
count: 1,
|
||||
shipped: SHIPPED_LEVELS.has(level) ? 0 : 1
|
||||
}
|
||||
|
||||
logEntries.value = [...entries, entry].slice(-MEMORY_LIMIT)
|
||||
persistEntry(entry).catch(() => {})
|
||||
|
||||
if (!entry.shipped) scheduleShip()
|
||||
return entry
|
||||
}
|
||||
|
||||
export const clientLog = {
|
||||
info: (scope, message, detail) => logEvent('info', scope, message, detail),
|
||||
warn: (scope, message, detail) => logEvent('warning', scope, message, detail),
|
||||
error: (scope, message, detail) => logEvent('error', scope, message, detail)
|
||||
}
|
||||
|
||||
// --- IndexedDB mirror ------------------------------------------------------
|
||||
|
||||
async function persistEntry(entry) {
|
||||
if (!db.clientLogs) return
|
||||
await db.clientLogs.put({ ...entry, context: entry.context ?? null })
|
||||
|
||||
if (entry.count === 1) {
|
||||
const total = await db.clientLogs.count()
|
||||
if (total > STORED_LIMIT) {
|
||||
const stale = await db.clientLogs.orderBy('occurredAt').limit(total - STORED_LIMIT).primaryKeys()
|
||||
await db.clientLogs.bulkDelete(stale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads what an earlier session left behind, so a crash before the batch
|
||||
* went out still reaches the server, and the developer view shows it.
|
||||
*/
|
||||
export async function restoreClientLog() {
|
||||
if (storageReady || !db.clientLogs) return
|
||||
storageReady = true
|
||||
|
||||
try {
|
||||
const stored = await db.clientLogs.orderBy('occurredAt').toArray()
|
||||
if (!stored.length) return
|
||||
|
||||
const current = logEntries.value
|
||||
const known = new Set(current.map(entry => entry.id))
|
||||
nextId = Math.max(nextId, ...stored.map(entry => entry.id + 1))
|
||||
logEntries.value = [...stored.filter(entry => !known.has(entry.id)), ...current]
|
||||
.sort((a, b) => a.occurredAt - b.occurredAt)
|
||||
.slice(-MEMORY_LIMIT)
|
||||
|
||||
if (stored.some(entry => !entry.shipped)) scheduleShip()
|
||||
} catch (error) {
|
||||
console.warn('Client log restore failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearClientLog() {
|
||||
logEntries.value = []
|
||||
if (db.clientLogs) await db.clientLogs.clear().catch(() => {})
|
||||
}
|
||||
|
||||
// --- Shipping --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wires the sender. `enabled()` says whether this user's entries may leave
|
||||
* the browser (remote accounts only); `send(batch)` performs the request.
|
||||
*/
|
||||
export function setClientLogTransport(next) {
|
||||
transport = next
|
||||
if (transport) scheduleShip()
|
||||
}
|
||||
|
||||
function scheduleShip(delay = SHIP_DELAY_MS) {
|
||||
if (!transport || shipTimer) return
|
||||
shipTimer = setTimeout(() => {
|
||||
shipTimer = null
|
||||
shipClientLog().catch(() => {})
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends everything not yet shipped. Failures are silent and retried later;
|
||||
* a log that logs its own failures never stops.
|
||||
*/
|
||||
export async function shipClientLog({ keepalive = false } = {}) {
|
||||
if (!transport || shipping || shipBlocked) return false
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) return false
|
||||
|
||||
let enabled = false
|
||||
try {
|
||||
enabled = Boolean(transport.enabled())
|
||||
} catch {
|
||||
enabled = false
|
||||
}
|
||||
if (!enabled) return false
|
||||
|
||||
const pending = logEntries.value.filter(entry => !entry.shipped).slice(0, SHIP_BATCH_SIZE)
|
||||
if (!pending.length) return false
|
||||
|
||||
shipping = true
|
||||
try {
|
||||
await transport.send({
|
||||
appVersion: transport.appVersion ?? null,
|
||||
sessionId: SESSION_ID,
|
||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent.slice(0, 400) : null,
|
||||
entries: pending.map(entry => ({
|
||||
level: entry.level,
|
||||
scope: entry.scope,
|
||||
message: entry.count > 1 ? `${entry.message} (×${entry.count})` : entry.message,
|
||||
url: entry.url,
|
||||
occurredAt: new Date(entry.lastAt).toISOString(),
|
||||
context: entry.context
|
||||
}))
|
||||
}, { keepalive })
|
||||
|
||||
for (const entry of pending) entry.shipped = 1
|
||||
logEntries.value = [...logEntries.value]
|
||||
if (db.clientLogs) {
|
||||
await db.clientLogs.bulkPut(pending.map(entry => ({ ...entry }))).catch(() => {})
|
||||
}
|
||||
|
||||
if (logEntries.value.some(entry => !entry.shipped)) scheduleShip(0)
|
||||
return true
|
||||
} catch (error) {
|
||||
// 401/403/422: the server will not take these, stop trying this session.
|
||||
if ([401, 403, 422].includes(error?.status)) shipBlocked = true
|
||||
else scheduleShip(SHIP_RETRY_MS)
|
||||
return false
|
||||
} finally {
|
||||
shipping = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Test hook: forget the transport and any scheduled batch. */
|
||||
export function resetClientLogForTests() {
|
||||
if (shipTimer) clearTimeout(shipTimer)
|
||||
shipTimer = null
|
||||
shipping = false
|
||||
shipBlocked = false
|
||||
transport = null
|
||||
storageReady = false
|
||||
logEntries.value = []
|
||||
nextId = 1
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', () => scheduleShip(0))
|
||||
// A batch still waiting when the tab closes goes out with keepalive.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') shipClientLog({ keepalive: true }).catch(() => {})
|
||||
})
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { ref } from 'vue'
|
||||
import { db } from 'src/db'
|
||||
import { apiJson } from 'src/services/apiClient'
|
||||
import { clientLog } from 'src/services/clientLog'
|
||||
|
||||
/**
|
||||
* Outbound write queue for remote users.
|
||||
|
|
@ -161,7 +162,7 @@ export function createSyncQueue({ getUserId, onApplied, onRejected, onBlocked })
|
|||
const userId = currentUserId()
|
||||
pendingCount.value = userId ? await db.syncQueue.where('userId').equals(userId).count() : 0
|
||||
} catch (error) {
|
||||
console.warn('Sync queue count failed:', error)
|
||||
clientLog.warn('sync', 'Sync queue count failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -355,7 +356,7 @@ export function createSyncQueue({ getUserId, onApplied, onRejected, onBlocked })
|
|||
try {
|
||||
await onRejected(item, error)
|
||||
} catch (handlerError) {
|
||||
console.warn('Sync rejection handler failed:', handlerError)
|
||||
clientLog.warn('sync', 'Sync rejection handler failed', handlerError)
|
||||
}
|
||||
}
|
||||
continue
|
||||
|
|
@ -371,12 +372,12 @@ export function createSyncQueue({ getUserId, onApplied, onRejected, onBlocked })
|
|||
try {
|
||||
await onApplied(item, payload)
|
||||
} catch (handlerError) {
|
||||
console.warn('Sync apply handler failed:', handlerError)
|
||||
clientLog.warn('sync', 'Sync apply handler failed', handlerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Sync queue processing failed:', error)
|
||||
clientLog.warn('sync', 'Sync queue processing failed', error)
|
||||
inFlightId = null
|
||||
scheduleRetry()
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||
import { computed, ref } from 'vue'
|
||||
import { clearToken, getToken, loginRemote, onUnauthorized, revokeToken, setToken } from 'src/services/apiClient'
|
||||
import { setImageCacheScope } from 'src/composables/useImageCache'
|
||||
import { clientLog } from 'src/services/clientLog'
|
||||
|
||||
export const AUTH_STORAGE_KEY = 'thatsme-auth'
|
||||
|
||||
|
|
@ -156,7 +157,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('Remote login failed:', error)
|
||||
clientLog.warn('auth', 'Remote login failed', error)
|
||||
lastError.value = error?.status === 429
|
||||
? 'Zu viele Fehlversuche. Bitte kurz warten und erneut versuchen.'
|
||||
: error?.status === 422
|
||||
|
|
@ -212,7 +213,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
try {
|
||||
await revokeToken(token)
|
||||
} catch (error) {
|
||||
console.warn('Token revoke failed:', error)
|
||||
clientLog.warn('auth', 'Token revoke failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { createSyncQueue, isLocalMediaId, isOnline, isSyncing, newLocalMediaId,
|
|||
import { useSettingsStore, DEFAULT_EMOTION_GRADIENT_START, DEFAULT_EMOTION_GRADIENT_END } from 'stores/settings'
|
||||
import { useAuthStore } from 'stores/auth'
|
||||
import { compareCalendarDates } from 'src/utils/calendarDate'
|
||||
import { clientLog } from 'src/services/clientLog'
|
||||
|
||||
// Color interpolation
|
||||
function lerpColor(a, b, t) {
|
||||
|
|
@ -342,7 +343,7 @@ export const useEventsStore = defineStore('events', () => {
|
|||
return added
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Sync queue write failed:', error)
|
||||
clientLog.warn('events', 'Sync queue write failed', error)
|
||||
lastError.value = 'Die Änderung konnte nicht zwischengespeichert werden.'
|
||||
if (editingEventId.value === entry.eventId) saveState.value = 'error'
|
||||
return false
|
||||
|
|
@ -467,7 +468,7 @@ export const useEventsStore = defineStore('events', () => {
|
|||
const userId = events.value[idx]?.userId ?? item.userId
|
||||
db.eventMedia.delete(localId)
|
||||
.then(() => db.eventMedia.put({ ...merged, eventId, userId }))
|
||||
.catch(e => console.warn('Dexie media replace failed:', e))
|
||||
.catch(e => clientLog.warn('events', 'Dexie media replace failed', e))
|
||||
|
||||
if (editingEventId.value === eventId) {
|
||||
withPersistSuppressed(() => {
|
||||
|
|
@ -487,7 +488,7 @@ export const useEventsStore = defineStore('events', () => {
|
|||
}
|
||||
|
||||
async function applyQueueRejection(item, error) {
|
||||
console.warn('Sync item rejected:', item.action, item.eventId, error)
|
||||
clientLog.warn('events', `Sync item rejected: ${item.action} ${item.eventId}`, error)
|
||||
|
||||
switch (item.action) {
|
||||
case 'create':
|
||||
|
|
@ -631,7 +632,7 @@ export const useEventsStore = defineStore('events', () => {
|
|||
if (isStaleSession(session)) return
|
||||
events.value = stored
|
||||
} catch (e) {
|
||||
console.warn('Event load failed:', e)
|
||||
clientLog.warn('events', 'Event load failed', e)
|
||||
if (isStaleSession(session)) return
|
||||
|
||||
// A failed load is not an empty account: fall back to whatever is
|
||||
|
|
@ -650,7 +651,7 @@ export const useEventsStore = defineStore('events', () => {
|
|||
.between([userId, Dexie.minKey], [userId, Dexie.maxKey])
|
||||
.toArray()
|
||||
} catch (e) {
|
||||
console.warn('Dexie cache read failed:', e)
|
||||
clientLog.warn('events', 'Dexie cache read failed', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
|
@ -660,7 +661,7 @@ export const useEventsStore = defineStore('events', () => {
|
|||
// nested in `media`) cannot be structured-cloned by IndexedDB.
|
||||
function dbPut(event) {
|
||||
const row = JSON.parse(JSON.stringify({ ...toRaw(event), userId: event.userId ?? authStore.currentUserId }))
|
||||
db.events.put(row).catch(e => console.warn('Dexie put failed:', e))
|
||||
db.events.put(row).catch(e => clientLog.warn('events', 'Dexie put failed', e))
|
||||
}
|
||||
|
||||
function dbDelete(id) {
|
||||
|
|
@ -668,7 +669,7 @@ export const useEventsStore = defineStore('events', () => {
|
|||
await db.events.delete(id)
|
||||
await db.eventMedia.where('eventId').equals(id).delete()
|
||||
}
|
||||
remove().catch(e => console.warn('Dexie delete failed:', e))
|
||||
remove().catch(e => clientLog.warn('events', 'Dexie delete failed', e))
|
||||
}
|
||||
|
||||
// Plain copies of media items. Blobs (local videos, recordings) are kept
|
||||
|
|
@ -738,7 +739,7 @@ export const useEventsStore = defineStore('events', () => {
|
|||
})))
|
||||
}
|
||||
|
||||
persist().catch(e => console.warn('Dexie media persist failed:', e))
|
||||
persist().catch(e => clientLog.warn('events', 'Dexie media persist failed', e))
|
||||
}
|
||||
|
||||
async function loadEventMedia(event) {
|
||||
|
|
@ -767,7 +768,7 @@ export const useEventsStore = defineStore('events', () => {
|
|||
applyMediaToDraft(restored)
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('Dexie media load failed:', e)
|
||||
clientLog.warn('events', 'Dexie media load failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1113,7 +1114,7 @@ export const useEventsStore = defineStore('events', () => {
|
|||
}
|
||||
dbPut(events.value[current])
|
||||
db.eventMedia.put({ ...item, eventId: targetEventId, userId: events.value[current].userId })
|
||||
.catch(e => console.warn('Dexie media persist failed:', e))
|
||||
.catch(e => clientLog.warn('events', 'Dexie media persist failed', e))
|
||||
// The title may have been set just now; the image path itself comes
|
||||
// from the upload.
|
||||
queueEventWrite('update', events.value[current])
|
||||
|
|
@ -1158,7 +1159,7 @@ export const useEventsStore = defineStore('events', () => {
|
|||
}
|
||||
dbPut(events.value[idx])
|
||||
db.eventMedia.bulkPut(items.map(item => ({ ...item, eventId: targetEventId, userId: events.value[idx].userId })))
|
||||
.catch(e => console.warn('Dexie media persist failed:', e))
|
||||
.catch(e => clientLog.warn('events', 'Dexie media persist failed', e))
|
||||
}
|
||||
|
||||
return items
|
||||
|
|
@ -1210,7 +1211,7 @@ export const useEventsStore = defineStore('events', () => {
|
|||
}
|
||||
dbPut(events.value[idx])
|
||||
db.eventMedia.put({ ...item, eventId: targetEventId, userId: events.value[idx].userId })
|
||||
.catch(e => console.warn('Dexie media persist failed:', e))
|
||||
.catch(e => clientLog.warn('events', 'Dexie media persist failed', e))
|
||||
}
|
||||
|
||||
return item
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { defineStore, getActivePinia } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { apiJson } from 'src/services/apiClient'
|
||||
import { isTransientError } from 'src/services/syncQueue'
|
||||
import { clientLog } from 'src/services/clientLog'
|
||||
import { WAVE_PRESETS, findWavePreset, isBuiltInPresetId } from 'src/config/wavePresets'
|
||||
import { normalizeBackgroundImage } from 'src/config/backgrounds'
|
||||
import { useAuthStore } from 'stores/auth'
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'thatsme-settings'
|
||||
const PERSIST_DELAY_MS = 250
|
||||
// The server copy waits a little longer: scrolling the timeline changes the
|
||||
// stored scroll position several times a second, and each change used to be
|
||||
// its own PUT. Identical payloads are not sent twice.
|
||||
const REMOTE_PERSIST_DELAY_MS = 1500
|
||||
const REMOTE_RETRY_BASE_MS = 3000
|
||||
const REMOTE_RETRY_MAX_MS = 60000
|
||||
export const DEFAULT_EMOTION_GRADIENT_START = '#2d2e83'
|
||||
export const DEFAULT_EMOTION_GRADIENT_END = '#3aaa35'
|
||||
export const DEFAULT_TIMELINE_ZOOM = 1
|
||||
|
|
@ -141,7 +149,16 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
const stored = loadFromStorage(authStore.currentUserId)
|
||||
const initialActivePreset = stored?.presets?.find(preset => preset.id === stored?.activePresetId)
|
||||
const initialSettings = initialActivePreset?.settings ?? stored
|
||||
// The Pinia this store belongs to. A remote send that fires after the
|
||||
// store was replaced (tests create one per case) must not go out.
|
||||
const ownPinia = getActivePinia()
|
||||
let persistTimer = null
|
||||
let remoteTimer = null
|
||||
let remoteInFlight = false
|
||||
let remoteDirty = false
|
||||
let remoteAttempts = 0
|
||||
let lastSentSettings = null
|
||||
let lastLocalSettings = null
|
||||
let isApplyingSettings = false
|
||||
// Incremented on every user switch so a late settings response can be
|
||||
// recognised as belonging to a session that has already ended.
|
||||
|
|
@ -213,30 +230,107 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
if (!authStore.currentUserId || isApplyingSettings) return
|
||||
|
||||
const storedSettings = createStoredSettings()
|
||||
const serialized = JSON.stringify(storedSettings)
|
||||
// An immediate save followed by the watcher's scheduled one: the second
|
||||
// has nothing new and must not push the server write out again.
|
||||
if (serialized === lastLocalSettings) return
|
||||
lastLocalSettings = serialized
|
||||
|
||||
try {
|
||||
localStorage.setItem(getStorageKey(authStore.currentUserId), JSON.stringify(storedSettings))
|
||||
localStorage.setItem(getStorageKey(authStore.currentUserId), serialized)
|
||||
} catch (error) {
|
||||
// Private mode, full quota, or a torn-down environment: the remote
|
||||
// copy below still happens, the local mirror is best effort.
|
||||
console.warn('Settings could not be written to localStorage:', error)
|
||||
clientLog.warn('settings', 'Einstellungen konnten nicht lokal gespeichert werden', error)
|
||||
}
|
||||
|
||||
if (isRemoteUser()) {
|
||||
const generation = settingsGeneration
|
||||
if (isRemoteUser()) scheduleRemotePersist()
|
||||
}
|
||||
|
||||
apiJson('/settings', {
|
||||
function scheduleRemotePersist(delay = REMOTE_PERSIST_DELAY_MS) {
|
||||
remoteDirty = true
|
||||
if (remoteTimer) clearTimeout(remoteTimer)
|
||||
remoteTimer = setTimeout(() => {
|
||||
remoteTimer = null
|
||||
sendRemoteSettings()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function resetRemotePersist() {
|
||||
if (remoteTimer) clearTimeout(remoteTimer)
|
||||
remoteTimer = null
|
||||
remoteDirty = false
|
||||
remoteAttempts = 0
|
||||
lastSentSettings = null
|
||||
lastLocalSettings = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes the current settings to the server. One request at a time; a
|
||||
* change that arrives while one is in flight goes out afterwards. A
|
||||
* network or server hiccup is retried quietly with growing pauses, only a
|
||||
* rejection of the payload itself is shown to the user.
|
||||
*/
|
||||
async function sendRemoteSettings() {
|
||||
if (remoteInFlight) return
|
||||
if (getActivePinia() !== ownPinia) {
|
||||
remoteDirty = false
|
||||
return
|
||||
}
|
||||
if (!authStore.currentUserId || !isRemoteUser()) {
|
||||
remoteDirty = false
|
||||
return
|
||||
}
|
||||
|
||||
const storedSettings = createStoredSettings()
|
||||
const serialized = JSON.stringify(storedSettings)
|
||||
if (serialized === lastSentSettings) {
|
||||
remoteDirty = false
|
||||
return
|
||||
}
|
||||
|
||||
const generation = settingsGeneration
|
||||
remoteInFlight = true
|
||||
remoteDirty = false
|
||||
|
||||
try {
|
||||
await apiJson('/settings', {
|
||||
method: 'PUT',
|
||||
body: { settings: storedSettings }
|
||||
}).catch((error) => {
|
||||
console.warn('Remote settings persist failed:', error)
|
||||
if (generation !== settingsGeneration) return
|
||||
|
||||
lastError.value = 'Die Einstellungen konnten nicht gespeichert werden.'
|
||||
})
|
||||
if (generation !== settingsGeneration) return
|
||||
|
||||
lastSentSettings = serialized
|
||||
remoteAttempts = 0
|
||||
} catch (error) {
|
||||
if (generation !== settingsGeneration) return
|
||||
|
||||
if (isTransientError(error)) {
|
||||
remoteAttempts += 1
|
||||
const backoff = Math.min(REMOTE_RETRY_BASE_MS * 2 ** (remoteAttempts - 1), REMOTE_RETRY_MAX_MS)
|
||||
const delay = Math.max(backoff, error?.retryAfterMs ?? 0)
|
||||
clientLog.warn('settings', 'Einstellungen noch nicht übertragen, neuer Versuch folgt', {
|
||||
status: error?.status ?? 0,
|
||||
attempt: remoteAttempts,
|
||||
retryInMs: delay
|
||||
})
|
||||
scheduleRemotePersist(delay)
|
||||
} else {
|
||||
clientLog.error('settings', 'Server hat die Einstellungen abgelehnt', error)
|
||||
lastError.value = 'Die Einstellungen konnten nicht gespeichert werden.'
|
||||
}
|
||||
} finally {
|
||||
remoteInFlight = false
|
||||
if (remoteDirty && !remoteTimer) scheduleRemotePersist()
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', () => {
|
||||
if (remoteDirty) scheduleRemotePersist(0)
|
||||
})
|
||||
}
|
||||
|
||||
function schedulePersist() {
|
||||
if (isApplyingSettings) return
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
|
|
@ -258,6 +352,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
// next switch must not overwrite the settings of whoever is active then.
|
||||
settingsGeneration += 1
|
||||
const generation = settingsGeneration
|
||||
resetRemotePersist()
|
||||
|
||||
isApplyingSettings = true
|
||||
lastError.value = null
|
||||
|
|
@ -275,7 +370,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
localStorage.setItem(getStorageKey(userId), JSON.stringify(nextStored))
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Remote settings load failed:', error)
|
||||
clientLog.warn('settings', 'Einstellungen konnten nicht vom Server geladen werden', error)
|
||||
if (generation !== settingsGeneration) return
|
||||
|
||||
// The locally cached settings from `loadFromStorage` stay in
|
||||
|
|
@ -343,7 +438,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
try {
|
||||
await apiJson('/settings/media/background', { method: 'DELETE' })
|
||||
} catch (error) {
|
||||
console.warn('Remote settings background delete failed:', error)
|
||||
clientLog.warn('settings', 'Remote settings background delete failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { clientLog } from 'src/services/clientLog'
|
||||
/**
|
||||
* Re-encode a recording as 16-bit PCM WAV.
|
||||
*
|
||||
|
|
@ -29,7 +30,7 @@ export async function blobToWav(blob, { sampleRate = WAV_SAMPLE_RATE } = {}) {
|
|||
const mono = await downmix(decoded, sampleRate, OfflineCtor)
|
||||
return new Blob([encodeWav(mono, sampleRate)], { type: 'audio/wav' })
|
||||
} catch (error) {
|
||||
console.warn('WAV conversion failed:', error)
|
||||
clientLog.warn('voice', 'WAV conversion failed', error)
|
||||
return null
|
||||
} finally {
|
||||
context?.close?.().catch(() => {})
|
||||
|
|
|
|||
128
frontend/test/vitest/client-log.spec.js
Normal file
128
frontend/test/vitest/client-log.spec.js
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { db } from 'src/db'
|
||||
import {
|
||||
clientLog,
|
||||
logEntries,
|
||||
normalizeContext,
|
||||
resetClientLogForTests,
|
||||
restoreClientLog,
|
||||
setClientLogTransport,
|
||||
shipClientLog
|
||||
} from 'src/services/clientLog'
|
||||
import { flush, waitFor } from './helpers'
|
||||
|
||||
beforeEach(async () => {
|
||||
resetClientLogForTests()
|
||||
await db.clientLogs.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetClientLogForTests()
|
||||
})
|
||||
|
||||
describe('client log — collecting', () => {
|
||||
it('bumps a counter for the same message instead of adding a line', () => {
|
||||
clientLog.warn('image', 'Bild konnte nicht geladen werden (429)', { status: 429 })
|
||||
clientLog.warn('image', 'Bild konnte nicht geladen werden (429)', { status: 429 })
|
||||
clientLog.warn('image', 'Bild konnte nicht geladen werden (429)')
|
||||
clientLog.error('api', 'PUT /settings → 429', { status: 429 })
|
||||
|
||||
expect(logEntries.value).toHaveLength(2)
|
||||
expect(logEntries.value[0]).toMatchObject({ level: 'warning', scope: 'image', count: 3, shipped: 0 })
|
||||
expect(logEntries.value[1]).toMatchObject({ level: 'error', scope: 'api', count: 1, shipped: 0 })
|
||||
})
|
||||
|
||||
it('keeps info entries local and flattens errors into scalar context', () => {
|
||||
const entry = clientLog.info('sync', 'Warteschlange geleert')
|
||||
expect(entry.shipped).toBe(1)
|
||||
|
||||
const error = Object.assign(new Error('Too Many Requests'), { status: 429 })
|
||||
error.name = 'ApiError'
|
||||
expect(normalizeContext(error)).toMatchObject({ error: 'ApiError: Too Many Requests', status: 429 })
|
||||
|
||||
expect(normalizeContext({ nested: { a: 1 }, ok: true, skipped: undefined, empty: '' }))
|
||||
.toEqual({ nested: '{"a":1}', ok: true })
|
||||
expect(normalizeContext(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('client log — shipping', () => {
|
||||
it('sends unshipped warnings and errors as one batch and marks them', async () => {
|
||||
const send = vi.fn(async () => {})
|
||||
clientLog.error('api', 'PUT /settings → 429', { status: 429 })
|
||||
clientLog.warn('image', 'Bild konnte nicht geladen werden (429)')
|
||||
clientLog.warn('image', 'Bild konnte nicht geladen werden (429)')
|
||||
clientLog.info('sync', 'nur lokal')
|
||||
|
||||
setClientLogTransport({ appVersion: '0.0.1', enabled: () => true, send })
|
||||
|
||||
expect(await shipClientLog()).toBe(true)
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
|
||||
const [batch] = send.mock.calls[0]
|
||||
expect(batch.appVersion).toBe('0.0.1')
|
||||
expect(batch.entries).toHaveLength(2)
|
||||
expect(batch.entries[0]).toMatchObject({
|
||||
level: 'error',
|
||||
scope: 'api',
|
||||
message: 'PUT /settings → 429',
|
||||
context: { status: 429 }
|
||||
})
|
||||
expect(batch.entries[1].message).toBe('Bild konnte nicht geladen werden (429) (×2)')
|
||||
expect(batch.entries[0].occurredAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
||||
|
||||
expect(logEntries.value.every(entry => entry.shipped === 1)).toBe(true)
|
||||
// Nothing left: the next call sends nothing.
|
||||
expect(await shipClientLog()).toBe(false)
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps everything in the browser when the transport says no (demo user)', async () => {
|
||||
const send = vi.fn(async () => {})
|
||||
clientLog.error('window', 'crash')
|
||||
setClientLogTransport({ enabled: () => false, send })
|
||||
|
||||
expect(await shipClientLog()).toBe(false)
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
expect(logEntries.value[0].shipped).toBe(0)
|
||||
})
|
||||
|
||||
it('stops after the server rejects the batch, but retries after a hiccup', async () => {
|
||||
const rejected = vi.fn(async () => {
|
||||
throw Object.assign(new Error('Unprocessable'), { status: 422 })
|
||||
})
|
||||
clientLog.error('window', 'crash')
|
||||
setClientLogTransport({ enabled: () => true, send: rejected })
|
||||
|
||||
expect(await shipClientLog()).toBe(false)
|
||||
expect(await shipClientLog()).toBe(false)
|
||||
expect(rejected).toHaveBeenCalledTimes(1)
|
||||
|
||||
resetClientLogForTests()
|
||||
const flaky = vi.fn(async () => {
|
||||
throw Object.assign(new Error('Server error'), { status: 500 })
|
||||
})
|
||||
clientLog.error('window', 'crash again')
|
||||
setClientLogTransport({ enabled: () => true, send: flaky })
|
||||
|
||||
expect(await shipClientLog()).toBe(false)
|
||||
expect(await shipClientLog()).toBe(false)
|
||||
expect(flaky).toHaveBeenCalledTimes(2)
|
||||
expect(logEntries.value[0].shipped).toBe(0)
|
||||
})
|
||||
|
||||
it('restores unshipped entries from IndexedDB after a reload', async () => {
|
||||
clientLog.error('window', 'Absturz vor dem Versand')
|
||||
await waitFor(() => false, { rounds: 3 }).catch(() => {})
|
||||
await flush(2)
|
||||
expect(await db.clientLogs.count()).toBe(1)
|
||||
|
||||
// A reload: memory is gone, the table is not.
|
||||
resetClientLogForTests()
|
||||
expect(logEntries.value).toHaveLength(0)
|
||||
|
||||
await restoreClientLog()
|
||||
expect(logEntries.value).toHaveLength(1)
|
||||
expect(logEntries.value[0]).toMatchObject({ message: 'Absturz vor dem Versand', shipped: 0 })
|
||||
})
|
||||
})
|
||||
174
frontend/test/vitest/image-cache.spec.js
Normal file
174
frontend/test/vitest/image-cache.spec.js
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { db } from 'src/db'
|
||||
import { deferred } from './helpers'
|
||||
|
||||
let apiFetchMock = vi.fn()
|
||||
|
||||
vi.mock('src/services/apiClient', async () => {
|
||||
const actual = await vi.importActual('src/services/apiClient')
|
||||
return {
|
||||
...actual,
|
||||
API_BASE: 'http://api.test',
|
||||
apiFetch: (...args) => apiFetchMock(...args)
|
||||
}
|
||||
})
|
||||
|
||||
const { resolveFullRes, useImageCache, setImageCacheScope, resetImageCacheForTests } = await import('composables/useImageCache')
|
||||
const { logEntries, resetClientLogForTests } = await import('src/services/clientLog')
|
||||
|
||||
function imageResponse() {
|
||||
return new Response(new Blob(['jpeg'], { type: 'image/jpeg' }), { status: 200 })
|
||||
}
|
||||
|
||||
function statusResponse(status, headers = {}) {
|
||||
return new Response(null, { status, headers })
|
||||
}
|
||||
|
||||
let objectUrls = 0
|
||||
|
||||
beforeEach(async () => {
|
||||
apiFetchMock = vi.fn()
|
||||
objectUrls = 0
|
||||
vi.stubGlobal('URL', Object.assign(URL, {
|
||||
createObjectURL: () => `blob:mock-${++objectUrls}`,
|
||||
revokeObjectURL: () => {}
|
||||
}))
|
||||
resetClientLogForTests()
|
||||
resetImageCacheForTests()
|
||||
setImageCacheScope('image-user')
|
||||
await db.scopedImageCache.clear()
|
||||
// Timers and the clock the gate compares against; fake-indexeddb keeps
|
||||
// the real event loop (setImmediate).
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
|
||||
})
|
||||
|
||||
/**
|
||||
* Lets the promise chains behind the gate and the IndexedDB look-ups run to
|
||||
* the end. setImmediate is real, so each round drains the microtasks that
|
||||
* came before it.
|
||||
*/
|
||||
async function settle(rounds = 6) {
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
/** Advances the fake clock, then lets everything it woke up finish. */
|
||||
async function advance(ms) {
|
||||
await vi.advanceTimersByTimeAsync(ms)
|
||||
await settle()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
setImageCacheScope(null)
|
||||
})
|
||||
|
||||
describe('image cache — protected images', () => {
|
||||
it('waits out a 429 and delivers the image on the next attempt', async () => {
|
||||
apiFetchMock
|
||||
.mockResolvedValueOnce(statusResponse(429, { 'Retry-After': '2' }))
|
||||
.mockResolvedValueOnce(imageResponse())
|
||||
|
||||
const pending = resolveFullRes('/event-media/7/preview')
|
||||
await settle()
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Retry-After says two seconds; nothing happens before that.
|
||||
await advance(1500)
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await advance(600)
|
||||
expect(await pending).toBe('blob:mock-1')
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(logEntries.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('gives up after three server errors with null, not with a path the img cannot load', async () => {
|
||||
apiFetchMock.mockResolvedValue(statusResponse(500))
|
||||
|
||||
const pending = resolveFullRes('/event-media/7/preview')
|
||||
await settle()
|
||||
await advance(1500)
|
||||
await advance(3000)
|
||||
|
||||
expect(await pending).toBeNull()
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(3)
|
||||
expect(logEntries.value).toHaveLength(1)
|
||||
expect(logEntries.value[0]).toMatchObject({
|
||||
level: 'warning',
|
||||
scope: 'image',
|
||||
context: { status: 500, url: '/event-media/7/preview' }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not retry a 404', async () => {
|
||||
apiFetchMock.mockResolvedValue(statusResponse(404))
|
||||
|
||||
const pending = resolveFullRes('/event-media/9/original')
|
||||
await settle()
|
||||
|
||||
expect(await pending).toBeNull()
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('lets at most six image requests run at once', async () => {
|
||||
const gates = []
|
||||
let inFlight = 0
|
||||
let peak = 0
|
||||
apiFetchMock.mockImplementation(() => {
|
||||
inFlight += 1
|
||||
peak = Math.max(peak, inFlight)
|
||||
const gate = deferred()
|
||||
gates.push(gate)
|
||||
return gate.promise.then(() => {
|
||||
inFlight -= 1
|
||||
return imageResponse()
|
||||
})
|
||||
})
|
||||
|
||||
const all = Promise.all(Array.from({ length: 10 }, (_, i) => resolveFullRes(`/event-media/${i}/thumb`)))
|
||||
await settle()
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(6)
|
||||
|
||||
for (const gate of gates.splice(0)) gate.resolve()
|
||||
await settle()
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(10)
|
||||
for (const gate of gates.splice(0)) gate.resolve()
|
||||
|
||||
const results = await all
|
||||
expect(results.every(src => src?.startsWith('blob:mock-'))).toBe(true)
|
||||
expect(peak).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('image cache — composable', () => {
|
||||
it('reports a failure instead of showing a broken image', async () => {
|
||||
apiFetchMock.mockResolvedValue(statusResponse(404))
|
||||
|
||||
const { resolvedSrc, loading, failed } = useImageCache('/event-media/3/thumb', 'e3')
|
||||
await nextTick()
|
||||
expect(loading.value).toBe(true)
|
||||
|
||||
await settle(10)
|
||||
|
||||
expect(resolvedSrc.value).toBeNull()
|
||||
expect(failed.value).toBe(true)
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the direct URL for public images', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => statusResponse(500)))
|
||||
|
||||
const { resolvedSrc, failed } = useImageCache('https://cdn.test/photo.jpg', 'e4')
|
||||
await settle(10)
|
||||
// A 500 is retried twice before the cache gives up on it.
|
||||
await advance(1500)
|
||||
await advance(3000)
|
||||
|
||||
expect(resolvedSrc.value).toBe('https://cdn.test/photo.jpg')
|
||||
expect(failed.value).toBe(false)
|
||||
})
|
||||
})
|
||||
133
frontend/test/vitest/settings-persist.spec.js
Normal file
133
frontend/test/vitest/settings-persist.spec.js
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createApiRouter } from './helpers'
|
||||
|
||||
let api = createApiRouter()
|
||||
|
||||
vi.mock('src/services/apiClient', () => ({
|
||||
API_BASE: 'http://api.test',
|
||||
apiJson: (...args) => api.apiJson(...args),
|
||||
apiFetch: vi.fn(),
|
||||
retryAfterMs: () => null,
|
||||
getToken: vi.fn(async () => 'token'),
|
||||
setToken: vi.fn(async () => {}),
|
||||
clearToken: vi.fn(async () => {}),
|
||||
onUnauthorized: vi.fn(() => () => {}),
|
||||
revokeToken: vi.fn(async () => {}),
|
||||
loginRemote: vi.fn(),
|
||||
ApiError: class ApiError extends Error {}
|
||||
}))
|
||||
|
||||
const { useAuthStore } = await import('stores/auth')
|
||||
const { useSettingsStore } = await import('stores/settings')
|
||||
const { resetClientLogForTests, logEntries } = await import('src/services/clientLog')
|
||||
|
||||
function apiError(status, extra = {}) {
|
||||
return Object.assign(new Error(`HTTP ${status}`), { status, ...extra })
|
||||
}
|
||||
|
||||
describe('settings store — the server copy is written sparingly', () => {
|
||||
let settingsStore
|
||||
let puts
|
||||
|
||||
beforeEach(async () => {
|
||||
setActivePinia(createPinia())
|
||||
api = createApiRouter()
|
||||
resetClientLogForTests()
|
||||
puts = []
|
||||
// Only the timers: IndexedDB needs the real event loop.
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
|
||||
api.always('/settings', (url, options) => {
|
||||
if (options.method !== 'PUT') return Promise.resolve({ data: null })
|
||||
puts.push(options.body.settings)
|
||||
return Promise.resolve({ data: options.body.settings })
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
settingsStore = useSettingsStore()
|
||||
authStore.currentUserId = 'remote-user-1'
|
||||
// The GET at start-up and the watchers behind it.
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
puts.length = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('bundles a burst of scroll positions into one PUT and skips identical payloads', async () => {
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
settingsStore.saveTimelineScrollLeft(i * 10, true)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
}
|
||||
// Two seconds of scrolling, not a single request yet.
|
||||
expect(puts).toHaveLength(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(puts).toHaveLength(1)
|
||||
expect(puts[0].timelineScrollLeft).toBe(200)
|
||||
|
||||
// The same value again changes nothing on the server.
|
||||
settingsStore.saveTimelineScrollLeft(200, true)
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
expect(puts).toHaveLength(1)
|
||||
|
||||
settingsStore.saveTimelineScrollLeft(260, true)
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(puts).toHaveLength(2)
|
||||
expect(puts[1].timelineScrollLeft).toBe(260)
|
||||
})
|
||||
|
||||
it('retries a 429 quietly instead of telling the user the settings were lost', async () => {
|
||||
let attempts = 0
|
||||
api = createApiRouter()
|
||||
api.always('/settings', (url, options) => {
|
||||
if (options.method !== 'PUT') return Promise.resolve({ data: null })
|
||||
attempts += 1
|
||||
if (attempts === 1) return Promise.reject(apiError(429, { retryAfterMs: 2000 }))
|
||||
return Promise.resolve({ data: options.body.settings })
|
||||
})
|
||||
|
||||
settingsStore.saveTimelineScrollLeft(50, true)
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(attempts).toBe(1)
|
||||
expect(settingsStore.lastError).toBeNull()
|
||||
expect(logEntries.value.at(-1)).toMatchObject({ level: 'warning', scope: 'settings', context: { status: 429, attempt: 1 } })
|
||||
|
||||
// Backoff (3 s) outlasts the Retry-After here; then it goes out again.
|
||||
await vi.advanceTimersByTimeAsync(2500)
|
||||
expect(attempts).toBe(1)
|
||||
await vi.advanceTimersByTimeAsync(600)
|
||||
expect(attempts).toBe(2)
|
||||
expect(settingsStore.lastError).toBeNull()
|
||||
|
||||
// A change made while the retry was pending is included, not lost.
|
||||
settingsStore.saveTimelineScrollLeft(80, true)
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
|
||||
it('reports a payload the server rejects', async () => {
|
||||
api = createApiRouter()
|
||||
api.always('/settings', (url, options) => (
|
||||
options.method === 'PUT'
|
||||
? Promise.reject(apiError(422))
|
||||
: Promise.resolve({ data: null })
|
||||
))
|
||||
|
||||
settingsStore.saveTimelineScrollLeft(50, true)
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
|
||||
expect(settingsStore.lastError).toBe('Die Einstellungen konnten nicht gespeichert werden.')
|
||||
expect(logEntries.value.at(-1)).toMatchObject({ level: 'error', scope: 'settings' })
|
||||
})
|
||||
|
||||
it('sends nothing more once another store has taken over', async () => {
|
||||
settingsStore.saveTimelineScrollLeft(50, true)
|
||||
// A new Pinia, as the next test would create it.
|
||||
setActivePinia(createPinia())
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
expect(puts).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue