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
46
backend/app/Http/Controllers/Api/ClientLogController.php
Normal file
46
backend/app/Http/Controllers/Api/ClientLogController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
44
backend/app/Http/Requests/StoreClientLogsRequest.php
Normal file
44
backend/app/Http/Requests/StoreClientLogsRequest.php
Normal 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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -39,5 +39,17 @@ class AppServiceProvider extends ServiceProvider
|
|||
RateLimiter::for('uploads', function (Request $request): Limit {
|
||||
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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,5 +15,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
//
|
||||
})
|
||||
->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();
|
||||
|
|
|
|||
|
|
@ -73,6 +73,17 @@ return [
|
|||
'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' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\AuthController;
|
||||
use App\Http\Controllers\Api\ClientLogController;
|
||||
use App\Http\Controllers\Api\EventController;
|
||||
use App\Http\Controllers\Api\EventMediaController;
|
||||
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::get('/settings', [SettingsController::class, 'show']);
|
||||
Route::put('/settings', [SettingsController::class, 'update']);
|
||||
Route::get('/settings/media/background', [SettingsMediaController::class, 'show']);
|
||||
Route::post('/settings/media/background', [SettingsMediaController::class, 'store'])
|
||||
->middleware('throttle:uploads');
|
||||
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::post('/events/{clientId}/media', [EventMediaController::class, 'store'])
|
||||
->middleware('throttle:uploads');
|
||||
|
|
@ -31,3 +30,18 @@ Route::middleware(['auth:api', 'throttle:api'])->group(function () {
|
|||
]);
|
||||
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']);
|
||||
});
|
||||
|
|
|
|||
84
backend/tests/Feature/Api/ClientLogTest.php
Normal file
84
backend/tests/Feature/Api/ClientLogTest.php
Normal 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']);
|
||||
});
|
||||
33
backend/tests/Feature/Api/RateLimitTest.php
Normal file
33
backend/tests/Feature/Api/RateLimitTest.php
Normal 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
77
documentation/logging.md
Normal 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.
|
||||
|
|
@ -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