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:
Kevin Adametz 2026-09-25 09:31:35 +00:00
parent 522deffa1a
commit ab23c9f4ef
28 changed files with 1689 additions and 78 deletions

View file

@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreClientLogsRequest;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
class ClientLogController extends Controller
{
/**
* Log channel that receives browser-side warnings and errors.
*/
public const CHANNEL = 'client';
/**
* POST /api/client-logs — a batch of warnings and errors collected in the
* browser. Each entry becomes one line in the `client` log channel, tagged
* with the user, the app version and where in the app it happened.
*/
public function store(StoreClientLogsRequest $request): Response
{
$userId = (int) $request->user()->id;
$batch = [
'user_id' => $userId,
'app_version' => $request->validated('appVersion'),
'session_id' => $request->validated('sessionId'),
'user_agent' => $request->validated('userAgent'),
'ip' => $request->ip(),
];
$logger = Log::channel(self::CHANNEL);
foreach ($request->validated('entries') as $entry) {
$logger->log($entry['level'], "[{$entry['scope']}] {$entry['message']}", array_filter([
...$batch,
'url' => $entry['url'] ?? null,
'occurred_at' => $entry['occurredAt'],
'context' => $entry['context'] ?? null,
], fn (mixed $value): bool => $value !== null && $value !== []));
}
return response()->noContent();
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests;
use App\Rules\ScalarSetting;
use Illuminate\Foundation\Http\FormRequest;
class StoreClientLogsRequest extends FormRequest
{
/**
* Levels the client may report. Anything below a warning stays in the
* browser; the server log is for things somebody has to look at.
*
* @var array<int, string>
*/
public const LEVELS = ['warning', 'error'];
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'appVersion' => ['nullable', 'string', 'max:40'],
'sessionId' => ['nullable', 'string', 'max:64'],
'userAgent' => ['nullable', 'string', 'max:400'],
'entries' => ['required', 'array', 'min:1', 'max:50'],
'entries.*.level' => ['required', 'string', 'in:'.implode(',', self::LEVELS)],
'entries.*.scope' => ['required', 'string', 'max:60'],
'entries.*.message' => ['required', 'string', 'max:1000'],
'entries.*.url' => ['nullable', 'string', 'max:500'],
'entries.*.occurredAt' => ['required', 'date'],
'entries.*.context' => ['nullable', 'array', 'max:20'],
// Context values are flat: a status code, a path, a message. Nested
// structures are stringified by the client before sending.
'entries.*.context.*' => ['nullable', new ScalarSetting(500)],
];
}
}

View file

@ -39,5 +39,17 @@ class AppServiceProvider extends ServiceProvider
RateLimiter::for('uploads', function (Request $request): Limit { RateLimiter::for('uploads', function (Request $request): Limit {
return Limit::perMinute(30)->by('uploads|user|'.$request->user()?->getAuthIdentifier()); return Limit::perMinute(30)->by('uploads|user|'.$request->user()?->getAuthIdentifier());
}); });
// Thumbnails, previews and originals. One request per image the
// timeline shows, so this has to be far above the write budget.
RateLimiter::for('media', function (Request $request): Limit {
return Limit::perMinute(1200)->by('media|user|'.$request->user()?->getAuthIdentifier());
});
// Client-side log batches; the app sends at most one every few
// seconds, so anything beyond this is a misbehaving client.
RateLimiter::for('client-logs', function (Request $request): Limit {
return Limit::perMinute(30)->by('client-logs|user|'.$request->user()?->getAuthIdentifier());
});
} }
} }

View file

@ -15,5 +15,11 @@ return Application::configure(basePath: dirname(__DIR__))
// //
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {
// // Every logged exception carries who was acting and where, so a line
// in laravel.log can be matched to a client log entry.
$exceptions->context(fn (): array => array_filter([
'user_id' => auth()->id(),
'path' => request()?->path(),
'method' => request()?->method(),
]));
})->create(); })->create();

View file

@ -73,6 +73,17 @@ return [
'replace_placeholders' => true, 'replace_placeholders' => true,
], ],
// Warnings and errors reported by the Quasar app via POST /api/client-logs.
// Kept apart from the server log so browser-side failures (failed
// uploads, 429s, unhandled exceptions) can be read on their own.
'client' => [
'driver' => 'daily',
'path' => storage_path('logs/client.log'),
'level' => env('LOG_CLIENT_LEVEL', 'debug'),
'days' => env('LOG_CLIENT_DAYS', 30),
'replace_placeholders' => true,
],
'slack' => [ 'slack' => [
'driver' => 'slack', 'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'), 'url' => env('LOG_SLACK_WEBHOOK_URL'),

View file

@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\Api\AuthController; use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\ClientLogController;
use App\Http\Controllers\Api\EventController; use App\Http\Controllers\Api\EventController;
use App\Http\Controllers\Api\EventMediaController; use App\Http\Controllers\Api\EventMediaController;
use App\Http\Controllers\Api\SettingsController; use App\Http\Controllers\Api\SettingsController;
@ -15,11 +16,9 @@ Route::middleware(['auth:api', 'throttle:api'])->group(function () {
Route::post('/logout', [AuthController::class, 'logout']); Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/settings', [SettingsController::class, 'show']); Route::get('/settings', [SettingsController::class, 'show']);
Route::put('/settings', [SettingsController::class, 'update']); Route::put('/settings', [SettingsController::class, 'update']);
Route::get('/settings/media/background', [SettingsMediaController::class, 'show']);
Route::post('/settings/media/background', [SettingsMediaController::class, 'store']) Route::post('/settings/media/background', [SettingsMediaController::class, 'store'])
->middleware('throttle:uploads'); ->middleware('throttle:uploads');
Route::delete('/settings/media/background', [SettingsMediaController::class, 'destroy']); Route::delete('/settings/media/background', [SettingsMediaController::class, 'destroy']);
Route::get('/event-media/{media}/{variant}', [EventMediaController::class, 'show']);
Route::get('/events/{clientId}/media', [EventMediaController::class, 'index']); Route::get('/events/{clientId}/media', [EventMediaController::class, 'index']);
Route::post('/events/{clientId}/media', [EventMediaController::class, 'store']) Route::post('/events/{clientId}/media', [EventMediaController::class, 'store'])
->middleware('throttle:uploads'); ->middleware('throttle:uploads');
@ -31,3 +30,18 @@ Route::middleware(['auth:api', 'throttle:api'])->group(function () {
]); ]);
Route::post('/events/sync', [EventController::class, 'sync']); Route::post('/events/sync', [EventController::class, 'sync']);
}); });
// Browser-side warnings and errors, batched by the app. Own budget so a
// client that is failing loudly cannot lock itself out of the real API.
Route::middleware(['auth:api', 'throttle:client-logs'])->group(function () {
Route::post('/client-logs', [ClientLogController::class, 'store']);
});
// Image reads get their own budget: a long timeline fetches one thumbnail per
// visible event, and that must never eat into the limit for the actual API
// writes (which is what showed up as "Einstellungen konnten nicht gespeichert
// werden" while scrolling).
Route::middleware(['auth:api', 'throttle:media'])->group(function () {
Route::get('/settings/media/background', [SettingsMediaController::class, 'show']);
Route::get('/event-media/{media}/{variant}', [EventMediaController::class, 'show']);
});

View file

@ -0,0 +1,84 @@
<?php
use App\Http\Controllers\Api\ClientLogController;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Laravel\Passport\Passport;
use Psr\Log\LoggerInterface;
test('client logs require a token', function () {
$this->postJson('/api/client-logs', ['entries' => []])
->assertUnauthorized();
});
test('client log entries are written to the client channel with the user attached', function () {
$user = User::factory()->create();
Passport::actingAs($user);
$logger = Mockery::mock(LoggerInterface::class);
Log::shouldReceive('channel')->once()->with(ClientLogController::CHANNEL)->andReturn($logger);
$logger->shouldReceive('log')->once()->withArgs(function (string $level, string $message, array $context) use ($user): bool {
return $level === 'error'
&& $message === '[api] PUT /settings failed'
&& $context['user_id'] === $user->id
&& $context['app_version'] === '0.0.1'
&& $context['url'] === '/#/'
&& $context['context'] === ['status' => 429, 'path' => '/settings'];
});
$logger->shouldReceive('log')->once()->withArgs(function (string $level, string $message, array $context): bool {
return $level === 'warning'
&& $message === '[image] Thumbnail load failed'
&& ! array_key_exists('context', $context);
});
$this->postJson('/api/client-logs', [
'appVersion' => '0.0.1',
'sessionId' => 'abc',
'userAgent' => 'Safari',
'entries' => [
[
'level' => 'error',
'scope' => 'api',
'message' => 'PUT /settings failed',
'url' => '/#/',
'occurredAt' => '2026-09-25T10:00:00.000Z',
'context' => ['status' => 429, 'path' => '/settings'],
],
[
'level' => 'warning',
'scope' => 'image',
'message' => 'Thumbnail load failed',
'occurredAt' => '2026-09-25T10:00:01.000Z',
],
],
])->assertNoContent();
});
test('client logs reject unknown levels, oversized batches and nested context', function () {
Passport::actingAs(User::factory()->create());
$entry = fn (array $overrides = []) => [
'level' => 'error',
'scope' => 'api',
'message' => 'x',
'occurredAt' => '2026-09-25T10:00:00.000Z',
...$overrides,
];
$this->postJson('/api/client-logs', ['entries' => [$entry(['level' => 'debug'])]])
->assertStatus(422)
->assertJsonValidationErrors(['entries.0.level']);
$this->postJson('/api/client-logs', ['entries' => array_fill(0, 51, $entry())])
->assertStatus(422)
->assertJsonValidationErrors(['entries']);
$this->postJson('/api/client-logs', ['entries' => [$entry(['context' => ['nested' => ['a' => 1]]])]])
->assertStatus(422)
->assertJsonValidationErrors(['entries.0.context.nested']);
$this->postJson('/api/client-logs', ['entries' => []])
->assertStatus(422)
->assertJsonValidationErrors(['entries']);
});

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Support\Facades\Route;
function middlewareOf(string $method, string $uri): array
{
$route = collect(Route::getRoutes()->getRoutes())
->first(fn ($route) => in_array($method, $route->methods(), true) && $route->uri() === $uri);
expect($route)->not->toBeNull("route {$method} {$uri} is missing");
return $route->gatherMiddleware();
}
test('media reads have their own rate limit, apart from the api writes', function () {
foreach (['api/event-media/{media}/{variant}', 'api/settings/media/background'] as $uri) {
$middleware = middlewareOf('GET', $uri);
expect($middleware)->toContain('auth:api')
->toContain('throttle:media')
->not->toContain('throttle:api');
}
});
test('api writes and client logs keep their limits', function () {
expect(middlewareOf('PUT', 'api/settings'))->toContain('throttle:api');
expect(middlewareOf('GET', 'api/events'))->toContain('throttle:api');
$clientLogs = middlewareOf('POST', 'api/client-logs');
expect($clientLogs)->toContain('auth:api')
->toContain('throttle:client-logs')
->not->toContain('throttle:api');
});

77
documentation/logging.md Normal file
View file

@ -0,0 +1,77 @@
# Fehler-Logging
Stand: 25.09.2026. Wo Fehler landen, wie man sie liest und was der Client automatisch meldet.
## Überblick
| Quelle | Ablage | Lesen |
|---|---|---|
| Browser-App (Warnungen, Fehler) | `backend/storage/logs/client-YYYY-MM-DD.log` | `tail -f storage/logs/client-*.log` oder `php artisan pail` |
| Browser-App, lokal | Einstellungen → Entwickler → Protokoll | im Browser, „Kopieren“ liefert JSON |
| Laravel (Exceptions, 5xx) | `backend/storage/logs/laravel.log` | `php artisan pail` |
Der Kanal `client` ist in `config/logging.php` definiert (täglich rotierend, 30 Tage, per `LOG_CLIENT_DAYS` und `LOG_CLIENT_LEVEL` einstellbar). Für den Server-Log empfiehlt sich auf dem Testserver `LOG_STACK=daily`, damit `laravel.log` nicht unbegrenzt wächst.
## Was der Client meldet
Der Logger liegt in `frontend/src/services/clientLog.js`. Er hält bis zu 200 Einträge im Speicher, spiegelt sie in die IndexedDB-Tabelle `clientLogs` (überlebt einen Reload) und schickt Warnungen und Fehler gebündelt an `POST /api/client-logs`:
- frühestens 5 Sekunden nach dem ersten neuen Eintrag, höchstens 50 Einträge pro Batch,
- nur online und nur für Konten mit Server-Anbindung (Demo-Nutzer behalten alles lokal),
- beim Verlassen der Seite mit `keepalive`, damit der letzte Batch noch rausgeht,
- gleiche Meldung aus gleicher Quelle innerhalb von 10 Sekunden erhöht einen Zähler statt eine Zeile zu erzeugen (`(×12)` im Log).
Erfasst wird automatisch:
- jede fehlgeschlagene API-Antwort außer 401 (`[api] PUT /settings → 429`, mit `Retry-After`),
- Netzwerkfehler ohne Antwort (`→ keine Antwort`, mit `online: false`),
- Bilder, die nach drei Versuchen nicht geladen werden konnten (`[image] …`, mit Status und Pfad),
- Einstellungen, die nicht übertragen werden konnten, mit Versuchszähler,
- Sync-Warteschlange, Sprachnotizen, Dexie-Fehler,
- unbehandelte Fehler: Vue `errorHandler`, `window.onerror`, `unhandledrejection`, fehlgeschlagene Ressourcen-Loads (`src/boot/errorLog.js`).
Jeder Eintrag trägt `user_id`, `app_version`, `session_id`, `user_agent`, `ip`, die Route in der App (`url`) und den Zeitpunkt im Browser (`occurred_at`).
### Eigene Meldungen
```js
import { clientLog } from 'src/services/clientLog'
clientLog.warn('scope', 'Was passiert ist', error) // Error oder flaches Objekt
clientLog.error('scope', 'Was passiert ist', { status: 500 })
clientLog.info('scope', 'nur lokal sichtbar')
```
`scope` ist ein kurzes Wort (`api`, `image`, `settings`, `sync`, `events`, `voice`), damit sich im Log nach Quelle filtern lässt. Kontext wird auf eine Ebene mit Skalaren abgeflacht, verschachtelte Objekte werden als JSON-String übernommen.
## Rate Limits
| Limiter | Grenze | Gilt für |
|---|---|---|
| `api` | 300/min pro Nutzer | alle Schreib- und Lesezugriffe der App außer Medien |
| `media` | 1200/min pro Nutzer | `GET /event-media/{id}/{variant}`, `GET /settings/media/background` |
| `uploads` | 30/min pro Nutzer | Medien- und Hintergrund-Uploads |
| `client-logs` | 30/min pro Nutzer | `POST /client-logs` |
Warum getrennt: Eine lange Timeline lädt pro sichtbarem Ereignis ein Vorschaubild. Vor dem 25.09.2026 zählten diese Bilder und die Einstellungs-Schreibvorgänge auf dasselbe Limit. Beim Scrollen wurde zusätzlich nach jeder Scroll-Pause sofort ein `PUT /settings` gesendet. Nach etwa einer Minute Scrollen antwortete die API mit 429, die Bilder blieben leer und die App meldete „Einstellungen konnten nicht gespeichert werden“.
## Verhalten des Clients bei 429 und Netzproblemen
- **Einstellungen** werden lokal sofort gespeichert, zum Server frühestens 1,5 Sekunden nach der letzten Änderung und nur, wenn sich der Inhalt geändert hat. Ein 429 oder Netzfehler wird still mit wachsendem Abstand wiederholt (3 s, 6 s, … bis 60 s) und beim Wieder-Online-Gehen. Nur eine Ablehnung des Inhalts (z. B. 422) zeigt die Meldung.
- **Bilder** laufen durch ein Gate mit höchstens 6 gleichzeitigen Anfragen. Nach einem 429 warten alle Bildanfragen die `Retry-After`-Zeit ab. Jedes Bild wird bis zu dreimal versucht, dann bleibt die Kachel leer (Platzhalter-Icon), statt ein kaputtes Bild zu zeigen. Während des Ladens pulsieren drei Punkte.
- **Sync-Warteschlange** (Events, Medien) hatte diese Logik bereits: 429 und 5xx gelten als vorübergehend und werden wiederholt.
## Nachschauen auf dem Server
```bash
# Client-Meldungen der letzten Minuten
tail -n 100 storage/logs/client-$(date +%F).log
# Nur Fehler eines Nutzers
grep '"user_id":7' storage/logs/client-*.log | grep ERROR
# Live, beide Kanäle
php artisan pail --timeout=0
```
Jede Laravel-Exception im Server-Log trägt seit dem 25.09.2026 `user_id`, `path` und `method` im Kontext (`bootstrap/app.php`), damit sich Server- und Client-Zeilen einander zuordnen lassen.

View file

@ -11,7 +11,7 @@ export default defineConfig((ctx) => {
// app boot file (/src/boot) // app boot file (/src/boot)
// --> boot files are part of "main.js" // --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files // https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: [], boot: ['errorLog'],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
css: ['app.scss'], css: ['app.scss'],

View 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()
})

View file

@ -140,6 +140,38 @@
<div class="settings-row__hint">{{ APP_VERSION }}</div> <div class="settings-row__hint">{{ APP_VERSION }}</div>
</div> </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> </div>
</ModalCard> </ModalCard>
</template> </template>
@ -149,7 +181,9 @@ import { ref, computed } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import ModalCard from 'components/ModalCard.vue' import ModalCard from 'components/ModalCard.vue'
import { useSettingsStore, ACCENT_COLORS } from 'stores/settings' import { useSettingsStore, ACCENT_COLORS } from 'stores/settings'
import { useAuthStore } from 'stores/auth'
import { APP_VERSION } from 'src/config/appVersion' import { APP_VERSION } from 'src/config/appVersion'
import { clearClientLog, logEntries } from 'src/services/clientLog'
defineProps({ open: { type: Boolean, default: false } }) defineProps({ open: { type: Boolean, default: false } })
defineEmits(['close']) defineEmits(['close'])
@ -189,9 +223,123 @@ function selectAccent(value) {
function onAppearanceChange(e) { function onAppearanceChange(e) {
settingsStore.appearance = e.target.value 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> </script>
<style scoped> <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 { .settings-section__title {
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 700;

View file

@ -75,8 +75,8 @@
<img :src="keyImageSrc" class="event-panel__image" alt="" /> <img :src="keyImageSrc" class="event-panel__image" alt="" />
</template> </template>
<!-- Shown while the image of the newly opened event is loading --> <!-- Shown while the image of the newly opened event is loading -->
<div v-else class="event-panel__image-skeleton" aria-hidden="true"> <div v-else class="event-panel__image-skeleton">
<q-spinner size="28px" /> <LoadingDots size="8px" label="Bild wird geladen" />
</div> </div>
</button> </button>
<input <input
@ -384,8 +384,18 @@
playsinline playsinline
preload="metadata" preload="metadata"
/> />
<div v-else class="event-panel__media-fallback"> <div v-else-if="mediaLoadState[item.id] === 'loading'" class="event-panel__media-fallback">
<q-icon :name="item.type === 'video' ? 'movie' : 'image'" size="28px" /> <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> </div>
<!-- Library-style type marker --> <!-- Library-style type marker -->
<span v-if="item.type === 'video'" class="event-panel__media-play" aria-hidden="true"> <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 { EVENT_CATEGORIES, findEventCategory } from 'src/config/eventCategories'
import ConfirmDialog from 'components/ConfirmDialog.vue' import ConfirmDialog from 'components/ConfirmDialog.vue'
import VoiceNotesCard from 'components/VoiceNotesCard.vue' import VoiceNotesCard from 'components/VoiceNotesCard.vue'
import LoadingDots from 'components/LoadingDots.vue'
import { formatDuration } from 'composables/useVoiceRecorder' import { formatDuration } from 'composables/useVoiceRecorder'
const $q = useQuasar() const $q = useQuasar()
@ -675,6 +686,8 @@ const mediaGalleryOpen = ref(false)
const mediaGalleryIndex = ref(0) const mediaGalleryIndex = ref(0)
// Poster / image sources per item (resolved through the image cache). // Poster / image sources per item (resolved through the image cache).
const mediaSrcById = ref({}) 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. // Full video sources per remote item, fetched only when the gallery shows them.
const videoSrcById = ref({}) const videoSrcById = ref({})
@ -734,6 +747,11 @@ watch(
// For videos this is the poster frame; a video without one has no image // 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. // source and the tile falls back to an icon or its own first frame.
const pending = items.filter(item => !mediaSrcById.value[item.id]) 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) => [ const entries = await Promise.all(pending.map(async (item) => [
item.id, item.id,
item.blob && item.type !== 'video' item.blob && item.type !== 'video'
@ -745,6 +763,11 @@ watch(
...mediaSrcById.value, ...mediaSrcById.value,
...Object.fromEntries(entries.filter(([, src]) => Boolean(src))) ...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 } { deep: true, immediate: true }
) )

View file

@ -23,6 +23,10 @@
class="glow-dot__image" class="glow-dot__image"
alt="" 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> </div>
<!-- An event without a title still needs a label; the date stands in --> <!-- An event without a title still needs a label; the date stands in -->
<div v-if="!isGhost" class="glow-dot__label" :style="labelStyle"> <div v-if="!isGhost" class="glow-dot__label" :style="labelStyle">
@ -38,6 +42,7 @@ import { computed } from 'vue'
import { useEventsStore } from 'stores/events' import { useEventsStore } from 'stores/events'
import { useSettingsStore } from 'stores/settings' import { useSettingsStore } from 'stores/settings'
import { useImageCache } from 'composables/useImageCache' import { useImageCache } from 'composables/useImageCache'
import LoadingDots from 'components/LoadingDots.vue'
import { emotionToYPercent, isLowerHalf } from 'src/utils/timelinePosition' import { emotionToYPercent, isLowerHalf } from 'src/utils/timelinePosition'
import { findEventCategory } from 'src/config/eventCategories' import { findEventCategory } from 'src/config/eventCategories'
@ -55,7 +60,7 @@ const settingsStore = useSettingsStore()
// Resolve image: cached thumbnail from IndexedDB or fetch & cache // Resolve image: cached thumbnail from IndexedDB or fetch & cache
const imageUrl = computed(() => props.event.image || null) const imageUrl = computed(() => props.event.image || null)
const eventId = computed(() => props.event.id) 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 fl = computed(() => settingsStore.floatingLines)
const glowColor = computed(() => eventsStore.getGlowColor(props.event)) 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) // CSS diameter derived from settings (circleRadius is half-size in px)
const dpr = Math.min(window.devicePixelRatio || 1, 2) const dpr = Math.min(window.devicePixelRatio || 1, 2)
const dotSize = computed(() => 2 * fl.value.circleRadius / dpr) 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 // Y position from emotion; shared with the shader points, see utils/timelinePosition
const yPercent = computed(() => emotionToYPercent(props.event.emotion)) const yPercent = computed(() => emotionToYPercent(props.event.emotion))
@ -195,6 +202,15 @@ function onSelect() {
border-radius: 50%; 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 */ /* States */
.glow-dot--ghost { .glow-dot--ghost {
opacity: 1; opacity: 1;

View 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>

View file

@ -113,6 +113,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { useEventsStore } from 'stores/events' import { useEventsStore } from 'stores/events'
import { resolveFullRes } from 'composables/useImageCache' import { resolveFullRes } from 'composables/useImageCache'
import { clientLog } from 'src/services/clientLog'
import { import {
MAX_VOICE_NOTE_MS, MAX_VOICE_NOTE_MS,
extensionForAudioType, extensionForAudioType,
@ -237,7 +238,7 @@ async function saveRecording({ blob, mimeType, durationMs }) {
}) })
eventsStore.saveGhostNow() eventsStore.saveGhostNow()
} catch (error) { } 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.' }) $q.notify({ type: 'negative', message: 'Die Sprachnotiz konnte nicht gespeichert werden.' })
} finally { } finally {
saving.value = false saving.value = false
@ -267,7 +268,7 @@ async function confirmDelete() {
await eventsStore.deleteGhostVoiceNote(note.id) await eventsStore.deleteGhostVoiceNote(note.id)
eventsStore.saveGhostNow() eventsStore.saveGhostNow()
} catch (error) { } 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.' }) $q.notify({ type: 'negative', message: 'Die Sprachnotiz konnte nicht gelöscht werden.' })
} }
} }

View file

@ -1,9 +1,18 @@
import { ref, unref, watch } from 'vue' import { ref, unref, watch } from 'vue'
import { db } from 'src/db' 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 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. // In-memory cache: avoids repeated IndexedDB reads and blob URL creation.
// Shared across all component instances, therefore scoped by user — the URL // 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 // 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') .where('userId')
.equals(previousScope) .equals(previousScope)
.delete() .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}` return `${API_BASE}|${scopeUserId ?? 'anonymous'}|${imageUrl}`
} }
function isProtectedApiImageUrl(imageUrl) { export function isProtectedApiImageUrl(imageUrl) {
return imageUrl.startsWith('/event-media/') || imageUrl.startsWith('/settings/media/') 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. * Create a thumbnail (THUMB_SIZE x THUMB_SIZE) from a source image blob.
* Returns a new Blob (JPEG, quality 0.8). * 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. * Fetch an image from URL, cache thumbnail in IndexedDB, return blob URL.
*/ */
async function fetchAndCache(imageUrl, eventId) { async function fetchAndCache(imageUrl, eventId) {
const response = isProtectedApiImageUrl(imageUrl) const response = await fetchImageWithRetry(imageUrl)
? await apiFetch(imageUrl)
: await fetch(imageUrl)
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
const blob = await response.blob() const blob = await response.blob()
// Create thumbnail // Create thumbnail
@ -142,7 +250,7 @@ async function getCachedImage(imageUrl) {
return blobUrl return blobUrl
} }
} catch (e) { } catch (e) {
console.warn('Image cache read failed:', e) clientLog.warn('image', 'Bild-Cache konnte nicht gelesen werden', e)
} }
return null return null
} }
@ -150,15 +258,20 @@ async function getCachedImage(imageUrl) {
/** /**
* Composable: resolves an event's image to a displayable src. * Composable: resolves an event's image to a displayable src.
* - Checks memory cache → IndexedDB cache → fetches & caches thumbnail. * - 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) { export function useImageCache(imageUrl, eventId) {
const resolvedSrc = ref(null) const resolvedSrc = ref(null)
const loading = ref(false) const loading = ref(false)
const failed = ref(false)
let requestId = 0 let requestId = 0
async function resolve(nextUrl = unref(imageUrl)) { async function resolve(nextUrl = unref(imageUrl)) {
const currentRequestId = ++requestId const currentRequestId = ++requestId
failed.value = false
if (!nextUrl) { if (!nextUrl) {
resolvedSrc.value = null resolvedSrc.value = null
@ -174,26 +287,34 @@ export function useImageCache(imageUrl, eventId) {
return return
} }
loading.value = true
// 2. IndexedDB cache // 2. IndexedDB cache
const cached = await getCachedImage(nextUrl) const cached = await getCachedImage(nextUrl)
if (currentRequestId !== requestId) return
if (cached) { if (cached) {
if (currentRequestId !== requestId) return
resolvedSrc.value = cached resolvedSrc.value = cached
loading.value = false loading.value = false
return return
} }
// 3. Fetch, create thumbnail, cache // 3. Fetch, create thumbnail, cache
loading.value = true
try { try {
const blobUrl = await fetchAndCache(nextUrl, unref(eventId)) const blobUrl = await fetchAndCache(nextUrl, unref(eventId))
if (currentRequestId !== requestId) return if (currentRequestId !== requestId) return
resolvedSrc.value = blobUrl resolvedSrc.value = blobUrl
} catch (e) { } catch (e) {
// Fallback: use original URL directly (works when online)
console.warn('Image cache failed, using direct URL:', e)
if (currentRequestId !== requestId) return 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 { } finally {
if (currentRequestId === requestId) loading.value = false if (currentRequestId === requestId) loading.value = false
} }
@ -203,13 +324,15 @@ export function useImageCache(imageUrl, eventId) {
resolve(nextUrl) resolve(nextUrl)
}, { immediate: true }) }, { immediate: true })
return { resolvedSrc, loading } return { resolvedSrc, loading, failed, retry: () => resolve() }
} }
/** /**
* Resolve full-res image for EventPanel (no thumbnail, just cache check). * Resolve full-res image for EventPanel (no thumbnail, just cache check).
* Returns the original URL — browser Cache-Control handles caching. * Returns a blob URL for protected images and the original URL for public
* When offline, falls back to cached thumbnail. * 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) { export async function resolveFullRes(imageUrl) {
if (!imageUrl) return null if (!imageUrl) return null
@ -219,13 +342,12 @@ export async function resolveFullRes(imageUrl) {
if (memoryCache.has(key)) return memoryCache.get(key) if (memoryCache.has(key)) return memoryCache.get(key)
try { try {
const response = await apiFetch(imageUrl) const response = await fetchImageWithRetry(imageUrl)
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
const blobUrl = URL.createObjectURL(await response.blob()) const blobUrl = URL.createObjectURL(await response.blob())
memoryCache.set(key, blobUrl) memoryCache.set(key, blobUrl)
return blobUrl return blobUrl
} catch (error) { } catch {
console.warn('Full-res image load failed:', error) return await getCachedImage(imageUrl)
} }
} }
@ -251,6 +373,12 @@ export async function clearEventImages(eventId) {
} }
await db.scopedImageCache.where('eventId').equals(eventId).delete() await db.scopedImageCache.where('eventId').equals(eventId).delete()
} catch (e) { } 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
}

View file

@ -45,3 +45,14 @@ db.version(4).stores({
eventMedia: 'id, eventId, userId, createdAt', eventMedia: 'id, eventId, userId, createdAt',
meta: 'key' 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'
})

View file

@ -1,4 +1,5 @@
import { db } from 'src/db' import { db } from 'src/db'
import { clientLog } from 'src/services/clientLog'
function normalizeApiBase(base) { function normalizeApiBase(base) {
const trimmedBase = String(base).replace(/\/+$/, '') const trimmedBase = String(base).replace(/\/+$/, '')
@ -33,11 +34,12 @@ function resolveApiBase() {
export const API_BASE = resolveApiBase() export const API_BASE = resolveApiBase()
export class ApiError extends Error { export class ApiError extends Error {
constructor(message, { status = 0, errors = null } = {}) { constructor(message, { status = 0, errors = null, retryAfterMs = null } = {}) {
super(message) super(message)
this.name = 'ApiError' this.name = 'ApiError'
this.status = status this.status = status
this.errors = errors 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 = {}) { 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 const token = auth ? await getToken() : null
if (auth && !token) { if (auth && !token) {
@ -122,16 +148,29 @@ export async function apiFetch(path, options = {}) {
console.debug('API request:', requestUrl) console.debug('API request:', requestUrl)
} }
const response = await fetch(requestUrl, { let response
...fetchOptions, try {
body: body && !isFormData && typeof body !== 'string' ? JSON.stringify(body) : body, response = await fetch(requestUrl, {
headers: { ...fetchOptions,
Accept: 'application/json', body: body && !isFormData && typeof body !== 'string' ? JSON.stringify(body) : body,
...(!isFormData ? { 'Content-Type': 'application/json' } : {}), headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}), Accept: 'application/json',
...headers ...(!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) { if (response.status === 401 && auth) {
await handleUnauthorized(token) await handleUnauthorized(token)
@ -145,9 +184,11 @@ export async function apiJson(path, options = {}) {
const payload = await readResponsePayload(response) const payload = await readResponsePayload(response)
if (!response.ok) { if (!response.ok) {
if (options.log !== false) logFailedResponse(path, options, response, payload)
throw new ApiError(payload?.message || 'Die API-Anfrage ist fehlgeschlagen.', { throw new ApiError(payload?.message || 'Die API-Anfrage ist fehlgeschlagen.', {
status: response.status, status: response.status,
errors: payload?.errors ?? null errors: payload?.errors ?? null,
retryAfterMs: retryAfterMs(response)
}) })
} }

View 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(() => {})
})
}

View file

@ -1,6 +1,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { db } from 'src/db' import { db } from 'src/db'
import { apiJson } from 'src/services/apiClient' import { apiJson } from 'src/services/apiClient'
import { clientLog } from 'src/services/clientLog'
/** /**
* Outbound write queue for remote users. * Outbound write queue for remote users.
@ -161,7 +162,7 @@ export function createSyncQueue({ getUserId, onApplied, onRejected, onBlocked })
const userId = currentUserId() const userId = currentUserId()
pendingCount.value = userId ? await db.syncQueue.where('userId').equals(userId).count() : 0 pendingCount.value = userId ? await db.syncQueue.where('userId').equals(userId).count() : 0
} catch (error) { } 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 { try {
await onRejected(item, error) await onRejected(item, error)
} catch (handlerError) { } catch (handlerError) {
console.warn('Sync rejection handler failed:', handlerError) clientLog.warn('sync', 'Sync rejection handler failed', handlerError)
} }
} }
continue continue
@ -371,12 +372,12 @@ export function createSyncQueue({ getUserId, onApplied, onRejected, onBlocked })
try { try {
await onApplied(item, payload) await onApplied(item, payload)
} catch (handlerError) { } catch (handlerError) {
console.warn('Sync apply handler failed:', handlerError) clientLog.warn('sync', 'Sync apply handler failed', handlerError)
} }
} }
} }
} catch (error) { } catch (error) {
console.warn('Sync queue processing failed:', error) clientLog.warn('sync', 'Sync queue processing failed', error)
inFlightId = null inFlightId = null
scheduleRetry() scheduleRetry()
} finally { } finally {

View file

@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { clearToken, getToken, loginRemote, onUnauthorized, revokeToken, setToken } from 'src/services/apiClient' import { clearToken, getToken, loginRemote, onUnauthorized, revokeToken, setToken } from 'src/services/apiClient'
import { setImageCacheScope } from 'src/composables/useImageCache' import { setImageCacheScope } from 'src/composables/useImageCache'
import { clientLog } from 'src/services/clientLog'
export const AUTH_STORAGE_KEY = 'thatsme-auth' export const AUTH_STORAGE_KEY = 'thatsme-auth'
@ -156,7 +157,7 @@ export const useAuthStore = defineStore('auth', () => {
return true return true
} catch (error) { } catch (error) {
console.warn('Remote login failed:', error) clientLog.warn('auth', 'Remote login failed', error)
lastError.value = error?.status === 429 lastError.value = error?.status === 429
? 'Zu viele Fehlversuche. Bitte kurz warten und erneut versuchen.' ? 'Zu viele Fehlversuche. Bitte kurz warten und erneut versuchen.'
: error?.status === 422 : error?.status === 422
@ -212,7 +213,7 @@ export const useAuthStore = defineStore('auth', () => {
try { try {
await revokeToken(token) await revokeToken(token)
} catch (error) { } catch (error) {
console.warn('Token revoke failed:', error) clientLog.warn('auth', 'Token revoke failed', error)
} }
} }

View file

@ -7,6 +7,7 @@ import { createSyncQueue, isLocalMediaId, isOnline, isSyncing, newLocalMediaId,
import { useSettingsStore, DEFAULT_EMOTION_GRADIENT_START, DEFAULT_EMOTION_GRADIENT_END } from 'stores/settings' import { useSettingsStore, DEFAULT_EMOTION_GRADIENT_START, DEFAULT_EMOTION_GRADIENT_END } from 'stores/settings'
import { useAuthStore } from 'stores/auth' import { useAuthStore } from 'stores/auth'
import { compareCalendarDates } from 'src/utils/calendarDate' import { compareCalendarDates } from 'src/utils/calendarDate'
import { clientLog } from 'src/services/clientLog'
// Color interpolation // Color interpolation
function lerpColor(a, b, t) { function lerpColor(a, b, t) {
@ -342,7 +343,7 @@ export const useEventsStore = defineStore('events', () => {
return added return added
}) })
.catch((error) => { .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.' lastError.value = 'Die Änderung konnte nicht zwischengespeichert werden.'
if (editingEventId.value === entry.eventId) saveState.value = 'error' if (editingEventId.value === entry.eventId) saveState.value = 'error'
return false return false
@ -467,7 +468,7 @@ export const useEventsStore = defineStore('events', () => {
const userId = events.value[idx]?.userId ?? item.userId const userId = events.value[idx]?.userId ?? item.userId
db.eventMedia.delete(localId) db.eventMedia.delete(localId)
.then(() => db.eventMedia.put({ ...merged, eventId, userId })) .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) { if (editingEventId.value === eventId) {
withPersistSuppressed(() => { withPersistSuppressed(() => {
@ -487,7 +488,7 @@ export const useEventsStore = defineStore('events', () => {
} }
async function applyQueueRejection(item, error) { 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) { switch (item.action) {
case 'create': case 'create':
@ -631,7 +632,7 @@ export const useEventsStore = defineStore('events', () => {
if (isStaleSession(session)) return if (isStaleSession(session)) return
events.value = stored events.value = stored
} catch (e) { } catch (e) {
console.warn('Event load failed:', e) clientLog.warn('events', 'Event load failed', e)
if (isStaleSession(session)) return if (isStaleSession(session)) return
// A failed load is not an empty account: fall back to whatever is // 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]) .between([userId, Dexie.minKey], [userId, Dexie.maxKey])
.toArray() .toArray()
} catch (e) { } catch (e) {
console.warn('Dexie cache read failed:', e) clientLog.warn('events', 'Dexie cache read failed', e)
return [] return []
} }
} }
@ -660,7 +661,7 @@ export const useEventsStore = defineStore('events', () => {
// nested in `media`) cannot be structured-cloned by IndexedDB. // nested in `media`) cannot be structured-cloned by IndexedDB.
function dbPut(event) { function dbPut(event) {
const row = JSON.parse(JSON.stringify({ ...toRaw(event), userId: event.userId ?? authStore.currentUserId })) 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) { function dbDelete(id) {
@ -668,7 +669,7 @@ export const useEventsStore = defineStore('events', () => {
await db.events.delete(id) await db.events.delete(id)
await db.eventMedia.where('eventId').equals(id).delete() 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 // 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) { async function loadEventMedia(event) {
@ -767,7 +768,7 @@ export const useEventsStore = defineStore('events', () => {
applyMediaToDraft(restored) applyMediaToDraft(restored)
}) })
} catch (e) { } 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]) dbPut(events.value[current])
db.eventMedia.put({ ...item, eventId: targetEventId, userId: events.value[current].userId }) 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 // The title may have been set just now; the image path itself comes
// from the upload. // from the upload.
queueEventWrite('update', events.value[current]) queueEventWrite('update', events.value[current])
@ -1158,7 +1159,7 @@ export const useEventsStore = defineStore('events', () => {
} }
dbPut(events.value[idx]) dbPut(events.value[idx])
db.eventMedia.bulkPut(items.map(item => ({ ...item, eventId: targetEventId, userId: events.value[idx].userId }))) 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 return items
@ -1210,7 +1211,7 @@ export const useEventsStore = defineStore('events', () => {
} }
dbPut(events.value[idx]) dbPut(events.value[idx])
db.eventMedia.put({ ...item, eventId: targetEventId, userId: events.value[idx].userId }) 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 return item

View file

@ -1,12 +1,20 @@
import { defineStore } from 'pinia' import { defineStore, getActivePinia } from 'pinia'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { apiJson } from 'src/services/apiClient' 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 { WAVE_PRESETS, findWavePreset, isBuiltInPresetId } from 'src/config/wavePresets'
import { normalizeBackgroundImage } from 'src/config/backgrounds' import { normalizeBackgroundImage } from 'src/config/backgrounds'
import { useAuthStore } from 'stores/auth' import { useAuthStore } from 'stores/auth'
const STORAGE_KEY_PREFIX = 'thatsme-settings' const STORAGE_KEY_PREFIX = 'thatsme-settings'
const PERSIST_DELAY_MS = 250 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_START = '#2d2e83'
export const DEFAULT_EMOTION_GRADIENT_END = '#3aaa35' export const DEFAULT_EMOTION_GRADIENT_END = '#3aaa35'
export const DEFAULT_TIMELINE_ZOOM = 1 export const DEFAULT_TIMELINE_ZOOM = 1
@ -141,7 +149,16 @@ export const useSettingsStore = defineStore('settings', () => {
const stored = loadFromStorage(authStore.currentUserId) const stored = loadFromStorage(authStore.currentUserId)
const initialActivePreset = stored?.presets?.find(preset => preset.id === stored?.activePresetId) const initialActivePreset = stored?.presets?.find(preset => preset.id === stored?.activePresetId)
const initialSettings = initialActivePreset?.settings ?? stored 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 persistTimer = null
let remoteTimer = null
let remoteInFlight = false
let remoteDirty = false
let remoteAttempts = 0
let lastSentSettings = null
let lastLocalSettings = null
let isApplyingSettings = false let isApplyingSettings = false
// Incremented on every user switch so a late settings response can be // Incremented on every user switch so a late settings response can be
// recognised as belonging to a session that has already ended. // recognised as belonging to a session that has already ended.
@ -213,30 +230,107 @@ export const useSettingsStore = defineStore('settings', () => {
if (!authStore.currentUserId || isApplyingSettings) return if (!authStore.currentUserId || isApplyingSettings) return
const storedSettings = createStoredSettings() 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 { try {
localStorage.setItem(getStorageKey(authStore.currentUserId), JSON.stringify(storedSettings)) localStorage.setItem(getStorageKey(authStore.currentUserId), serialized)
} catch (error) { } catch (error) {
// Private mode, full quota, or a torn-down environment: the remote // Private mode, full quota, or a torn-down environment: the remote
// copy below still happens, the local mirror is best effort. // 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()) { if (isRemoteUser()) scheduleRemotePersist()
const generation = settingsGeneration }
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', method: 'PUT',
body: { settings: storedSettings } 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() { function schedulePersist() {
if (isApplyingSettings) return if (isApplyingSettings) return
if (persistTimer) clearTimeout(persistTimer) 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. // next switch must not overwrite the settings of whoever is active then.
settingsGeneration += 1 settingsGeneration += 1
const generation = settingsGeneration const generation = settingsGeneration
resetRemotePersist()
isApplyingSettings = true isApplyingSettings = true
lastError.value = null lastError.value = null
@ -275,7 +370,7 @@ export const useSettingsStore = defineStore('settings', () => {
localStorage.setItem(getStorageKey(userId), JSON.stringify(nextStored)) localStorage.setItem(getStorageKey(userId), JSON.stringify(nextStored))
} }
} catch (error) { } catch (error) {
console.warn('Remote settings load failed:', error) clientLog.warn('settings', 'Einstellungen konnten nicht vom Server geladen werden', error)
if (generation !== settingsGeneration) return if (generation !== settingsGeneration) return
// The locally cached settings from `loadFromStorage` stay in // The locally cached settings from `loadFromStorage` stay in
@ -343,7 +438,7 @@ export const useSettingsStore = defineStore('settings', () => {
try { try {
await apiJson('/settings/media/background', { method: 'DELETE' }) await apiJson('/settings/media/background', { method: 'DELETE' })
} catch (error) { } catch (error) {
console.warn('Remote settings background delete failed:', error) clientLog.warn('settings', 'Remote settings background delete failed', error)
} }
} }

View file

@ -1,3 +1,4 @@
import { clientLog } from 'src/services/clientLog'
/** /**
* Re-encode a recording as 16-bit PCM WAV. * 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) const mono = await downmix(decoded, sampleRate, OfflineCtor)
return new Blob([encodeWav(mono, sampleRate)], { type: 'audio/wav' }) return new Blob([encodeWav(mono, sampleRate)], { type: 'audio/wav' })
} catch (error) { } catch (error) {
console.warn('WAV conversion failed:', error) clientLog.warn('voice', 'WAV conversion failed', error)
return null return null
} finally { } finally {
context?.close?.().catch(() => {}) context?.close?.().catch(() => {})

View 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 })
})
})

View 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)
})
})

View 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)
})
})