feat: add caching debug logs and configurable max concurrent requests for Komga API to enhance performance monitoring

This commit is contained in:
Julien Froidefond
2025-10-18 09:08:41 +02:00
parent ae4b766085
commit b7704207ec
42 changed files with 1141 additions and 1302 deletions

View File

@@ -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 }
);
}
}

View File

@@ -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) {

View File

@@ -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(
{

View File

@@ -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(
{

View File

@@ -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(
{

View File

@@ -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(
{

View File

@@ -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(
{

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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 />;
}

View File

@@ -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,25 +92,22 @@ 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 && (
<Sidebar
isOpen={isSidebarOpen}
onClose={handleCloseSidebar}
initialLibraries={initialLibraries}
initialFavorites={initialFavorites}
userIsAdmin={userIsAdmin}
/>
)}
<main className={`${!isPublicRoute ? "container pt-safe" : ""}`}>{children}</main>
<InstallPWA />
<Toaster />
<NetworkStatus />
<DebugWrapper />
</div>
</DebugProvider>
<div className="relative min-h-screen bg-background" style={backgroundStyle}>
{!isPublicRoute && <Header onToggleSidebar={handleToggleSidebar} />}
{!isPublicRoute && (
<Sidebar
isOpen={isSidebarOpen}
onClose={handleCloseSidebar}
initialLibraries={initialLibraries}
initialFavorites={initialFavorites}
userIsAdmin={userIsAdmin}
/>
)}
<main className={`${!isPublicRoute ? "container pt-safe" : ""}`}>{children}</main>
<InstallPWA />
<Toaster />
<NetworkStatus />
</div>
</ThemeProvider>
);
}

View File

@@ -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>
);

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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;
};
}

View File

@@ -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);

View File

@@ -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: {

View File

@@ -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;
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;
const config = await prisma.tTLConfig.findUnique({
where: { userId: user.id },
});
return config as TTLConfig | null;
} catch (error) {
if (error instanceof AppError) {
throw error;
@@ -86,30 +81,28 @@ 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: {
defaultTTL: data.defaultTTL,
homeTTL: data.homeTTL,
librariesTTL: data.librariesTTL,
seriesTTL: data.seriesTTL,
booksTTL: data.booksTTL,
imagesTTL: data.imagesTTL,
},
create: {
userId: user.id,
defaultTTL: data.defaultTTL,
homeTTL: data.homeTTL,
librariesTTL: data.librariesTTL,
seriesTTL: data.seriesTTL,
booksTTL: data.booksTTL,
imagesTTL: data.imagesTTL,
},
});
return config as TTLConfig;
const config = await prisma.tTLConfig.upsert({
where: { userId: user.id },
update: {
defaultTTL: data.defaultTTL,
homeTTL: data.homeTTL,
librariesTTL: data.librariesTTL,
seriesTTL: data.seriesTTL,
booksTTL: data.booksTTL,
imagesTTL: data.imagesTTL,
},
create: {
userId: user.id,
defaultTTL: data.defaultTTL,
homeTTL: data.homeTTL,
librariesTTL: data.librariesTTL,
seriesTTL: data.seriesTTL,
booksTTL: data.booksTTL,
imagesTTL: data.imagesTTL,
},
});
return config as TTLConfig;
} catch (error) {
if (error instanceof AppError) {
throw error;

View File

@@ -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;
}
}
}

View File

@@ -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,15 +29,13 @@ export class FavoriteService {
try {
const user = await this.getCurrentUser();
return DebugService.measureMongoOperation("isFavorite", async () => {
const favorite = await prisma.favorite.findFirst({
where: {
userId: user.id,
seriesId: seriesId,
},
});
return !!favorite;
const favorite = await prisma.favorite.findFirst({
where: {
userId: user.id,
seriesId: seriesId,
},
});
return !!favorite;
} catch (error) {
console.error("Erreur lors de la vérification du favori:", error);
return false;
@@ -52,20 +49,18 @@ export class FavoriteService {
try {
const user = await this.getCurrentUser();
await DebugService.measureMongoOperation("addToFavorites", async () => {
await prisma.favorite.upsert({
where: {
userId_seriesId: {
userId: user.id,
seriesId,
},
},
update: {},
create: {
await prisma.favorite.upsert({
where: {
userId_seriesId: {
userId: user.id,
seriesId,
},
});
},
update: {},
create: {
userId: user.id,
seriesId,
},
});
this.dispatchFavoritesChanged();
@@ -81,13 +76,11 @@ export class FavoriteService {
try {
const user = await this.getCurrentUser();
await DebugService.measureMongoOperation("removeFromFavorites", async () => {
await prisma.favorite.deleteMany({
where: {
userId: user.id,
seriesId,
},
});
await prisma.favorite.deleteMany({
where: {
userId: user.id,
seriesId,
},
});
this.dispatchFavoritesChanged();
@@ -102,47 +95,41 @@ 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);
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: {
userId: user.id,
seriesId,
},
},
update: {},
create: {
const favorite = await prisma.favorite.upsert({
where: {
userId_seriesId: {
userId: user.id,
seriesId,
},
});
return favorite;
},
update: {},
create: {
userId: user.id,
seriesId,
},
});
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,
seriesId,
},
});
return result.count > 0;
const result = await prisma.favorite.deleteMany({
where: {
userId: user.id,
seriesId,
},
});
return result.count > 0;
}
}

View File

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

View File

@@ -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,
};

View File

@@ -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
);

View File

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

View File

@@ -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);
const endTime = performance.now();
await DebugService.logRequest({
url: `[REVALIDATE] ${debugKey}`,
startTime,
endTime,
fromCache: false,
cacheType: type,
});
if (process.env.CACHE_DEBUG === 'true') {
const endTime = performance.now();
// 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
}
}

View File

@@ -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;
}
}

View File

@@ -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;
};
}

View File

@@ -121,6 +121,7 @@ export interface KomgaBook {
media: BookMedia;
metadata: BookMetadata;
readProgress: ReadProgress | null;
deleted: boolean;
}
export interface BookMedia {

View File

@@ -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
View 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;
}