diff --git a/devbook.md b/devbook.md index 7b235bc..61f263d 100644 --- a/devbook.md +++ b/devbook.md @@ -200,6 +200,17 @@ Application web moderne pour la lecture de BD/mangas/comics via un serveur Komga - [x] API responses - [x] Static assets - [x] Images +- [x] Mode Debug + - [x] Système de logging des requêtes + - [x] Historique des requêtes API + - [x] Mesure des temps de réponse + - [x] Détection des accès cache/MongoDB + - [x] Logging des rendus de pages + - [x] Interface de debug améliorée + - [x] Filtres par type de requête + - [x] Distinction visuelle page courante/historique + - [x] Historique étendu (500 entrées) + - [x] Rafraîchissement intelligent - [x] SEO - [x] Meta tags - [x] Sitemap diff --git a/src/components/debug/DebugInfo.tsx b/src/components/debug/DebugInfo.tsx index 4431da4..00e1356 100644 --- a/src/components/debug/DebugInfo.tsx +++ b/src/components/debug/DebugInfo.tsx @@ -1,5 +1,5 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { usePathname } from "next/navigation"; import { X, @@ -11,9 +11,12 @@ import { Layout, RefreshCw, Globe, + Filter, + Calendar, } from "lucide-react"; import type { CacheType } from "@/lib/services/base-api.service"; import { useTranslation } from "react-i18next"; +import { useDebug } from "@/contexts/DebugContext"; interface RequestTiming { url: string; @@ -45,10 +48,13 @@ function formatDuration(duration: number) { return Math.round(duration); } +type FilterType = "all" | "current-page" | "api" | "cache" | "mongodb" | "page-render"; + export function DebugInfo() { - const [logs, setLogs] = useState([]); + const { logs, setLogs, clearLogs: clearLogsContext, isRefreshing, setIsRefreshing } = useDebug(); const [isMinimized, setIsMinimized] = useState(false); - const [isRefreshing, setIsRefreshing] = useState(false); + const [filter, setFilter] = useState("all"); + const [showFilters, setShowFilters] = useState(false); const pathname = usePathname(); const { t } = useTranslation(); @@ -67,32 +73,46 @@ export function DebugInfo() { } }; - // Rafraîchir les logs au montage et à chaque changement de page - useEffect(() => { - fetchLogs(); - }, [pathname]); - - // Rafraîchir les logs périodiquement si la fenêtre n'est pas minimisée - useEffect(() => { - if (isMinimized) return; - - const interval = setInterval(() => { - fetchLogs(); - }, 5000); // Rafraîchir toutes les 5 secondes - - return () => clearInterval(interval); - }, [isMinimized]); - const clearLogs = async () => { try { await fetch("/api/debug", { method: "DELETE" }); - setLogs([]); + clearLogsContext(); } catch (error) { console.error("Erreur lors de la suppression des logs:", error); } }; - const sortedLogs = [...logs].reverse(); + // 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 (
+ {!isMinimized && ( + + )}
+ {!isMinimized && showFilters && ( +
+
+ {[ + { 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 }) => ( + + ))} +
+
+ )} + {!isMinimized && (
{sortedLogs.length === 0 ? (

{t("debug.noRequests")}

) : ( - sortedLogs.map((log, index) => ( -
-
-
+ sortedLogs.map((log, index) => { + const isCurrentPage = isCurrentPageRequest(log); + return ( +
+
+
{log.fromCache && (
- )) + ); + }) )}
)} diff --git a/src/components/layout/ClientLayout.tsx b/src/components/layout/ClientLayout.tsx index 3beb684..2b71c2b 100644 --- a/src/components/layout/ClientLayout.tsx +++ b/src/components/layout/ClientLayout.tsx @@ -10,6 +10,7 @@ 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 type { KomgaLibrary, KomgaSeries } from "@/types/komga"; // Routes qui ne nécessitent pas d'authentification @@ -68,6 +69,7 @@ export default function ClientLayout({ children, initialLibraries = [], initialF return ( +
{!isPublicRoute &&
} {!isPublicRoute && ( @@ -84,6 +86,7 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
+
); } diff --git a/src/components/reader/hooks/usePageCache.ts b/src/components/reader/hooks/usePageCache.ts index 0c2d5ce..d7eb6ec 100644 --- a/src/components/reader/hooks/usePageCache.ts +++ b/src/components/reader/hooks/usePageCache.ts @@ -1,6 +1,7 @@ import { useCallback, useRef } from "react"; import type { PageCache } from "../types"; import type { KomgaBook } from "@/types/komga"; +import { usePreferences } from "@/contexts/PreferencesContext"; interface UsePageCacheProps { book: KomgaBook; @@ -9,6 +10,7 @@ interface UsePageCacheProps { export const usePageCache = ({ book, pages }: UsePageCacheProps) => { const pageCache = useRef({}); + const { preferences } = usePreferences(); const preloadPage = useCallback( async (pageNumber: number) => { @@ -32,9 +34,29 @@ export const usePageCache = ({ book, pages }: UsePageCacheProps) => { }; try { + const startTime = performance.now(); const response = await fetch(`/api/komga/books/${book.id}/pages/${pageNumber}`); const blob = await response.blob(); const url = URL.createObjectURL(blob); + const endTime = performance.now(); + + // Logger la requête côté client seulement si le mode debug est activé et ce n'est pas une requête de debug + if (!url.includes('/api/debug') && preferences.debug) { + try { + await fetch("/api/debug", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: `/api/komga/books/${book.id}/pages/${pageNumber}`, + startTime, + endTime, + fromCache: false, + }), + }); + } catch { + // Ignorer les erreurs de logging + } + } pageCache.current[pageNumber] = { blob, @@ -49,12 +71,30 @@ export const usePageCache = ({ book, pages }: UsePageCacheProps) => { resolveLoading!(); } }, - [book.id, pages.length] + [book.id, pages.length, preferences.debug] ); const getPageUrl = useCallback( async (pageNumber: number) => { if (pageCache.current[pageNumber]?.url) { + // Logger l'utilisation du cache côté client seulement si le mode debug est activé et ce n'est pas une requête de debug + const cacheUrl = `[CLIENT-CACHE] /api/komga/books/${book.id}/pages/${pageNumber}`; + if (!cacheUrl.includes('/api/debug') && preferences.debug) { + try { + await fetch("/api/debug", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: cacheUrl, + startTime: performance.now(), + endTime: performance.now(), + fromCache: true, + }), + }); + } catch { + // Ignorer les erreurs de logging + } + } return pageCache.current[pageNumber].url; } @@ -69,7 +109,7 @@ export const usePageCache = ({ book, pages }: UsePageCacheProps) => { `/api/komga/images/books/${book.id}/pages/${pageNumber}` ); }, - [book.id, preloadPage] + [book.id, preloadPage, preferences.debug] ); const cleanCache = useCallback( diff --git a/src/contexts/DebugContext.tsx b/src/contexts/DebugContext.tsx new file mode 100644 index 0000000..0ec8625 --- /dev/null +++ b/src/contexts/DebugContext.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { createContext, useContext, useState, useEffect, ReactNode } from "react"; +import type { RequestTiming } from "@/lib/services/debug.service"; +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(undefined); + +interface DebugProviderProps { + children: ReactNode; +} + +export function DebugProvider({ children }: DebugProviderProps) { + const [logs, setLogs] = useState([]); + 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 = () => { + 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 ( + + {children} + + ); +} + +export function useDebug() { + const context = useContext(DebugContext); + if (context === undefined) { + throw new Error("useDebug must be used within a DebugProvider"); + } + return context; +} diff --git a/src/lib/services/base-api.service.ts b/src/lib/services/base-api.service.ts index 4e82935..85e55af 100644 --- a/src/lib/services/base-api.service.ts +++ b/src/lib/services/base-api.service.ts @@ -1,11 +1,11 @@ import type { AuthConfig } from "@/types/auth"; import { getServerCacheService } from "./server-cache.service"; import { ConfigDBService } from "./config-db.service"; -import { DebugService } from "./debug.service"; 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 { fetchWithCacheDetection } from "../utils/fetch-with-cache-detection"; // Types de cache disponibles export type CacheType = "DEFAULT" | "HOME" | "LIBRARIES" | "SERIES" | "BOOKS" | "IMAGES"; @@ -86,7 +86,6 @@ export abstract class BaseApiService { headersOptions = {}, options: KomgaRequestInit = {} ): Promise { - const startTime = performance.now(); const config: AuthConfig = await this.getKomgaConfig(); const { path, params } = urlBuilder; const url = this.buildUrl(config, path, params); @@ -99,16 +98,7 @@ export abstract class BaseApiService { } try { - const response = await fetch(url, { headers, ...options }); - const endTime = performance.now(); - - // Log la requête - await DebugService.logRequest({ - url: path, - startTime, - endTime, - fromCache: false, - }); + const response = await fetchWithCacheDetection(url, { headers, ...options }); if (!response.ok) { throw new AppError(ERROR_CODES.KOMGA.HTTP_ERROR, { @@ -117,18 +107,8 @@ export abstract class BaseApiService { }); } - return options.isImage ? response : response.json(); + return options.isImage ? (response as T) : response.json(); } catch (error) { - const endTime = performance.now(); - - // Log aussi les erreurs - await DebugService.logRequest({ - url: path, - startTime, - endTime, - fromCache: false, - }); - throw error; } } diff --git a/src/lib/services/debug.service.ts b/src/lib/services/debug.service.ts index 70bdc17..1ab158d 100644 --- a/src/lib/services/debug.service.ts +++ b/src/lib/services/debug.service.ts @@ -25,6 +25,8 @@ export interface RequestTiming { } export class DebugService { + private static writeQueues = new Map>(); + private static async getCurrentUserId(): Promise { const user = await AuthServerService.getCurrentUser(); if (!user) { @@ -60,13 +62,116 @@ export class DebugService { const content = await fs.readFile(filePath, "utf-8"); return JSON.parse(content); } catch { - return []; + // 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 { - const trimmedLogs = logs.slice(-99); - await fs.writeFile(filePath, JSON.stringify(trimmedLogs, null, 2)); + // 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 { + 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 { + // 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 { + 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( @@ -95,7 +200,6 @@ export class DebugService { await this.ensureDebugDir(); const filePath = this.getLogFilePath(userId); - const logs = await this.readLogs(filePath); const newTiming = this.createTiming( timing.url, timing.startTime, @@ -108,7 +212,8 @@ export class DebugService { } ); - await this.writeLogs(filePath, [...logs, newTiming]); + // Utiliser un système d'append atomique + await this.appendLog(filePath, newTiming); } catch (error) { console.error("Erreur lors de l'enregistrement du log:", error); } @@ -143,13 +248,13 @@ export class DebugService { await this.ensureDebugDir(); const filePath = this.getLogFilePath(userId); - const logs = await this.readLogs(filePath); const now = performance.now(); const newTiming = this.createTiming(`Page Render: ${page}`, now - duration, now, false, { pageRender: { page, duration }, }); - await this.writeLogs(filePath, [...logs, newTiming]); + // 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); } diff --git a/src/lib/services/server-cache.service.ts b/src/lib/services/server-cache.service.ts index 3445cf2..a386ecd 100644 --- a/src/lib/services/server-cache.service.ts +++ b/src/lib/services/server-cache.service.ts @@ -400,9 +400,9 @@ class ServerCacheService { if (cached !== null) { const endTime = performance.now(); - // Log la requête avec l'indication du cache + // Log la requête avec l'indication du cache (URL plus claire) await DebugService.logRequest({ - url: cacheKey, + url: `[CACHE] ${key}`, startTime, endTime, fromCache: true, diff --git a/src/lib/utils/fetch-with-cache-detection.ts b/src/lib/utils/fetch-with-cache-detection.ts new file mode 100644 index 0000000..3929c02 --- /dev/null +++ b/src/lib/utils/fetch-with-cache-detection.ts @@ -0,0 +1,57 @@ +// 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; + } +}