feat: add caching debug logs and configurable max concurrent requests for Komga API to enhance performance monitoring
This commit is contained in:
6
ENV.md
6
ENV.md
@@ -17,6 +17,12 @@ NEXTAUTH_URL=https://ton-domaine.com
|
||||
# Admin User (optional - default password for julienfroidefond@gmail.com)
|
||||
ADMIN_DEFAULT_PASSWORD=Admin@2025
|
||||
|
||||
# Cache Debug (optional - logs cache operations)
|
||||
# CACHE_DEBUG=true
|
||||
|
||||
# Komga Request Queue (optional - max concurrent requests to Komga, default: 2)
|
||||
# KOMGA_MAX_CONCURRENT_REQUESTS=5
|
||||
|
||||
# Node Environment
|
||||
NODE_ENV=production
|
||||
```
|
||||
|
||||
@@ -33,6 +33,8 @@ services:
|
||||
- WATCHPACK_POLLING=true
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||
- CACHE_DEBUG=true
|
||||
- KOMGA_MAX_CONCURRENT_REQUESTS=5
|
||||
command: sh -c "pnpm config set store-dir /app/.pnpm-store && pnpm install --frozen-lockfile && pnpm prisma generate && pnpm dev"
|
||||
|
||||
mongodb:
|
||||
|
||||
@@ -20,6 +20,7 @@ services:
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||
- AUTH_TRUST_HOST=true
|
||||
- KOMGA_MAX_CONCURRENT_REQUESTS=5
|
||||
depends_on:
|
||||
mongodb:
|
||||
condition: service_healthy
|
||||
|
||||
317
docs/cache-debug.md
Normal file
317
docs/cache-debug.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Debug du Cache
|
||||
|
||||
Guide pour debugger le système de caching de StripStream.
|
||||
|
||||
## Activation des logs de cache
|
||||
|
||||
### Variable d'environnement
|
||||
|
||||
Activez les logs détaillés du cache serveur avec :
|
||||
|
||||
```bash
|
||||
CACHE_DEBUG=true
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
#### Développement (docker-compose.dev.yml)
|
||||
```yaml
|
||||
environment:
|
||||
- CACHE_DEBUG=true
|
||||
```
|
||||
|
||||
#### Production (.env)
|
||||
```env
|
||||
CACHE_DEBUG=true
|
||||
```
|
||||
|
||||
## Format des logs
|
||||
|
||||
Les logs de cache apparaissent dans la console serveur avec le format suivant :
|
||||
|
||||
### Cache HIT (donnée valide)
|
||||
```
|
||||
[CACHE HIT] home-ongoing | HOME | 0.45ms
|
||||
```
|
||||
- ✅ Donnée trouvée en cache
|
||||
- ✅ Donnée encore valide (pas expirée)
|
||||
- ⚡ Retour immédiat (très rapide)
|
||||
|
||||
### Cache STALE (donnée expirée)
|
||||
```
|
||||
[CACHE STALE] home-ongoing | HOME | 0.52ms
|
||||
```
|
||||
- ✅ Donnée trouvée en cache
|
||||
- ⚠️ Donnée expirée mais toujours retournée
|
||||
- 🔄 Revalidation lancée en background
|
||||
|
||||
### Cache MISS (pas de donnée)
|
||||
```
|
||||
[CACHE MISS] home-ongoing | HOME
|
||||
```
|
||||
- ❌ Aucune donnée en cache
|
||||
- 🌐 Fetch normal depuis Komga
|
||||
- 💾 Mise en cache automatique
|
||||
|
||||
### Cache SET (mise en cache)
|
||||
```
|
||||
[CACHE SET] home-ongoing | HOME | 324.18ms
|
||||
```
|
||||
- 💾 Donnée mise en cache après fetch
|
||||
- 📊 Temps total incluant le fetch Komga
|
||||
- ✅ Prochaines requêtes seront rapides
|
||||
|
||||
### Cache REVALIDATE (revalidation background)
|
||||
```
|
||||
[CACHE REVALIDATE] home-ongoing | HOME | 287.45ms
|
||||
```
|
||||
- 🔄 Revalidation en background (après STALE)
|
||||
- 🌐 Nouvelle donnée fetched depuis Komga
|
||||
- 💾 Cache mis à jour pour les prochaines requêtes
|
||||
|
||||
### Erreur de revalidation
|
||||
```
|
||||
[CACHE REVALIDATE ERROR] home-ongoing: Error: ...
|
||||
```
|
||||
- ❌ Échec de la revalidation background
|
||||
- ⚠️ Cache ancien conservé
|
||||
- 🔄 Retry au prochain STALE
|
||||
|
||||
## Types de cache
|
||||
|
||||
Les logs affichent le type de TTL utilisé :
|
||||
|
||||
| Type | TTL | Usage |
|
||||
|------|-----|-------|
|
||||
| `DEFAULT` | 5 min | Données génériques |
|
||||
| `HOME` | 10 min | Page d'accueil |
|
||||
| `LIBRARIES` | 24h | Bibliothèques |
|
||||
| `SERIES` | 5 min | Séries |
|
||||
| `BOOKS` | 5 min | Livres |
|
||||
| `IMAGES` | 7 jours | Images |
|
||||
|
||||
## Exemple de session complète
|
||||
|
||||
```bash
|
||||
# Première requête (cache vide)
|
||||
[CACHE MISS] home-ongoing | HOME
|
||||
[CACHE SET] home-ongoing | HOME | 324.18ms
|
||||
|
||||
# Requête suivante (cache valide)
|
||||
[CACHE HIT] home-ongoing | HOME | 0.45ms
|
||||
|
||||
# 10 minutes plus tard (cache expiré)
|
||||
[CACHE STALE] home-ongoing | HOME | 0.52ms
|
||||
[CACHE REVALIDATE] home-ongoing | HOME | 287.45ms
|
||||
|
||||
# Requête suivante (cache frais)
|
||||
[CACHE HIT] home-ongoing | HOME | 0.43ms
|
||||
```
|
||||
|
||||
## Outils complémentaires
|
||||
|
||||
### 1. DevTools du navigateur
|
||||
|
||||
#### Network Tab
|
||||
- Temps de réponse < 50ms = probablement du cache serveur
|
||||
- Headers `X-Cache` si configurés
|
||||
- Onglet "Timing" pour détails
|
||||
|
||||
#### Application → Cache Storage
|
||||
Inspectez le cache du Service Worker :
|
||||
- `stripstream-cache-v1` : Ressources statiques
|
||||
- `stripstream-images-v1` : Images (covers + pages)
|
||||
|
||||
Actions disponibles :
|
||||
- ✅ Voir le contenu de chaque cache
|
||||
- 🔍 Chercher une URL spécifique
|
||||
- 🗑️ Supprimer des entrées
|
||||
- 🧹 Vider complètement un cache
|
||||
|
||||
#### Application → Service Workers
|
||||
- État du Service Worker
|
||||
- "Unregister" pour le désactiver
|
||||
- "Update" pour forcer une mise à jour
|
||||
- Console pour voir les logs SW
|
||||
|
||||
### 2. API de monitoring
|
||||
|
||||
#### Taille du cache
|
||||
```bash
|
||||
curl http://localhost:3000/api/komga/cache/size
|
||||
```
|
||||
Response :
|
||||
```json
|
||||
{
|
||||
"sizeInBytes": 15728640,
|
||||
"itemCount": 234
|
||||
}
|
||||
```
|
||||
|
||||
#### Mode actuel
|
||||
```bash
|
||||
curl http://localhost:3000/api/komga/cache/mode
|
||||
```
|
||||
Response :
|
||||
```json
|
||||
{
|
||||
"mode": "memory"
|
||||
}
|
||||
```
|
||||
|
||||
#### Vider le cache
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/komga/cache/clear
|
||||
```
|
||||
|
||||
#### Changer de mode
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/komga/cache/mode \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"mode": "file"}'
|
||||
```
|
||||
|
||||
### 3. Mode fichier : Inspection du disque
|
||||
|
||||
Si vous utilisez le mode `file`, le cache est stocké sur disque :
|
||||
|
||||
```bash
|
||||
# Voir la structure du cache
|
||||
ls -la .cache/
|
||||
|
||||
# Voir la taille totale
|
||||
du -sh .cache/
|
||||
|
||||
# Compter les fichiers
|
||||
find .cache/ -type f | wc -l
|
||||
|
||||
# Voir le contenu d'une entrée
|
||||
cat .cache/user-id/home-ongoing.json | jq
|
||||
```
|
||||
|
||||
Exemple de contenu :
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"ongoing": [...],
|
||||
"recentlyRead": [...],
|
||||
"onDeck": [...]
|
||||
},
|
||||
"expiry": 1704067200000
|
||||
}
|
||||
```
|
||||
|
||||
## Patterns de debug courants
|
||||
|
||||
### Identifier un problème de cache
|
||||
|
||||
**Symptôme** : Les données ne se rafraîchissent pas
|
||||
```bash
|
||||
# 1. Vérifier si STALE + REVALIDATE se produisent
|
||||
CACHE_DEBUG=true
|
||||
|
||||
# 2. Observer les logs
|
||||
[CACHE STALE] series-123 | SERIES | 0.5ms
|
||||
[CACHE REVALIDATE ERROR] series-123: Network error
|
||||
|
||||
# 3. Problème identifié : Komga inaccessible
|
||||
```
|
||||
|
||||
**Solution** : Vérifier la connectivité avec Komga
|
||||
|
||||
### Optimiser les performances
|
||||
|
||||
**Objectif** : Identifier les requêtes lentes
|
||||
```bash
|
||||
# Activer les logs
|
||||
CACHE_DEBUG=true
|
||||
|
||||
# Observer les temps
|
||||
[CACHE MISS] library-456-all-series | SERIES
|
||||
[CACHE SET] library-456-all-series | SERIES | 2847.32ms # ⚠️ Très lent !
|
||||
```
|
||||
|
||||
**Solution** :
|
||||
- Vérifier la taille des bibliothèques
|
||||
- Augmenter le TTL pour ces données
|
||||
- Considérer la pagination
|
||||
|
||||
### Vérifier le mode de cache
|
||||
|
||||
```bash
|
||||
# Logs après redémarrage
|
||||
[CACHE MISS] home-ongoing | HOME # Mode memory : normal
|
||||
[CACHE HIT] home-ongoing | HOME # Mode file : cache persisté
|
||||
```
|
||||
|
||||
En mode `memory` : tous les caches sont vides au démarrage
|
||||
En mode `file` : les caches survivent au redémarrage
|
||||
|
||||
## Performance attendue
|
||||
|
||||
### Temps de réponse normaux
|
||||
|
||||
| Scénario | Temps attendu | Log |
|
||||
|----------|---------------|-----|
|
||||
| Cache HIT | < 1ms | `[CACHE HIT] ... \| 0.45ms` |
|
||||
| Cache STALE | < 1ms | `[CACHE STALE] ... \| 0.52ms` |
|
||||
| Cache MISS (petit) | 50-200ms | `[CACHE SET] ... \| 124.18ms` |
|
||||
| Cache MISS (gros) | 200-1000ms | `[CACHE SET] ... \| 847.32ms` |
|
||||
| Revalidate (background) | Variable | `[CACHE REVALIDATE] ... \| 287.45ms` |
|
||||
|
||||
### Signaux d'alerte
|
||||
|
||||
⚠️ **Cache HIT > 10ms**
|
||||
- Problème : Disque lent (mode file)
|
||||
- Solution : Vérifier les I/O, passer en mode memory
|
||||
|
||||
⚠️ **Cache MISS > 2000ms**
|
||||
- Problème : Komga très lent ou données énormes
|
||||
- Solution : Vérifier Komga, optimiser la requête
|
||||
|
||||
⚠️ **REVALIDATE ERROR fréquents**
|
||||
- Problème : Komga instable ou réseau
|
||||
- Solution : Augmenter les timeouts, vérifier la connectivité
|
||||
|
||||
⚠️ **Trop de MISS successifs**
|
||||
- Problème : Cache pas conservé ou TTL trop court
|
||||
- Solution : Vérifier le mode, augmenter les TTL
|
||||
|
||||
## Désactiver les logs
|
||||
|
||||
Pour désactiver les logs de cache en production :
|
||||
|
||||
```bash
|
||||
# .env
|
||||
CACHE_DEBUG=false
|
||||
|
||||
# ou simplement commenter/supprimer la ligne
|
||||
# CACHE_DEBUG=true
|
||||
```
|
||||
|
||||
Les logs sont **automatiquement désactivés** si la variable n'est pas définie.
|
||||
|
||||
## Logs et performance
|
||||
|
||||
**Impact sur les performances** :
|
||||
- Overhead : < 0.1ms par opération
|
||||
- Pas d'écriture disque (juste console)
|
||||
- Pas d'accumulation en mémoire
|
||||
- Safe pour la production
|
||||
|
||||
**Recommandations** :
|
||||
- ✅ Activé en développement
|
||||
- ✅ Activé temporairement en production pour diagnostics
|
||||
- ❌ Pas nécessaire en production normale
|
||||
|
||||
## Conclusion
|
||||
|
||||
Le système de logs de cache est conçu pour être :
|
||||
- 🎯 **Simple** : Format clair et concis
|
||||
- ⚡ **Rapide** : Impact négligeable sur les performances
|
||||
- 🔧 **Utile** : Informations essentielles pour le debug
|
||||
- 🔒 **Optionnel** : Désactivé par défaut
|
||||
|
||||
Pour la plupart des besoins de debug, les DevTools du navigateur suffisent.
|
||||
Les logs serveur sont utiles pour comprendre le comportement du cache côté backend.
|
||||
|
||||
502
docs/caching.md
Normal file
502
docs/caching.md
Normal file
@@ -0,0 +1,502 @@
|
||||
# Système de Caching
|
||||
|
||||
Ce document décrit l'architecture et les stratégies de caching de StripStream.
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Le système de caching est organisé en **3 couches indépendantes** avec des responsabilités clairement définies :
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ NAVIGATEUR │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ Service Worker (Cache API) │ │
|
||||
│ │ → Offline support │ │
|
||||
│ │ → Images (covers + pages) │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SERVEUR NEXT.JS │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ ServerCacheService │ │
|
||||
│ │ → Données API Komga │ │
|
||||
│ │ → Stale-while-revalidate │ │
|
||||
│ │ → Mode fichier ou mémoire │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SERVEUR KOMGA │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Couche 1 : Service Worker (Client)
|
||||
|
||||
### Fichier
|
||||
`public/sw.js`
|
||||
|
||||
### Responsabilité
|
||||
- Support offline de l'application
|
||||
- Cache persistant des images (couvertures et pages de livres)
|
||||
- Cache des ressources statiques Next.js
|
||||
|
||||
### Stratégies
|
||||
|
||||
#### Images : Cache-First
|
||||
```javascript
|
||||
// Pour toutes les images (covers + pages)
|
||||
const isImageResource = (url) => {
|
||||
return (
|
||||
(url.includes("/api/v1/books/") &&
|
||||
(url.includes("/pages") || url.includes("/thumbnail") || url.includes("/cover"))) ||
|
||||
(url.includes("/api/komga/images/") &&
|
||||
(url.includes("/series/") || url.includes("/books/")) &&
|
||||
url.includes("/thumbnail"))
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Comportement** :
|
||||
1. Vérifier si l'image est dans le cache
|
||||
2. Si oui → retourner depuis le cache
|
||||
3. Si non → fetch depuis le réseau
|
||||
4. Si succès → mettre en cache + retourner
|
||||
5. Si échec → retourner 404
|
||||
|
||||
**Avantages** :
|
||||
- Performance maximale (lecture instantanée depuis le cache)
|
||||
- Fonctionne offline une fois les images chargées
|
||||
- Économise la bande passante
|
||||
|
||||
#### Navigation et ressources statiques : Network-First
|
||||
```javascript
|
||||
// Pour les pages et ressources _next/static
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
// Mise en cache si succès
|
||||
if (response.ok && (isNextStaticResource || isNavigation)) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(async () => {
|
||||
// Fallback sur le cache si offline
|
||||
const cachedResponse = await cache.match(request);
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
// Page offline si navigation
|
||||
if (request.mode === "navigate") {
|
||||
return cache.match("/offline.html");
|
||||
}
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
**Avantages** :
|
||||
- Toujours la dernière version quand online
|
||||
- Fallback offline si nécessaire
|
||||
- Navigation fluide même sans connexion
|
||||
|
||||
### Caches
|
||||
|
||||
| Cache | Usage | Stratégie | Taille |
|
||||
|-------|-------|-----------|--------|
|
||||
| `stripstream-cache-v1` | Ressources statiques + pages | Network-First | ~5 MB |
|
||||
| `stripstream-images-v1` | Images (covers + pages) | Cache-First | Illimité |
|
||||
|
||||
### Nettoyage
|
||||
- Automatique lors de l'activation du Service Worker
|
||||
- Suppression des anciennes versions de cache
|
||||
- Pas d'expiration (contrôlé par l'utilisateur via les paramètres du navigateur)
|
||||
|
||||
## Couche 2 : ServerCacheService (Serveur)
|
||||
|
||||
### Fichier
|
||||
`src/lib/services/server-cache.service.ts`
|
||||
|
||||
### Responsabilité
|
||||
- Cache des réponses API Komga côté serveur
|
||||
- Optimisation des temps de réponse
|
||||
- Réduction de la charge sur Komga
|
||||
|
||||
### Stratégie : Stale-While-Revalidate
|
||||
|
||||
Cette stratégie est **la clé de la performance** de l'application.
|
||||
|
||||
#### Principe
|
||||
```
|
||||
Requête → Cache existe ?
|
||||
├─ Non → Fetch normal + mise en cache
|
||||
└─ Oui → Cache valide ?
|
||||
├─ Oui → Retourne immédiatement
|
||||
└─ Non → Retourne le cache expiré (stale)
|
||||
ET revalide en background
|
||||
```
|
||||
|
||||
#### Implémentation
|
||||
```typescript
|
||||
async getOrSet<T>(
|
||||
key: string,
|
||||
fetcher: () => Promise<T>,
|
||||
type: keyof typeof ServerCacheService.DEFAULT_TTL = "DEFAULT"
|
||||
): Promise<T> {
|
||||
const cacheKey = `${user.id}-${key}`;
|
||||
const cachedResult = this.getStale(cacheKey);
|
||||
|
||||
if (cachedResult !== null) {
|
||||
const { data, isStale } = cachedResult;
|
||||
|
||||
// Si le cache est expiré, revalider en background
|
||||
if (isStale) {
|
||||
this.revalidateInBackground(cacheKey, fetcher, type, key);
|
||||
}
|
||||
|
||||
return data as T; // Retour immédiat
|
||||
}
|
||||
|
||||
// Pas de cache, fetch normal
|
||||
const data = await fetcher();
|
||||
this.set(cacheKey, data, type);
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
#### Avantages
|
||||
✅ **Temps de réponse constant** : Le cache expiré est retourné instantanément
|
||||
✅ **Données fraîches** : Revalidation en background pour la prochaine requête
|
||||
✅ **Pas de délai** : L'utilisateur ne subit jamais l'attente de revalidation
|
||||
✅ **Résilience** : Même si Komga est lent, l'app reste rapide
|
||||
|
||||
#### Inconvénients
|
||||
⚠️ Les données peuvent être légèrement obsolètes (jusqu'au prochain refresh)
|
||||
⚠️ Nécessite un cache initialisé (première requête toujours lente)
|
||||
|
||||
### Modes de stockage
|
||||
|
||||
L'utilisateur peut choisir entre deux modes :
|
||||
|
||||
#### Mode Mémoire (par défaut)
|
||||
```typescript
|
||||
cacheMode: "memory"
|
||||
```
|
||||
- Cache stocké en RAM
|
||||
- **Performances** : Très rapide (lecture < 1ms)
|
||||
- **Persistance** : Perdu au redémarrage du serveur
|
||||
- **Capacité** : Limitée par la RAM disponible
|
||||
- **Idéal pour** : Développement, faible charge
|
||||
|
||||
#### Mode Fichier
|
||||
```typescript
|
||||
cacheMode: "file"
|
||||
```
|
||||
- Cache stocké sur disque (`.cache/`)
|
||||
- **Performances** : Rapide (lecture 5-10ms)
|
||||
- **Persistance** : Survit aux redémarrages
|
||||
- **Capacité** : Limitée par l'espace disque
|
||||
- **Idéal pour** : Production, haute charge
|
||||
|
||||
### Time-To-Live (TTL)
|
||||
|
||||
Chaque type de données a un TTL configuré :
|
||||
|
||||
| Type | TTL par défaut | Justification |
|
||||
|------|----------------|---------------|
|
||||
| `DEFAULT` | 5 minutes | Données génériques |
|
||||
| `HOME` | 10 minutes | Page d'accueil (données agrégées) |
|
||||
| `LIBRARIES` | 24 heures | Bibliothèques (rarement modifiées) |
|
||||
| `SERIES` | 5 minutes | Séries (métadonnées + progression) |
|
||||
| `BOOKS` | 5 minutes | Livres (métadonnées + progression) |
|
||||
| `IMAGES` | 7 jours | Images (immuables) |
|
||||
|
||||
#### Configuration personnalisée
|
||||
|
||||
Les TTL peuvent être personnalisés par l'utilisateur via la base de données :
|
||||
|
||||
```typescript
|
||||
// Modèle Prisma : TTLConfig
|
||||
{
|
||||
defaultTTL: 5 * 60 * 1000,
|
||||
homeTTL: 10 * 60 * 1000,
|
||||
librariesTTL: 24 * 60 * 60 * 1000,
|
||||
seriesTTL: 5 * 60 * 1000,
|
||||
booksTTL: 5 * 60 * 1000,
|
||||
imagesTTL: 7 * 24 * 60 * 60 * 1000,
|
||||
}
|
||||
```
|
||||
|
||||
### Isolation par utilisateur
|
||||
|
||||
Chaque utilisateur a son propre cache :
|
||||
|
||||
```typescript
|
||||
const cacheKey = `${user.id}-${key}`;
|
||||
```
|
||||
|
||||
**Avantages** :
|
||||
- Pas de collision entre utilisateurs
|
||||
- Progression de lecture individuelle
|
||||
- Préférences personnalisées
|
||||
|
||||
### Invalidation du cache
|
||||
|
||||
Le cache peut être invalidé :
|
||||
|
||||
#### Manuellement
|
||||
```typescript
|
||||
await cacheService.delete(key); // Une clé
|
||||
await cacheService.deleteAll(prefix); // Toutes les clés avec préfixe
|
||||
await cacheService.clear(); // Tout le cache
|
||||
```
|
||||
|
||||
#### Automatiquement
|
||||
- Lors d'une mise à jour de progression
|
||||
- Lors d'un changement de favoris
|
||||
- Lors de la suppression d'une série
|
||||
|
||||
#### API
|
||||
```
|
||||
DELETE /api/komga/cache/clear // Vider tout le cache
|
||||
DELETE /api/komga/home // Invalider le cache home
|
||||
```
|
||||
|
||||
## Couche 3 : Cache HTTP (Navigateur)
|
||||
|
||||
### Responsabilité
|
||||
- Cache basique géré par le navigateur
|
||||
- Headers HTTP standard
|
||||
|
||||
### Configuration
|
||||
|
||||
#### Next.js ISR (Incremental Static Regeneration)
|
||||
```typescript
|
||||
export const revalidate = 60; // Revalidation toutes les 60 secondes
|
||||
```
|
||||
|
||||
Utilisé uniquement pour les routes avec rendu statique.
|
||||
|
||||
#### Headers explicites (désactivé)
|
||||
|
||||
Les headers HTTP explicites ont été **supprimés** car :
|
||||
- Le ServerCacheService gère déjà le caching efficacement
|
||||
- Évite la confusion entre plusieurs couches de cache
|
||||
- Simplifie le debugging
|
||||
|
||||
Avant (supprimé) :
|
||||
```typescript
|
||||
NextResponse.json(data, {
|
||||
headers: {
|
||||
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Maintenant :
|
||||
```typescript
|
||||
NextResponse.json(data); // Pas de headers
|
||||
```
|
||||
|
||||
## Flow de données complet
|
||||
|
||||
Exemple : Chargement de la page d'accueil
|
||||
|
||||
```
|
||||
1. Utilisateur → GET /
|
||||
↓
|
||||
2. Next.js → HomeService.getHomeData()
|
||||
↓
|
||||
3. HomeService → ServerCacheService.getOrSet("home-ongoing", ...)
|
||||
↓
|
||||
4. ServerCacheService
|
||||
├─ Cache valide ? → Retourne immédiatement
|
||||
├─ Cache expiré ? → Retourne cache + revalide en background
|
||||
└─ Pas de cache ? → Fetch Komga + mise en cache
|
||||
↓
|
||||
5. Response → Client
|
||||
↓
|
||||
6. Images → Service Worker (Cache-First)
|
||||
├─ En cache ? → Lecture instantanée
|
||||
└─ Pas en cache ? → Fetch + mise en cache
|
||||
```
|
||||
|
||||
### Temps de réponse typiques
|
||||
|
||||
| Scénario | Temps | Détails |
|
||||
|----------|-------|---------|
|
||||
| Cache ServerCache valide + SW | ~50ms | Optimal |
|
||||
| Cache ServerCache expiré + SW | ~50ms | Revalidation en background |
|
||||
| Pas de cache ServerCache + SW | ~200-500ms | Première requête |
|
||||
| Cache SW uniquement | ~10ms | Images seulement |
|
||||
| Tout à froid | ~500-1000ms | Pire cas |
|
||||
|
||||
## Cas d'usage
|
||||
|
||||
### 1. Première visite
|
||||
```
|
||||
User → App → Komga (tous les caches vides)
|
||||
Temps : ~500-1000ms
|
||||
```
|
||||
|
||||
### 2. Visite suivante (online)
|
||||
```
|
||||
User → ServerCache (valide) → Images SW
|
||||
Temps : ~50ms
|
||||
```
|
||||
|
||||
### 3. Cache expiré (online)
|
||||
```
|
||||
User → ServerCache (stale) → Retour immédiat
|
||||
↓
|
||||
Revalidation background → Mise à jour cache
|
||||
Temps ressenti : ~50ms (aucun délai)
|
||||
```
|
||||
|
||||
### 4. Mode offline
|
||||
```
|
||||
User → Service Worker cache uniquement
|
||||
Fonctionnalités :
|
||||
✅ Navigation entre pages déjà visitées
|
||||
✅ Consultation des images déjà vues
|
||||
❌ Nouvelles données (nécessite connexion)
|
||||
```
|
||||
|
||||
## Monitoring et debug
|
||||
|
||||
### Logs de cache (recommandé pour le dev)
|
||||
|
||||
Activez les logs détaillés du cache serveur :
|
||||
|
||||
```bash
|
||||
# Dans docker-compose.dev.yml ou .env
|
||||
CACHE_DEBUG=true
|
||||
```
|
||||
|
||||
**Format des logs** :
|
||||
```
|
||||
[CACHE HIT] home-ongoing | HOME | 0.45ms # Cache valide
|
||||
[CACHE STALE] home-ongoing | HOME | 0.52ms # Cache expiré (retourné + revalidation)
|
||||
[CACHE MISS] home-ongoing | HOME # Pas de cache
|
||||
[CACHE SET] home-ongoing | HOME | 324.18ms # Mise en cache
|
||||
[CACHE REVALIDATE] home-ongoing | HOME | 287ms # Revalidation background
|
||||
```
|
||||
|
||||
📖 **Documentation complète** : [docs/cache-debug.md](./cache-debug.md)
|
||||
|
||||
### API de monitoring
|
||||
|
||||
#### Taille du cache serveur
|
||||
```bash
|
||||
GET /api/komga/cache/size
|
||||
Response: { sizeInBytes: 15728640, itemCount: 234 }
|
||||
```
|
||||
|
||||
#### Mode de cache actuel
|
||||
```bash
|
||||
GET /api/komga/cache/mode
|
||||
Response: { mode: "memory" }
|
||||
```
|
||||
|
||||
#### Changer le mode
|
||||
```bash
|
||||
POST /api/komga/cache/mode
|
||||
Body: { mode: "file" }
|
||||
```
|
||||
|
||||
#### Vider le cache
|
||||
```bash
|
||||
POST /api/komga/cache/clear
|
||||
```
|
||||
|
||||
### DevTools du navigateur
|
||||
|
||||
#### Network Tab
|
||||
- Temps de réponse < 50ms = cache serveur
|
||||
- Headers `X-Cache` si configurés
|
||||
- Onglet "Timing" pour détails
|
||||
|
||||
#### Application → Cache Storage
|
||||
Inspecter le Service Worker :
|
||||
- `stripstream-cache-v1` : Ressources statiques
|
||||
- `stripstream-images-v1` : Images
|
||||
|
||||
Actions disponibles :
|
||||
- Voir le contenu
|
||||
- Supprimer des entrées
|
||||
- Vider complètement
|
||||
|
||||
#### Application → Service Workers
|
||||
- État du Service Worker
|
||||
- "Unregister" pour le désactiver
|
||||
- "Update" pour forcer une mise à jour
|
||||
|
||||
## Optimisations futures possibles
|
||||
|
||||
### 1. Cache Redis (optionnel)
|
||||
- Pour un déploiement multi-instances
|
||||
- Cache partagé entre plusieurs serveurs
|
||||
- TTL natif Redis
|
||||
|
||||
### 2. Compression
|
||||
- Compresser les données en cache (Brotli/Gzip)
|
||||
- Économie d'espace disque/mémoire
|
||||
- Trade-off CPU vs espace
|
||||
|
||||
### 3. Prefetching intelligent
|
||||
- Précharger les séries en cours de lecture
|
||||
- Précharger les pages suivantes dans le reader
|
||||
- Basé sur l'historique utilisateur
|
||||
|
||||
### 4. Cache Analytics
|
||||
- Ratio hit/miss
|
||||
- Temps de réponse moyens
|
||||
- Identification des données les plus consultées
|
||||
|
||||
## Bonnes pratiques
|
||||
|
||||
### Pour les développeurs
|
||||
|
||||
✅ **Utiliser BaseApiService.fetchWithCache()**
|
||||
```typescript
|
||||
await this.fetchWithCache<T>(
|
||||
"cache-key",
|
||||
async () => this.fetchFromApi(...),
|
||||
"HOME" // Type de TTL
|
||||
);
|
||||
```
|
||||
|
||||
✅ **Invalider le cache après modification**
|
||||
```typescript
|
||||
await HomeService.invalidateHomeCache();
|
||||
```
|
||||
|
||||
✅ **Choisir le bon TTL**
|
||||
- Court (1-5 min) : Données qui changent souvent
|
||||
- Moyen (10-30 min) : Données agrégées
|
||||
- Long (24h+) : Données quasi-statiques
|
||||
|
||||
❌ **Ne pas cacher les mutations**
|
||||
Les POST/PUT/DELETE ne doivent jamais être cachés
|
||||
|
||||
❌ **Ne pas oublier l'isolation utilisateur**
|
||||
Toujours préfixer avec `userId` pour les données personnelles
|
||||
|
||||
### Pour les utilisateurs
|
||||
|
||||
- **Mode mémoire** : Plus rapide, mais cache perdu au redémarrage
|
||||
- **Mode fichier** : Persistant, idéal pour production
|
||||
- **Vider le cache** : En cas de problème d'affichage
|
||||
- **Offline** : Consulter les pages déjà visitées
|
||||
|
||||
## Conclusion
|
||||
|
||||
Le système de caching de StripStream est conçu pour :
|
||||
|
||||
🎯 **Performance** : Temps de réponse constants grâce au stale-while-revalidate
|
||||
🔒 **Fiabilité** : Fonctionne même si Komga est lent ou inaccessible
|
||||
💾 **Flexibilité** : Mode mémoire ou fichier selon les besoins
|
||||
🚀 **Offline-first** : Support complet du mode hors ligne
|
||||
🧹 **Simplicité** : 3 couches bien définies, pas de redondance
|
||||
|
||||
Le système est maintenu simple avec des responsabilités claires pour chaque couche, facilitant la maintenance et l'évolution future.
|
||||
|
||||
@@ -65,7 +65,6 @@ model Preferences {
|
||||
showThumbnails Boolean @default(true)
|
||||
cacheMode String @default("memory") // "memory" | "file"
|
||||
showOnlyUnread Boolean @default(false)
|
||||
debug Boolean @default(false)
|
||||
displayMode Json @default("{\"compact\": false, \"itemsPerPage\": 20}")
|
||||
background Json @default("{\"type\": \"default\"}")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
129
public/sw.js
129
public/sw.js
@@ -1,6 +1,5 @@
|
||||
const CACHE_NAME = "stripstream-cache-v1";
|
||||
const BOOKS_CACHE_NAME = "stripstream-books";
|
||||
const COVERS_CACHE_NAME = "stripstream-covers";
|
||||
const IMAGES_CACHE_NAME = "stripstream-images-v1";
|
||||
const OFFLINE_PAGE = "/offline.html";
|
||||
|
||||
const STATIC_ASSETS = [
|
||||
@@ -16,8 +15,7 @@ self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)),
|
||||
caches.open(BOOKS_CACHE_NAME),
|
||||
caches.open(COVERS_CACHE_NAME),
|
||||
caches.open(IMAGES_CACHE_NAME),
|
||||
])
|
||||
);
|
||||
});
|
||||
@@ -28,9 +26,7 @@ self.addEventListener("activate", (event) => {
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter(
|
||||
(name) => name !== CACHE_NAME && name !== BOOKS_CACHE_NAME && name !== COVERS_CACHE_NAME
|
||||
)
|
||||
.filter((name) => name !== CACHE_NAME && name !== IMAGES_CACHE_NAME)
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
})
|
||||
@@ -53,45 +49,31 @@ const isNextStaticResource = (url) => {
|
||||
return url.includes("/_next/static") && !isWebpackResource(url);
|
||||
};
|
||||
|
||||
// Fonction pour vérifier si c'est une ressource de livre
|
||||
const isBookResource = (url) => {
|
||||
return url.includes("/api/v1/books/") && (url.includes("/pages") || url.includes("/thumbnail"));
|
||||
};
|
||||
|
||||
// Fonction pour vérifier si c'est une image de couverture
|
||||
const isCoverImage = (url) => {
|
||||
const urlParams = new URLSearchParams(url.split("?")[1]);
|
||||
const originalUrl = urlParams.get("url") || url;
|
||||
|
||||
// Fonction pour vérifier si c'est une image (couvertures ou pages de livres)
|
||||
const isImageResource = (url) => {
|
||||
return (
|
||||
originalUrl.includes("/api/komga/images/") &&
|
||||
(originalUrl.includes("/series/") || originalUrl.includes("/books/")) &&
|
||||
originalUrl.includes("/thumbnail")
|
||||
(url.includes("/api/v1/books/") && (url.includes("/pages") || url.includes("/thumbnail") || url.includes("/cover"))) ||
|
||||
(url.includes("/api/komga/images/") && (url.includes("/series/") || url.includes("/books/")) && url.includes("/thumbnail"))
|
||||
);
|
||||
};
|
||||
|
||||
// Stratégie de cache pour les images de couverture
|
||||
const coverCacheStrategy = async (request) => {
|
||||
const urlParams = new URLSearchParams(request.url.split("?")[1]);
|
||||
const originalUrl = urlParams.get("url") || request.url;
|
||||
const originalRequest = new Request(originalUrl, request);
|
||||
|
||||
const cache = await caches.open(COVERS_CACHE_NAME);
|
||||
const cachedResponse = await cache.match(originalRequest);
|
||||
// Stratégie Cache-First pour les images
|
||||
const imageCacheStrategy = async (request) => {
|
||||
const cache = await caches.open(IMAGES_CACHE_NAME);
|
||||
const cachedResponse = await cache.match(request);
|
||||
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(originalRequest);
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
await cache.put(originalRequest, response.clone());
|
||||
await cache.put(request, response.clone());
|
||||
return response;
|
||||
}
|
||||
throw new Error("Network response was not ok");
|
||||
} catch (error) {
|
||||
console.error("Error fetching cover image", error);
|
||||
// Si 404, retourner une réponse vide sans throw (pas d'erreur console)
|
||||
if (response.status === 404) {
|
||||
return new Response("", {
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
@@ -100,27 +82,21 @@ const coverCacheStrategy = async (request) => {
|
||||
},
|
||||
});
|
||||
}
|
||||
// Pour les autres erreurs, throw
|
||||
throw new Error(`Network response error: ${response.status}`);
|
||||
} catch (error) {
|
||||
// Erreurs réseau (offline, timeout, etc.)
|
||||
console.warn("Image fetch failed:", error);
|
||||
return new Response("", {
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Écouteur pour les messages du client
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data && event.data.type === "CACHE_COVER") {
|
||||
const { url } = event.data;
|
||||
if (url && isCoverImage(url)) {
|
||||
fetch(url)
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(COVERS_CACHE_NAME);
|
||||
await cache.put(url, response.clone());
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignorer les erreurs de mise en cache
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
// Ignorer les requêtes non GET
|
||||
if (event.request.method !== "GET") return;
|
||||
@@ -128,54 +104,13 @@ self.addEventListener("fetch", (event) => {
|
||||
// Ignorer les ressources webpack
|
||||
if (isWebpackResource(event.request.url)) return;
|
||||
|
||||
// Gérer les requêtes d'images de couverture
|
||||
if (event.request.url.includes("/api/v1/books/") && event.request.url.includes("/cover")) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((response) => {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return fetch(event.request)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const responseToCache = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
return new Response(null, { status: 404 });
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Pour les images de couverture
|
||||
if (isCoverImage(event.request.url)) {
|
||||
event.respondWith(coverCacheStrategy(event.request));
|
||||
// Gérer les images avec Cache-First
|
||||
if (isImageResource(event.request.url)) {
|
||||
event.respondWith(imageCacheStrategy(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Pour les ressources de livre
|
||||
if (isBookResource(event.request.url)) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((response) => {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return fetch(event.request);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pour les ressources statiques de Next.js et les autres requêtes
|
||||
// Pour les ressources statiques de Next.js et les autres requêtes : Network-First
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import type { NextRequest} from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { RequestTiming } from "@/lib/services/debug.service";
|
||||
import { DebugService } from "@/lib/services/debug.service";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { getErrorMessage } from "@/utils/errors";
|
||||
import { AppError } from "@/utils/errors";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const logs: RequestTiming[] = await DebugService.getRequestLogs();
|
||||
return NextResponse.json(logs);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la récupération des logs:", error);
|
||||
if (error instanceof AppError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: error.code,
|
||||
name: "Debug fetch error",
|
||||
message: getErrorMessage(error.code),
|
||||
} as AppError,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: ERROR_CODES.DEBUG.FETCH_ERROR,
|
||||
name: "Debug fetch error",
|
||||
message: getErrorMessage(ERROR_CODES.DEBUG.FETCH_ERROR),
|
||||
} as AppError,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const timing: RequestTiming = await request.json();
|
||||
await DebugService.logRequest(timing);
|
||||
return NextResponse.json({
|
||||
message: "✅ Log enregistré avec succès",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de l'enregistrement du log:", error);
|
||||
if (error instanceof AppError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: error.code,
|
||||
name: "Debug save error",
|
||||
message: getErrorMessage(error.code),
|
||||
} as AppError,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: ERROR_CODES.DEBUG.SAVE_ERROR,
|
||||
name: "Debug save error",
|
||||
message: getErrorMessage(ERROR_CODES.DEBUG.SAVE_ERROR),
|
||||
} as AppError,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
await DebugService.clearLogs();
|
||||
return NextResponse.json({
|
||||
message: "🧹 Logs supprimés avec succès",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la suppression des logs:", error);
|
||||
if (error instanceof AppError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: error.code,
|
||||
name: "Debug clear error",
|
||||
message: getErrorMessage(error.code),
|
||||
} as AppError,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: ERROR_CODES.DEBUG.CLEAR_ERROR,
|
||||
name: "Debug clear error",
|
||||
message: getErrorMessage(ERROR_CODES.DEBUG.CLEAR_ERROR),
|
||||
} as AppError,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,7 @@ export const revalidate = 60;
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await HomeService.getHomeData();
|
||||
return NextResponse.json(data, {
|
||||
headers: {
|
||||
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120'
|
||||
}
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("API Home - Erreur:", error);
|
||||
if (error instanceof AppError) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BookService } from "@/lib/services/book.service";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import { getErrorMessage } from "@/utils/errors";
|
||||
import { findHttpStatus } from "@/utils/image-errors";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -18,6 +19,26 @@ export async function GET(
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la récupération de la page du livre:", error);
|
||||
|
||||
// Chercher un status HTTP 404 dans la chaîne d'erreurs
|
||||
const httpStatus = findHttpStatus(error);
|
||||
|
||||
if (httpStatus === 404) {
|
||||
const { bookId, pageNumber } = await params;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`📷 Page ${pageNumber} not found for book: ${bookId}`);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: ERROR_CODES.IMAGE.FETCH_ERROR,
|
||||
name: "Image not found",
|
||||
message: "Image not found",
|
||||
},
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof AppError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BookService } from "@/lib/services/book.service";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import { getErrorMessage } from "@/utils/errors";
|
||||
import { findHttpStatus } from "@/utils/image-errors";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -32,6 +33,27 @@ export async function GET(
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la récupération de la miniature de la page:", error);
|
||||
|
||||
// Chercher un status HTTP 404 dans la chaîne d'erreurs
|
||||
const httpStatus = findHttpStatus(error);
|
||||
|
||||
if (httpStatus === 404) {
|
||||
const { bookId, pageNumber: pageNumberParam } = await params;
|
||||
const pageNumber: number = parseInt(pageNumberParam);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`📷 Page ${pageNumber} thumbnail not found for book: ${bookId}`);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: ERROR_CODES.IMAGE.FETCH_ERROR,
|
||||
name: "Image not found",
|
||||
message: "Image not found",
|
||||
},
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof AppError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BookService } from "@/lib/services/book.service";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import { getErrorMessage } from "@/utils/errors";
|
||||
import { findHttpStatus } from "@/utils/image-errors";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -16,6 +17,26 @@ export async function GET(
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la récupération de la miniature du livre:", error);
|
||||
|
||||
// Chercher un status HTTP 404 dans la chaîne d'erreurs
|
||||
const httpStatus = findHttpStatus(error);
|
||||
|
||||
if (httpStatus === 404) {
|
||||
const bookId: string = (await params).bookId;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`📷 Thumbnail not found for book: ${bookId}`);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: ERROR_CODES.IMAGE.FETCH_ERROR,
|
||||
name: "Image not found",
|
||||
message: "Image not found",
|
||||
},
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof AppError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SeriesService } from "@/lib/services/series.service";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import { getErrorMessage } from "@/utils/errors";
|
||||
import { findHttpStatus } from "@/utils/image-errors";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -18,6 +19,26 @@ export async function GET(
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la récupération de la couverture de la série:", error);
|
||||
|
||||
// Chercher un status HTTP 404 dans la chaîne d'erreurs
|
||||
const httpStatus = findHttpStatus(error);
|
||||
|
||||
if (httpStatus === 404) {
|
||||
const seriesId: string = (await params).seriesId;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`📷 First page image not found for series: ${seriesId}`);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: ERROR_CODES.IMAGE.FETCH_ERROR,
|
||||
name: "Image not found",
|
||||
message: "Image not found",
|
||||
},
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof AppError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SeriesService } from "@/lib/services/series.service";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import { getErrorMessage } from "@/utils/errors";
|
||||
import { findHttpStatus } from "@/utils/image-errors";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -15,6 +16,26 @@ export async function GET(
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la récupération de la miniature de la série:", error);
|
||||
|
||||
// Chercher un status HTTP 404 dans la chaîne d'erreurs
|
||||
const httpStatus = findHttpStatus(error);
|
||||
|
||||
if (httpStatus === 404) {
|
||||
const seriesId: string = (await params).seriesId;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`📷 Image not found for series: ${seriesId}`);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: ERROR_CODES.IMAGE.FETCH_ERROR,
|
||||
name: "Image not found",
|
||||
message: "Image not found",
|
||||
},
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof AppError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Suspense } from "react";
|
||||
import { ClientBookPage } from "@/components/reader/ClientBookPage";
|
||||
import { BookSkeleton } from "@/components/skeletons/BookSkeleton";
|
||||
import { withPageTiming } from "@/lib/hoc/withPageTiming";
|
||||
|
||||
async function BookPage({ params }: { params: { bookId: string } }) {
|
||||
export default async function BookPage({ params }: { params: Promise<{ bookId: string }> }) {
|
||||
const { bookId } = await params;
|
||||
|
||||
return (
|
||||
@@ -12,5 +11,3 @@ async function BookPage({ params }: { params: { bookId: string } }) {
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default withPageTiming("BookPage", BookPage);
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { DownloadManager } from "@/components/downloads/DownloadManager";
|
||||
import { withPageTiming } from "@/lib/hoc/withPageTiming";
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function DownloadsPage() {
|
||||
export default function DownloadsPage() {
|
||||
return (
|
||||
<>
|
||||
<DownloadManager />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withPageTiming("DownloadsPage", DownloadsPage);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LoginContent } from "./LoginContent";
|
||||
import { withPageTiming } from "@/lib/hoc/withPageTiming";
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{
|
||||
@@ -13,7 +12,6 @@ export const metadata: Metadata = {
|
||||
description: "Connectez-vous à votre compte StripStream",
|
||||
};
|
||||
|
||||
async function LoginPage({ searchParams }: PageProps) {
|
||||
export default async function LoginPage({ searchParams }: PageProps) {
|
||||
return <LoginContent searchParams={await searchParams} />;
|
||||
}
|
||||
export default withPageTiming("LoginPage", LoginPage);
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
X,
|
||||
Database,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
Clock,
|
||||
CircleDot,
|
||||
Layout,
|
||||
RefreshCw,
|
||||
Globe,
|
||||
Filter,
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
import type { RequestTiming } from "@/types/debug";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDebug } from "@/contexts/DebugContext";
|
||||
|
||||
function formatTime(timestamp: string) {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString("fr-FR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(duration: number) {
|
||||
return Math.round(duration);
|
||||
}
|
||||
|
||||
type FilterType = "all" | "current-page" | "api" | "cache" | "mongodb" | "page-render";
|
||||
|
||||
export function DebugInfo() {
|
||||
const { logs, setLogs, clearLogs, isRefreshing, setIsRefreshing } = useDebug();
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [filter, setFilter] = useState<FilterType>("all");
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const response = await fetch("/api/debug");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setLogs(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la récupération des logs:", error);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour déterminer si une requête appartient à la page courante
|
||||
const isCurrentPageRequest = (log: RequestTiming): boolean => {
|
||||
if (log.pageRender) {
|
||||
return log.pageRender.page === pathname;
|
||||
}
|
||||
// Pour les requêtes API, on considère qu'elles appartiennent à la page courante
|
||||
// si elles ont été faites récemment (dans les 30 dernières secondes)
|
||||
const logTime = new Date(log.timestamp).getTime();
|
||||
const now = Date.now();
|
||||
return now - logTime < 30000; // 30 secondes
|
||||
};
|
||||
|
||||
// Filtrer les logs selon le filtre sélectionné
|
||||
const filteredLogs = logs.filter((log) => {
|
||||
switch (filter) {
|
||||
case "current-page":
|
||||
return isCurrentPageRequest(log);
|
||||
case "api":
|
||||
return !log.fromCache && !log.mongoAccess && !log.pageRender;
|
||||
case "cache":
|
||||
return log.fromCache;
|
||||
case "mongodb":
|
||||
return log.mongoAccess;
|
||||
case "page-render":
|
||||
return log.pageRender;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
const sortedLogs = [...filteredLogs].reverse();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-4 right-4 bg-zinc-900/90 backdrop-blur-md border border-zinc-700 rounded-lg shadow-lg p-4 text-zinc-100 z-50 ${
|
||||
isMinimized ? "w-auto" : "w-[800px] max-h-[50vh] overflow-auto"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4 sticky top-0 bg-zinc-900/90 backdrop-blur-md pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-bold text-lg">{t("debug.title")}</h2>
|
||||
{!isMinimized && (
|
||||
<span className="text-xs text-zinc-400">
|
||||
{sortedLogs.length} {t("debug.entries", { count: sortedLogs.length })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isMinimized && (
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`hover:bg-zinc-700/80 hover:backdrop-blur-md rounded-full p-1.5 ${showFilters ? "bg-zinc-700/80 backdrop-blur-md" : ""}`}
|
||||
aria-label="Filtres"
|
||||
>
|
||||
<Filter className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
className="hover:bg-zinc-700/80 hover:backdrop-blur-md rounded-full p-1.5"
|
||||
aria-label={t("debug.actions.refresh")}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`h-5 w-5 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
className="hover:bg-zinc-700/80 hover:backdrop-blur-md rounded-full p-1.5"
|
||||
aria-label={t(isMinimized ? "debug.actions.maximize" : "debug.actions.minimize")}
|
||||
>
|
||||
{isMinimized ? <Maximize2 className="h-5 w-5" /> : <Minimize2 className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={clearLogs}
|
||||
className="hover:bg-zinc-700/80 hover:backdrop-blur-md rounded-full p-1.5"
|
||||
aria-label={t("debug.actions.clear")}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMinimized && showFilters && (
|
||||
<div className="mb-4 p-3 bg-zinc-800/80 backdrop-blur-md rounded-lg">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ key: "all", label: "Toutes", icon: Calendar },
|
||||
{ key: "current-page", label: "Page courante", icon: Layout },
|
||||
{ key: "api", label: "API", icon: Globe },
|
||||
{ key: "cache", label: "Cache", icon: Database },
|
||||
{ key: "mongodb", label: "MongoDB", icon: CircleDot },
|
||||
{ key: "page-render", label: "Rendu", icon: Layout },
|
||||
].map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilter(key as FilterType)}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-full text-xs transition-colors ${
|
||||
filter === key
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-zinc-700 text-zinc-300 hover:bg-zinc-600"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMinimized && (
|
||||
<div className="space-y-3">
|
||||
{sortedLogs.length === 0 ? (
|
||||
<p className="text-sm opacity-75">{t("debug.noRequests")}</p>
|
||||
) : (
|
||||
sortedLogs.map((log, index) => {
|
||||
const isCurrentPage = isCurrentPageRequest(log);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`text-sm space-y-1.5 p-2 rounded border-l-2 ${
|
||||
isCurrentPage
|
||||
? "bg-blue-900/20 border-blue-500"
|
||||
: "bg-zinc-800 border-zinc-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{log.fromCache && (
|
||||
<div
|
||||
title={t("debug.tooltips.cache", { type: log.cacheType || "DEFAULT" })}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<Database className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
{log.mongoAccess && (
|
||||
<div
|
||||
title={t("debug.tooltips.mongodb", {
|
||||
operation: log.mongoAccess.operation,
|
||||
})}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<CircleDot className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
{log.pageRender && (
|
||||
<div
|
||||
title={t("debug.tooltips.pageRender", { page: log.pageRender.page })}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<Layout className="h-4 w-4 text-purple-400" />
|
||||
</div>
|
||||
)}
|
||||
{!log.fromCache && !log.mongoAccess && !log.pageRender && (
|
||||
<div title={t("debug.tooltips.apiCall")} className="flex-shrink-0">
|
||||
<Globe className="h-4 w-4 text-rose-400" />
|
||||
</div>
|
||||
)}
|
||||
<span className="font-medium truncate" title={log.url}>
|
||||
{log.url}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div
|
||||
className="flex items-center gap-1 text-zinc-400"
|
||||
title={t("debug.tooltips.logTime")}
|
||||
>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatTime(log.timestamp)}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`${
|
||||
log.pageRender
|
||||
? "text-purple-400"
|
||||
: log.mongoAccess
|
||||
? "text-blue-400"
|
||||
: log.fromCache
|
||||
? "text-emerald-400"
|
||||
: "text-rose-400"
|
||||
}`}
|
||||
>
|
||||
{formatDuration(log.duration)}ms
|
||||
</span>
|
||||
{log.mongoAccess && (
|
||||
<span className="text-blue-400" title={t("debug.tooltips.mongoAccess")}>
|
||||
+{formatDuration(log.mongoAccess.duration)}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1.5 bg-zinc-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${
|
||||
log.pageRender
|
||||
? "bg-purple-500"
|
||||
: log.fromCache
|
||||
? "bg-emerald-500"
|
||||
: "bg-rose-500"
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.min((log.duration / 1000) * 100, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
{log.mongoAccess && (
|
||||
<div
|
||||
className="h-full bg-blue-500"
|
||||
style={{
|
||||
width: `${Math.min((log.mongoAccess.duration / 1000) * 100, 100)}%`,
|
||||
marginTop: "-6px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
||||
import { DebugInfo } from "./DebugInfo";
|
||||
|
||||
export function DebugWrapper() {
|
||||
const { preferences } = usePreferences();
|
||||
if (!preferences.debug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <DebugInfo />;
|
||||
}
|
||||
@@ -9,8 +9,6 @@ import { Toaster } from "@/components/ui/toaster";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { registerServiceWorker } from "@/lib/registerSW";
|
||||
import { NetworkStatus } from "../ui/NetworkStatus";
|
||||
import { DebugWrapper } from "@/components/debug/DebugWrapper";
|
||||
import { DebugProvider } from "@/contexts/DebugContext";
|
||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
||||
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
||||
|
||||
@@ -94,7 +92,6 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<DebugProvider>
|
||||
<div className="relative min-h-screen bg-background" style={backgroundStyle}>
|
||||
{!isPublicRoute && <Header onToggleSidebar={handleToggleSidebar} />}
|
||||
{!isPublicRoute && (
|
||||
@@ -110,9 +107,7 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
|
||||
<InstallPWA />
|
||||
<Toaster />
|
||||
<NetworkStatus />
|
||||
<DebugWrapper />
|
||||
</div>
|
||||
</DebugProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,34 +75,6 @@ export function DisplaySettings() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="debug-mode">{t("settings.display.debugMode.label")}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.display.debugMode.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="debug-mode"
|
||||
checked={preferences.debug}
|
||||
onCheckedChange={async (checked) => {
|
||||
try {
|
||||
await updatePreferences({ debug: checked });
|
||||
toast({
|
||||
title: t("settings.title"),
|
||||
description: t("settings.komga.messages.configSaved"),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur détaillée:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settings.error.title"),
|
||||
description: t("settings.error.message"),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -87,11 +87,6 @@ export const ERROR_CODES = {
|
||||
INVALID_TOKEN: "MIDDLEWARE_INVALID_TOKEN",
|
||||
INVALID_SESSION: "MIDDLEWARE_INVALID_SESSION",
|
||||
},
|
||||
DEBUG: {
|
||||
FETCH_ERROR: "DEBUG_FETCH_ERROR",
|
||||
SAVE_ERROR: "DEBUG_SAVE_ERROR",
|
||||
CLEAR_ERROR: "DEBUG_CLEAR_ERROR",
|
||||
},
|
||||
CLIENT: {
|
||||
FETCH_ERROR: "CLIENT_FETCH_ERROR",
|
||||
NETWORK_ERROR: "CLIENT_NETWORK_ERROR",
|
||||
|
||||
@@ -85,11 +85,6 @@ export const ERROR_MESSAGES: Record<string, string> = {
|
||||
[ERROR_CODES.CONFIG.TTL_SAVE_ERROR]: "⏱️ Error saving TTL configuration",
|
||||
[ERROR_CODES.CONFIG.TTL_FETCH_ERROR]: "⏱️ Error fetching TTL configuration",
|
||||
|
||||
// Debug
|
||||
[ERROR_CODES.DEBUG.FETCH_ERROR]: "🔍 Error fetching logs",
|
||||
[ERROR_CODES.DEBUG.SAVE_ERROR]: "💾 Error saving log",
|
||||
[ERROR_CODES.DEBUG.CLEAR_ERROR]: "🧹 Error clearing logs",
|
||||
|
||||
// Client
|
||||
[ERROR_CODES.CLIENT.FETCH_ERROR]: "🌐 Error during request",
|
||||
[ERROR_CODES.CLIENT.NETWORK_ERROR]: "📡 Network connection error",
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import type { RequestTiming } from "@/types/debug";
|
||||
import { usePreferences } from "./PreferencesContext";
|
||||
|
||||
interface DebugContextType {
|
||||
logs: RequestTiming[];
|
||||
setLogs: (logs: RequestTiming[]) => void;
|
||||
addLog: (log: RequestTiming) => void;
|
||||
clearLogs: () => void;
|
||||
isRefreshing: boolean;
|
||||
setIsRefreshing: (refreshing: boolean) => void;
|
||||
}
|
||||
|
||||
const DebugContext = createContext<DebugContextType | undefined>(undefined);
|
||||
|
||||
interface DebugProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DebugProvider({ children }: DebugProviderProps) {
|
||||
const [logs, setLogs] = useState<RequestTiming[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const { preferences } = usePreferences();
|
||||
|
||||
const addLog = (log: RequestTiming) => {
|
||||
setLogs(prevLogs => {
|
||||
// Éviter les doublons basés sur l'URL et le timestamp
|
||||
const exists = prevLogs.some(existingLog =>
|
||||
existingLog.url === log.url && existingLog.timestamp === log.timestamp
|
||||
);
|
||||
if (exists) return prevLogs;
|
||||
|
||||
return [...prevLogs, log].sort((a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const clearLogs = async () => {
|
||||
try {
|
||||
// Vider le fichier côté serveur
|
||||
await fetch("/api/debug", { method: "DELETE" });
|
||||
// Vider le state côté client
|
||||
setLogs([]);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la suppression des logs:", error);
|
||||
// Même en cas d'erreur, vider le state côté client
|
||||
setLogs([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Charger les logs au montage du provider et les rafraîchir périodiquement
|
||||
useEffect(() => {
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
// Ne pas faire de requête si le debug n'est pas activé
|
||||
if (!preferences.debug) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRefreshing(true);
|
||||
const debugResponse = await fetch("/api/debug");
|
||||
if (debugResponse.ok) {
|
||||
const serverLogs = await debugResponse.json();
|
||||
setLogs(serverLogs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la récupération des logs:", error);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLogs();
|
||||
|
||||
// Rafraîchir toutes les 10 secondes (moins fréquent pour éviter les conflits)
|
||||
const interval = setInterval(fetchLogs, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [preferences.debug]);
|
||||
|
||||
return (
|
||||
<DebugContext.Provider
|
||||
value={{
|
||||
logs,
|
||||
setLogs,
|
||||
addLog,
|
||||
clearLogs,
|
||||
isRefreshing,
|
||||
setIsRefreshing
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DebugContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDebug() {
|
||||
const context = useContext(DebugContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useDebug must be used within a DebugProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -77,10 +77,6 @@
|
||||
"unreadFilter": {
|
||||
"label": "Default Unread Filter",
|
||||
"description": "Show only unread series by default"
|
||||
},
|
||||
"debugMode": {
|
||||
"label": "Debug mode",
|
||||
"description": "Show debug information in the interface"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
@@ -388,10 +384,6 @@
|
||||
"MIDDLEWARE_INVALID_TOKEN": "Invalid authentication token",
|
||||
"MIDDLEWARE_INVALID_SESSION": "Invalid session",
|
||||
|
||||
"DEBUG_FETCH_ERROR": "Error fetching debug logs",
|
||||
"DEBUG_SAVE_ERROR": "Error saving debug logs",
|
||||
"DEBUG_CLEAR_ERROR": "Error clearing debug logs",
|
||||
|
||||
"CLIENT_FETCH_ERROR": "Error fetching data",
|
||||
"CLIENT_NETWORK_ERROR": "Network error",
|
||||
"CLIENT_REQUEST_FAILED": "Request failed",
|
||||
@@ -434,25 +426,5 @@
|
||||
},
|
||||
"common": {
|
||||
"retry": "Retry"
|
||||
},
|
||||
"debug": {
|
||||
"title": "DEBUG",
|
||||
"entries": "entry",
|
||||
"entries_plural": "entries",
|
||||
"noRequests": "No recorded requests",
|
||||
"actions": {
|
||||
"refresh": "Refresh logs",
|
||||
"maximize": "Maximize",
|
||||
"minimize": "Minimize",
|
||||
"clear": "Clear logs"
|
||||
},
|
||||
"tooltips": {
|
||||
"cache": "Cache: {{type}}",
|
||||
"mongodb": "MongoDB: {{operation}}",
|
||||
"pageRender": "Page Render: {{page}}",
|
||||
"apiCall": "API Call",
|
||||
"logTime": "Log time",
|
||||
"mongoAccess": "MongoDB access time"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,10 +77,6 @@
|
||||
"unreadFilter": {
|
||||
"label": "Filtre \"À lire\" par défaut",
|
||||
"description": "Afficher uniquement les séries non lues par défaut"
|
||||
},
|
||||
"debugMode": {
|
||||
"label": "Mode debug",
|
||||
"description": "Afficher les informations de debug dans l'interface"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
@@ -386,10 +382,6 @@
|
||||
"MIDDLEWARE_INVALID_TOKEN": "Jeton d'authentification invalide",
|
||||
"MIDDLEWARE_INVALID_SESSION": "Session invalide",
|
||||
|
||||
"DEBUG_FETCH_ERROR": "Erreur lors de la récupération des logs de debug",
|
||||
"DEBUG_SAVE_ERROR": "Erreur lors de la sauvegarde des logs de debug",
|
||||
"DEBUG_CLEAR_ERROR": "Erreur lors de la suppression des logs de debug",
|
||||
|
||||
"CLIENT_FETCH_ERROR": "Erreur lors de la récupération des données",
|
||||
"CLIENT_NETWORK_ERROR": "Erreur réseau",
|
||||
"CLIENT_REQUEST_FAILED": "La requête a échoué",
|
||||
@@ -436,25 +428,5 @@
|
||||
},
|
||||
"common": {
|
||||
"retry": "Réessayer"
|
||||
},
|
||||
"debug": {
|
||||
"title": "DEBUG",
|
||||
"entries": "entrée",
|
||||
"entries_plural": "entrées",
|
||||
"noRequests": "Aucune requête enregistrée",
|
||||
"actions": {
|
||||
"refresh": "Rafraîchir les logs",
|
||||
"maximize": "Agrandir",
|
||||
"minimize": "Minimiser",
|
||||
"clear": "Effacer les logs"
|
||||
},
|
||||
"tooltips": {
|
||||
"cache": "Cache: {{type}}",
|
||||
"mongodb": "MongoDB: {{operation}}",
|
||||
"pageRender": "Page Render: {{page}}",
|
||||
"apiCall": "API Call",
|
||||
"logTime": "Heure du log",
|
||||
"mongoAccess": "Temps d'accès MongoDB"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ReactElement } from "react";
|
||||
import { DebugService } from "@/lib/services/debug.service";
|
||||
|
||||
type PageComponent = (props: any) => Promise<ReactElement> | ReactElement;
|
||||
|
||||
export function withPageTiming(pageName: string, Component: PageComponent) {
|
||||
return async function PageWithTiming(props: any) {
|
||||
const start = performance.now();
|
||||
const result = await Promise.resolve(Component(props));
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Ensure params is awaited before using it
|
||||
const params = props.params ? await Promise.resolve(props.params) : {};
|
||||
|
||||
// Only log if debug is enabled and user is authenticated
|
||||
try {
|
||||
await DebugService.logPageRender(pageName + JSON.stringify(params), duration);
|
||||
} catch {
|
||||
// Silently fail if user is not authenticated or debug is disabled
|
||||
// This prevents errors on public pages like /login
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import type { KomgaConfig } from "@/types/komga";
|
||||
import type { ServerCacheService } from "./server-cache.service";
|
||||
import { DebugService } from "./debug.service";
|
||||
import { RequestMonitorService } from "./request-monitor.service";
|
||||
import { RequestQueueService } from "./request-queue.service";
|
||||
|
||||
@@ -109,8 +108,6 @@ export abstract class BaseApiService {
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
// Timeout de 60 secondes au lieu de 10 par défaut
|
||||
const timeoutMs = 60000;
|
||||
const controller = new AbortController();
|
||||
@@ -149,19 +146,27 @@ export abstract class BaseApiService {
|
||||
family: 4,
|
||||
});
|
||||
}
|
||||
|
||||
// Retry automatique sur timeout de connexion (cold start)
|
||||
if (fetchError?.cause?.code === 'UND_ERR_CONNECT_TIMEOUT') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`⏱️ Connection timeout for ${url}. Retrying once (cold start)...`);
|
||||
|
||||
return await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
// @ts-ignore - undici-specific options
|
||||
connectTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
throw fetchError;
|
||||
}
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
const endTime = performance.now();
|
||||
|
||||
// Logger la requête côté serveur
|
||||
await DebugService.logRequest({
|
||||
url: url,
|
||||
startTime,
|
||||
endTime,
|
||||
fromCache: false, // Côté serveur, on ne peut pas détecter le cache navigateur
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AppError(ERROR_CODES.KOMGA.HTTP_ERROR, {
|
||||
@@ -172,16 +177,6 @@ export abstract class BaseApiService {
|
||||
|
||||
return options.isImage ? (response as T) : response.json();
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
|
||||
// Logger l'erreur côté serveur
|
||||
await DebugService.logRequest({
|
||||
url: url,
|
||||
startTime,
|
||||
endTime,
|
||||
fromCache: false,
|
||||
});
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
@@ -99,7 +99,7 @@ export class BookService extends BaseApiService {
|
||||
const arrayBuffer = response.buffer.buffer.slice(
|
||||
response.buffer.byteOffset,
|
||||
response.buffer.byteOffset + response.buffer.byteLength
|
||||
);
|
||||
) as ArrayBuffer;
|
||||
|
||||
return new Response(arrayBuffer, {
|
||||
headers: {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { DebugService } from "./debug.service";
|
||||
import { getCurrentUser } from "../auth-utils";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
@@ -50,12 +49,10 @@ export class ConfigDBService {
|
||||
try {
|
||||
const user: User | null = await this.getCurrentUser();
|
||||
|
||||
return DebugService.measureMongoOperation("getConfig", async () => {
|
||||
const config = await prisma.komgaConfig.findUnique({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
return config as KomgaConfig | null;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
@@ -68,12 +65,10 @@ export class ConfigDBService {
|
||||
try {
|
||||
const user: User | null = await this.getCurrentUser();
|
||||
|
||||
return DebugService.measureMongoOperation("getTTLConfig", async () => {
|
||||
const config = await prisma.tTLConfig.findUnique({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
return config as TTLConfig | null;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
@@ -86,7 +81,6 @@ export class ConfigDBService {
|
||||
try {
|
||||
const user: User | null = await this.getCurrentUser();
|
||||
|
||||
return DebugService.measureMongoOperation("saveTTLConfig", async () => {
|
||||
const config = await prisma.tTLConfig.upsert({
|
||||
where: { userId: user.id },
|
||||
update: {
|
||||
@@ -109,7 +103,6 @@ export class ConfigDBService {
|
||||
});
|
||||
|
||||
return config as TTLConfig;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import type { RequestTiming } from "@/types/debug";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import { getCurrentUser } from "../auth-utils";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
|
||||
export type { RequestTiming };
|
||||
|
||||
export class DebugService {
|
||||
private static writeQueues = new Map<string, Promise<void>>();
|
||||
|
||||
private static async getCurrentUserId(): Promise<string> {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
throw new AppError(ERROR_CODES.AUTH.UNAUTHENTICATED);
|
||||
}
|
||||
return user.id;
|
||||
}
|
||||
|
||||
private static getLogFilePath(userId: string): string {
|
||||
return path.join(process.cwd(), "debug-logs", `${userId}.json`);
|
||||
}
|
||||
|
||||
private static async ensureDebugDir(): Promise<void> {
|
||||
const debugDir = path.join(process.cwd(), "debug-logs");
|
||||
try {
|
||||
await fs.access(debugDir);
|
||||
} catch {
|
||||
await fs.mkdir(debugDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
private static async isDebugEnabled(): Promise<boolean> {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
const preferences = await PreferencesService.getPreferences();
|
||||
return preferences.debug === true;
|
||||
}
|
||||
|
||||
private static async readLogs(filePath: string): Promise<RequestTiming[]> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
// Essayer de lire un fichier de sauvegarde
|
||||
try {
|
||||
const backupPath = filePath + '.backup';
|
||||
const backupContent = await fs.readFile(backupPath, "utf-8");
|
||||
return JSON.parse(backupContent);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async writeLogs(filePath: string, logs: RequestTiming[]): Promise<void> {
|
||||
// Obtenir la queue existante ou créer une nouvelle
|
||||
const existingQueue = this.writeQueues.get(filePath);
|
||||
|
||||
// Créer une nouvelle promesse qui attend la queue précédente
|
||||
const newQueue = existingQueue
|
||||
? existingQueue.then(() => this.performAppend(filePath, logs))
|
||||
: this.performAppend(filePath, logs);
|
||||
|
||||
// Mettre à jour la queue
|
||||
this.writeQueues.set(filePath, newQueue);
|
||||
|
||||
try {
|
||||
await newQueue;
|
||||
} finally {
|
||||
// Nettoyer la queue si c'est la dernière opération
|
||||
if (this.writeQueues.get(filePath) === newQueue) {
|
||||
this.writeQueues.delete(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async performAppend(filePath: string, logs: RequestTiming[]): Promise<void> {
|
||||
try {
|
||||
// Lire le fichier existant
|
||||
const existingLogs = await this.readLogs(filePath);
|
||||
|
||||
// Fusionner avec les nouveaux logs
|
||||
const allLogs = [...existingLogs, ...logs];
|
||||
|
||||
// Garder seulement les 1000 derniers logs
|
||||
const trimmedLogs = allLogs.slice(-1000);
|
||||
|
||||
// Créer une sauvegarde avant d'écrire
|
||||
try {
|
||||
await fs.copyFile(filePath, filePath + '.backup');
|
||||
} catch {
|
||||
// Ignorer si le fichier n'existe pas encore
|
||||
}
|
||||
|
||||
// Écrire le fichier complet (c'est nécessaire pour maintenir l'ordre chronologique)
|
||||
await fs.writeFile(filePath, JSON.stringify(trimmedLogs, null, 2), { flag: 'w' });
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors de l'écriture des logs pour ${filePath}:`, error);
|
||||
// Ne pas relancer l'erreur pour éviter de casser l'application
|
||||
}
|
||||
}
|
||||
|
||||
private static async appendLog(filePath: string, log: RequestTiming): Promise<void> {
|
||||
// Obtenir la queue existante ou créer une nouvelle
|
||||
const existingQueue = this.writeQueues.get(filePath);
|
||||
|
||||
// Créer une nouvelle promesse qui attend la queue précédente
|
||||
const newQueue = existingQueue
|
||||
? existingQueue.then(() => this.performSingleAppend(filePath, log))
|
||||
: this.performSingleAppend(filePath, log);
|
||||
|
||||
// Mettre à jour la queue
|
||||
this.writeQueues.set(filePath, newQueue);
|
||||
|
||||
try {
|
||||
await newQueue;
|
||||
} finally {
|
||||
// Nettoyer la queue si c'est la dernière opération
|
||||
if (this.writeQueues.get(filePath) === newQueue) {
|
||||
this.writeQueues.delete(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async performSingleAppend(filePath: string, log: RequestTiming): Promise<void> {
|
||||
try {
|
||||
// Lire le fichier existant
|
||||
const existingLogs = await this.readLogs(filePath);
|
||||
|
||||
// Vérifier les doublons avec des tolérances différentes selon le type
|
||||
const isPageRender = log.pageRender !== undefined;
|
||||
const timeTolerance = isPageRender ? 500 : 50; // 500ms pour les rendus, 50ms pour les requêtes
|
||||
|
||||
const exists = existingLogs.some(existingLog =>
|
||||
existingLog.url === log.url &&
|
||||
Math.abs(existingLog.duration - log.duration) < 10 && // Durée similaire (10ms de tolérance)
|
||||
Math.abs(new Date(existingLog.timestamp).getTime() - new Date(log.timestamp).getTime()) < timeTolerance
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
// Ajouter le nouveau log
|
||||
const allLogs = [...existingLogs, log];
|
||||
|
||||
// Garder seulement les 1000 derniers logs
|
||||
const trimmedLogs = allLogs.slice(-1000);
|
||||
|
||||
// Écrire le fichier complet avec gestion d'erreur
|
||||
await fs.writeFile(filePath, JSON.stringify(trimmedLogs, null, 2), { flag: 'w' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors de l'écriture du log pour ${filePath}:`, error);
|
||||
// Ne pas relancer l'erreur pour éviter de casser l'application
|
||||
}
|
||||
}
|
||||
|
||||
private static createTiming(
|
||||
url: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
fromCache: boolean,
|
||||
additionalData?: Partial<RequestTiming>
|
||||
): RequestTiming {
|
||||
return {
|
||||
url,
|
||||
startTime,
|
||||
endTime,
|
||||
duration: endTime - startTime,
|
||||
timestamp: new Date().toISOString(),
|
||||
fromCache,
|
||||
...additionalData,
|
||||
};
|
||||
}
|
||||
|
||||
static async logRequest(timing: Omit<RequestTiming, "duration" | "timestamp">) {
|
||||
try {
|
||||
if (!(await this.isDebugEnabled())) return;
|
||||
|
||||
const userId = await this.getCurrentUserId();
|
||||
await this.ensureDebugDir();
|
||||
const filePath = this.getLogFilePath(userId);
|
||||
|
||||
const newTiming = this.createTiming(
|
||||
timing.url,
|
||||
timing.startTime,
|
||||
timing.endTime,
|
||||
timing.fromCache,
|
||||
{
|
||||
cacheType: timing.cacheType,
|
||||
mongoAccess: timing.mongoAccess,
|
||||
pageRender: timing.pageRender,
|
||||
}
|
||||
);
|
||||
|
||||
// Utiliser un système d'append atomique
|
||||
await this.appendLog(filePath, newTiming);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de l'enregistrement du log:", error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getRequestLogs(): Promise<RequestTiming[]> {
|
||||
try {
|
||||
const userId = await this.getCurrentUserId();
|
||||
const filePath = this.getLogFilePath(userId);
|
||||
return await this.readLogs(filePath);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static async clearLogs(): Promise<void> {
|
||||
try {
|
||||
const userId = await this.getCurrentUserId();
|
||||
const filePath = this.getLogFilePath(userId);
|
||||
await this.clearFile(filePath);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async clearFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
// Obtenir la queue existante ou créer une nouvelle
|
||||
const existingQueue = this.writeQueues.get(filePath);
|
||||
|
||||
// Créer une nouvelle promesse qui attend la queue précédente
|
||||
const newQueue = existingQueue
|
||||
? existingQueue.then(() => this.performClear(filePath))
|
||||
: this.performClear(filePath);
|
||||
|
||||
// Mettre à jour la queue
|
||||
this.writeQueues.set(filePath, newQueue);
|
||||
|
||||
try {
|
||||
await newQueue;
|
||||
} finally {
|
||||
// Nettoyer la queue si c'est la dernière opération
|
||||
if (this.writeQueues.get(filePath) === newQueue) {
|
||||
this.writeQueues.delete(filePath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors du vidage du fichier ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private static async performClear(filePath: string): Promise<void> {
|
||||
try {
|
||||
// Créer une sauvegarde avant de vider
|
||||
try {
|
||||
await fs.copyFile(filePath, filePath + '.backup');
|
||||
} catch {
|
||||
// Ignorer si le fichier n'existe pas encore
|
||||
}
|
||||
|
||||
// Écrire un tableau vide pour vider le fichier
|
||||
await fs.writeFile(filePath, JSON.stringify([], null, 2), { flag: 'w' });
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors du vidage du fichier ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async logPageRender(page: string, duration: number) {
|
||||
try {
|
||||
if (!(await this.isDebugEnabled())) return;
|
||||
|
||||
const userId = await this.getCurrentUserId();
|
||||
await this.ensureDebugDir();
|
||||
const filePath = this.getLogFilePath(userId);
|
||||
|
||||
const now = performance.now();
|
||||
const newTiming = this.createTiming(`Page Render: ${page}`, now - duration, now, false, {
|
||||
pageRender: { page, duration },
|
||||
});
|
||||
|
||||
// Utiliser le même système d'append atomique
|
||||
await this.appendLog(filePath, newTiming);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de l'enregistrement du log de rendu:", error);
|
||||
}
|
||||
}
|
||||
|
||||
static async measureMongoOperation<T>(operation: string, func: () => Promise<T>): Promise<T> {
|
||||
const startTime = performance.now();
|
||||
try {
|
||||
if (!(await this.isDebugEnabled())) {
|
||||
return func();
|
||||
}
|
||||
|
||||
const result = await func();
|
||||
const endTime = performance.now();
|
||||
|
||||
await this.logRequest({
|
||||
url: `MongoDB: ${operation}`,
|
||||
startTime,
|
||||
endTime,
|
||||
fromCache: false,
|
||||
mongoAccess: {
|
||||
operation,
|
||||
duration: endTime - startTime,
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
await this.logRequest({
|
||||
url: `MongoDB Error: ${operation}`,
|
||||
startTime,
|
||||
endTime,
|
||||
fromCache: false,
|
||||
mongoAccess: {
|
||||
operation,
|
||||
duration: endTime - startTime,
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { DebugService } from "./debug.service";
|
||||
import { getCurrentUser } from "../auth-utils";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
@@ -30,7 +29,6 @@ export class FavoriteService {
|
||||
try {
|
||||
const user = await this.getCurrentUser();
|
||||
|
||||
return DebugService.measureMongoOperation("isFavorite", async () => {
|
||||
const favorite = await prisma.favorite.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -38,7 +36,6 @@ export class FavoriteService {
|
||||
},
|
||||
});
|
||||
return !!favorite;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la vérification du favori:", error);
|
||||
return false;
|
||||
@@ -52,7 +49,6 @@ export class FavoriteService {
|
||||
try {
|
||||
const user = await this.getCurrentUser();
|
||||
|
||||
await DebugService.measureMongoOperation("addToFavorites", async () => {
|
||||
await prisma.favorite.upsert({
|
||||
where: {
|
||||
userId_seriesId: {
|
||||
@@ -66,7 +62,6 @@ export class FavoriteService {
|
||||
seriesId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
this.dispatchFavoritesChanged();
|
||||
} catch (error) {
|
||||
@@ -81,14 +76,12 @@ export class FavoriteService {
|
||||
try {
|
||||
const user = await this.getCurrentUser();
|
||||
|
||||
await DebugService.measureMongoOperation("removeFromFavorites", async () => {
|
||||
await prisma.favorite.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
seriesId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
this.dispatchFavoritesChanged();
|
||||
} catch (error) {
|
||||
@@ -102,19 +95,16 @@ export class FavoriteService {
|
||||
static async getAllFavoriteIds(): Promise<string[]> {
|
||||
const user = await this.getCurrentUser();
|
||||
|
||||
return DebugService.measureMongoOperation("getAllFavoriteIds", async () => {
|
||||
const favorites = await prisma.favorite.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { seriesId: true },
|
||||
});
|
||||
return favorites.map((favorite) => favorite.seriesId);
|
||||
});
|
||||
}
|
||||
|
||||
static async addFavorite(seriesId: string) {
|
||||
const user = await this.getCurrentUser();
|
||||
|
||||
return DebugService.measureMongoOperation("addFavorite", async () => {
|
||||
const favorite = await prisma.favorite.upsert({
|
||||
where: {
|
||||
userId_seriesId: {
|
||||
@@ -129,13 +119,11 @@ export class FavoriteService {
|
||||
},
|
||||
});
|
||||
return favorite;
|
||||
});
|
||||
}
|
||||
|
||||
static async removeFavorite(seriesId: string): Promise<boolean> {
|
||||
const user = await this.getCurrentUser();
|
||||
|
||||
return DebugService.measureMongoOperation("removeFavorite", async () => {
|
||||
const result = await prisma.favorite.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -143,6 +131,5 @@ export class FavoriteService {
|
||||
},
|
||||
});
|
||||
return result.count > 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,9 +84,13 @@ export class LibraryService extends BaseApiService {
|
||||
try {
|
||||
// Récupérer toutes les séries depuis le cache
|
||||
const allSeries = await this.getAllLibrarySeries(libraryId);
|
||||
|
||||
// Filtrer les séries
|
||||
let filteredSeries = allSeries;
|
||||
|
||||
// Filtrer les séries supprimées (fichiers manquants sur le filesystem)
|
||||
filteredSeries = filteredSeries.filter((series) => !series.deleted);
|
||||
|
||||
if (unreadOnly) {
|
||||
filteredSeries = filteredSeries.filter(
|
||||
(series) => series.booksReadCount < series.booksCount
|
||||
@@ -96,7 +100,8 @@ export class LibraryService extends BaseApiService {
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
filteredSeries = filteredSeries.filter((series) =>
|
||||
series.metadata.title.toLowerCase().includes(searchLower)
|
||||
series.metadata.title.toLowerCase().includes(searchLower) ||
|
||||
series.id.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,6 +113,7 @@ export class LibraryService extends BaseApiService {
|
||||
const totalPages = Math.ceil(totalElements / size);
|
||||
const startIndex = page * size;
|
||||
const endIndex = Math.min(startIndex + size, totalElements);
|
||||
|
||||
const paginatedSeries = filteredSeries.slice(startIndex, endIndex);
|
||||
|
||||
// Construire la réponse
|
||||
|
||||
@@ -31,7 +31,6 @@ export class PreferencesService {
|
||||
showThumbnails: preferences.showThumbnails,
|
||||
cacheMode: preferences.cacheMode as "memory" | "file",
|
||||
showOnlyUnread: preferences.showOnlyUnread,
|
||||
debug: preferences.debug,
|
||||
displayMode: preferences.displayMode as UserPreferences["displayMode"],
|
||||
background: preferences.background as unknown as BackgroundPreferences,
|
||||
};
|
||||
@@ -51,7 +50,6 @@ export class PreferencesService {
|
||||
if (preferences.showThumbnails !== undefined) updateData.showThumbnails = preferences.showThumbnails;
|
||||
if (preferences.cacheMode !== undefined) updateData.cacheMode = preferences.cacheMode;
|
||||
if (preferences.showOnlyUnread !== undefined) updateData.showOnlyUnread = preferences.showOnlyUnread;
|
||||
if (preferences.debug !== undefined) updateData.debug = preferences.debug;
|
||||
if (preferences.displayMode !== undefined) updateData.displayMode = preferences.displayMode;
|
||||
if (preferences.background !== undefined) updateData.background = preferences.background;
|
||||
|
||||
@@ -63,7 +61,6 @@ export class PreferencesService {
|
||||
showThumbnails: preferences.showThumbnails ?? defaultPreferences.showThumbnails,
|
||||
cacheMode: preferences.cacheMode ?? defaultPreferences.cacheMode,
|
||||
showOnlyUnread: preferences.showOnlyUnread ?? defaultPreferences.showOnlyUnread,
|
||||
debug: preferences.debug ?? defaultPreferences.debug,
|
||||
displayMode: preferences.displayMode ?? defaultPreferences.displayMode,
|
||||
background: (preferences.background ?? defaultPreferences.background) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
@@ -73,7 +70,6 @@ export class PreferencesService {
|
||||
showThumbnails: updatedPreferences.showThumbnails,
|
||||
cacheMode: updatedPreferences.cacheMode as "memory" | "file",
|
||||
showOnlyUnread: updatedPreferences.showOnlyUnread,
|
||||
debug: updatedPreferences.debug,
|
||||
displayMode: updatedPreferences.displayMode as UserPreferences["displayMode"],
|
||||
background: updatedPreferences.background as unknown as BackgroundPreferences,
|
||||
};
|
||||
|
||||
@@ -14,8 +14,10 @@ class RequestQueue {
|
||||
private activeCount = 0;
|
||||
private maxConcurrent: number;
|
||||
|
||||
constructor(maxConcurrent: number = 5) {
|
||||
this.maxConcurrent = maxConcurrent;
|
||||
constructor(maxConcurrent?: number) {
|
||||
// Lire depuis env ou utiliser la valeur par défaut
|
||||
const envValue = process.env.KOMGA_MAX_CONCURRENT_REQUESTS;
|
||||
this.maxConcurrent = maxConcurrent ?? (envValue ? parseInt(envValue, 10) : 5);
|
||||
}
|
||||
|
||||
async enqueue<T>(execute: () => Promise<T>): Promise<T> {
|
||||
@@ -68,6 +70,10 @@ class RequestQueue {
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance - Limite à 2 requêtes simultanées vers Komga (réduit pour CPU)
|
||||
export const RequestQueueService = new RequestQueue(2);
|
||||
// Singleton instance - Par défaut limite à 2 requêtes simultanées (configurable via KOMGA_MAX_CONCURRENT_REQUESTS)
|
||||
export const RequestQueueService = new RequestQueue(
|
||||
process.env.KOMGA_MAX_CONCURRENT_REQUESTS
|
||||
? parseInt(process.env.KOMGA_MAX_CONCURRENT_REQUESTS, 10)
|
||||
: 2
|
||||
);
|
||||
|
||||
|
||||
@@ -92,6 +92,9 @@ export class SeriesService extends BaseApiService {
|
||||
// Filtrer les livres
|
||||
let filteredBooks = allBooks;
|
||||
|
||||
// Filtrer les livres supprimés (fichiers manquants sur le filesystem)
|
||||
filteredBooks = filteredBooks.filter((book: KomgaBook) => !book.deleted);
|
||||
|
||||
if (unreadOnly) {
|
||||
filteredBooks = filteredBooks.filter(
|
||||
(book: KomgaBook) => !book.readProgress || !book.readProgress.completed
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import { DebugService } from "./debug.service";
|
||||
import { getCurrentUser } from "../auth-utils";
|
||||
|
||||
export type CacheMode = "file" | "memory";
|
||||
@@ -440,14 +439,13 @@ class ServerCacheService {
|
||||
const { data, isStale } = cachedResult;
|
||||
const endTime = performance.now();
|
||||
|
||||
// Log la requête avec l'indication du cache
|
||||
await DebugService.logRequest({
|
||||
url: `[CACHE${isStale ? '-STALE' : ''}] ${key}`,
|
||||
startTime,
|
||||
endTime,
|
||||
fromCache: true,
|
||||
cacheType: type,
|
||||
});
|
||||
// Debug logging
|
||||
if (process.env.CACHE_DEBUG === 'true') {
|
||||
const icon = isStale ? '⚠️' : '✅';
|
||||
const status = isStale ? 'STALE' : 'HIT';
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${icon} [CACHE ${status}] ${key} | ${type} | ${(endTime - startTime).toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
// Si le cache est expiré, revalider en background sans bloquer la réponse
|
||||
if (isStale) {
|
||||
@@ -459,9 +457,21 @@ class ServerCacheService {
|
||||
}
|
||||
|
||||
// Pas de cache du tout, fetch normalement
|
||||
if (process.env.CACHE_DEBUG === 'true') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`❌ [CACHE MISS] ${key} | ${type}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetcher();
|
||||
this.set(cacheKey, data, type);
|
||||
|
||||
const endTime = performance.now();
|
||||
if (process.env.CACHE_DEBUG === 'true') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`💾 [CACHE SET] ${key} | ${type} | ${(endTime - startTime).toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
@@ -482,16 +492,13 @@ class ServerCacheService {
|
||||
const data = await fetcher();
|
||||
this.set(cacheKey, data, type);
|
||||
|
||||
if (process.env.CACHE_DEBUG === 'true') {
|
||||
const endTime = performance.now();
|
||||
await DebugService.logRequest({
|
||||
url: `[REVALIDATE] ${debugKey}`,
|
||||
startTime,
|
||||
endTime,
|
||||
fromCache: false,
|
||||
cacheType: type,
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`🔄 [CACHE REVALIDATE] ${debugKey} | ${type} | ${(endTime - startTime).toFixed(2)}ms`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Background revalidation failed for ${debugKey}:`, error);
|
||||
console.error(`🔴 [CACHE REVALIDATE ERROR] ${debugKey}:`, error);
|
||||
// Ne pas relancer l'erreur car c'est en background
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// Wrapper pour détecter le cache du navigateur
|
||||
export async function fetchWithCacheDetection(url: string, options: RequestInit = {}) {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
const endTime = performance.now();
|
||||
|
||||
// Détecter si la réponse vient du cache du navigateur
|
||||
const fromBrowserCache = response.headers.get('x-cache') === 'HIT' ||
|
||||
response.headers.get('cf-cache-status') === 'HIT' ||
|
||||
(endTime - startTime) < 5; // Si très rapide, probablement du cache
|
||||
|
||||
// Logger la requête seulement si ce n'est pas une requête de debug
|
||||
// Note: La vérification du mode debug se fait côté serveur dans DebugService
|
||||
if (!url.includes('/api/debug')) {
|
||||
try {
|
||||
await fetch("/api/debug", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: url,
|
||||
startTime,
|
||||
endTime,
|
||||
fromCache: fromBrowserCache,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// Ignorer les erreurs de logging
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
|
||||
// Logger aussi les erreurs seulement si ce n'est pas une requête de debug
|
||||
if (!url.includes('/api/debug')) {
|
||||
try {
|
||||
await fetch("/api/debug", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: url,
|
||||
startTime,
|
||||
endTime,
|
||||
fromCache: false,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// Ignorer les erreurs de logging
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { CacheType } from "./cache";
|
||||
|
||||
export interface RequestTiming {
|
||||
url: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
duration: number;
|
||||
timestamp: string;
|
||||
fromCache: boolean;
|
||||
cacheType?: CacheType;
|
||||
mongoAccess?: {
|
||||
operation: string;
|
||||
duration: number;
|
||||
};
|
||||
pageRender?: {
|
||||
page: string;
|
||||
duration: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ export interface KomgaBook {
|
||||
media: BookMedia;
|
||||
metadata: BookMetadata;
|
||||
readProgress: ReadProgress | null;
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
export interface BookMedia {
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface UserPreferences {
|
||||
showThumbnails: boolean;
|
||||
cacheMode: "memory" | "file";
|
||||
showOnlyUnread: boolean;
|
||||
debug: boolean;
|
||||
displayMode: {
|
||||
compact: boolean;
|
||||
itemsPerPage: number;
|
||||
@@ -22,7 +21,6 @@ export const defaultPreferences: UserPreferences = {
|
||||
showThumbnails: true,
|
||||
cacheMode: "memory",
|
||||
showOnlyUnread: false,
|
||||
debug: false,
|
||||
displayMode: {
|
||||
compact: false,
|
||||
itemsPerPage: 20,
|
||||
|
||||
23
src/utils/image-errors.ts
Normal file
23
src/utils/image-errors.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { AppError } from "./errors";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
|
||||
/**
|
||||
* Helper pour trouver le status HTTP dans la chaîne d'erreurs imbriquées
|
||||
* Parcourt récursivement les originalError pour trouver une erreur KOMGA.HTTP_ERROR
|
||||
*/
|
||||
export function findHttpStatus(error: unknown): number | null {
|
||||
if (!(error instanceof AppError)) return null;
|
||||
|
||||
// Si c'est une erreur HTTP, récupérer le status
|
||||
if (error.code === ERROR_CODES.KOMGA.HTTP_ERROR) {
|
||||
return (error.params as any)?.status || null;
|
||||
}
|
||||
|
||||
// Sinon, chercher récursivement dans originalError
|
||||
if (error.originalError) {
|
||||
return findHttpStatus(error.originalError);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user