diff --git a/src/app/api/komga/cache/entries/route.ts b/src/app/api/komga/cache/entries/route.ts new file mode 100644 index 0000000..398723a --- /dev/null +++ b/src/app/api/komga/cache/entries/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import type { ServerCacheService } from "@/lib/services/server-cache.service"; +import { getServerCacheService } from "@/lib/services/server-cache.service"; +import { ERROR_CODES } from "@/constants/errorCodes"; +import { getErrorMessage } from "@/utils/errors"; + +export async function GET() { + try { + const cacheService: ServerCacheService = await getServerCacheService(); + const entries = await cacheService.getCacheEntries(); + + return NextResponse.json({ entries }); + } catch (error) { + console.error("Erreur lors de la récupération des entrées du cache:", error); + return NextResponse.json( + { + error: { + code: ERROR_CODES.CACHE.SIZE_FETCH_ERROR, + name: "Cache entries fetch error", + message: getErrorMessage(ERROR_CODES.CACHE.SIZE_FETCH_ERROR), + }, + }, + { status: 500 } + ); + } +} + diff --git a/src/components/settings/CacheSettings.tsx b/src/components/settings/CacheSettings.tsx index 995ed12..8d7878d 100644 --- a/src/components/settings/CacheSettings.tsx +++ b/src/components/settings/CacheSettings.tsx @@ -3,11 +3,12 @@ import { useState, useEffect } from "react"; import { useTranslate } from "@/hooks/useTranslate"; import { useToast } from "@/components/ui/use-toast"; -import { Trash2, Loader2, HardDrive } from "lucide-react"; +import { Trash2, Loader2, HardDrive, List, ChevronDown, ChevronUp } from "lucide-react"; import { CacheModeSwitch } from "@/components/settings/CacheModeSwitch"; import { Label } from "@/components/ui/label"; import type { TTLConfigData } from "@/types/komga"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; interface CacheSettingsProps { initialTTLConfig: TTLConfigData | null; @@ -18,6 +19,19 @@ interface CacheSizeInfo { itemCount: number; } +interface CacheEntry { + key: string; + size: number; + expiry: number; + isExpired: boolean; +} + +interface ServiceWorkerCacheEntry { + url: string; + size: number; + cacheName: string; +} + export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { const { t } = useTranslate(); const { toast } = useToast(); @@ -26,6 +40,13 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { const [serverCacheSize, setServerCacheSize] = useState(null); const [swCacheSize, setSwCacheSize] = useState(null); const [isLoadingCacheSize, setIsLoadingCacheSize] = useState(true); + const [cacheEntries, setCacheEntries] = useState([]); + const [isLoadingEntries, setIsLoadingEntries] = useState(false); + const [showEntries, setShowEntries] = useState(false); + const [swCacheEntries, setSwCacheEntries] = useState([]); + const [isLoadingSwEntries, setIsLoadingSwEntries] = useState(false); + const [showSwEntries, setShowSwEntries] = useState(false); + const [expandedGroups, setExpandedGroups] = useState>({}); const [ttlConfig, setTTLConfig] = useState( initialTTLConfig || { defaultTTL: 5, @@ -45,6 +66,35 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; }; + const formatDate = (timestamp: number): string => { + return new Date(timestamp).toLocaleString(); + }; + + const getTimeRemaining = (expiry: number): string => { + const now = Date.now(); + const diff = expiry - now; + + if (diff < 0) return t("settings.cache.entries.expired"); + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return t("settings.cache.entries.daysRemaining", { count: days }); + if (hours > 0) return t("settings.cache.entries.hoursRemaining", { count: hours }); + if (minutes > 0) return t("settings.cache.entries.minutesRemaining", { count: minutes }); + return t("settings.cache.entries.lessThanMinute"); + }; + + const getCacheType = (key: string): string => { + if (key.includes("/home")) return "HOME"; + if (key.includes("/libraries")) return "LIBRARIES"; + if (key.includes("/series/")) return "SERIES"; + if (key.includes("/books/")) return "BOOKS"; + if (key.includes("/images/")) return "IMAGES"; + return "DEFAULT"; + }; + const fetchCacheSize = async () => { setIsLoadingCacheSize(true); try { @@ -85,6 +135,114 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { } }; + const fetchCacheEntries = async () => { + setIsLoadingEntries(true); + try { + const response = await fetch("/api/komga/cache/entries"); + if (response.ok) { + const data = await response.json(); + setCacheEntries(data.entries); + } + } catch (error) { + console.error("Erreur lors de la récupération des entrées du cache:", error); + } finally { + setIsLoadingEntries(false); + } + }; + + const toggleShowEntries = () => { + if (!showEntries && cacheEntries.length === 0) { + fetchCacheEntries(); + } + setShowEntries(!showEntries); + }; + + const fetchSwCacheEntries = async () => { + setIsLoadingSwEntries(true); + try { + if ("caches" in window) { + const entries: ServiceWorkerCacheEntry[] = []; + const cacheNames = await caches.keys(); + + for (const cacheName of cacheNames) { + const cache = await caches.open(cacheName); + const requests = await cache.keys(); + + for (const request of requests) { + const response = await cache.match(request); + if (response) { + const blob = await response.clone().blob(); + entries.push({ + url: request.url, + size: blob.size, + cacheName, + }); + } + } + } + + setSwCacheEntries(entries); + } + } catch (error) { + console.error("Erreur lors de la récupération des entrées du cache SW:", error); + } finally { + setIsLoadingSwEntries(false); + } + }; + + const toggleShowSwEntries = () => { + if (!showSwEntries && swCacheEntries.length === 0) { + fetchSwCacheEntries(); + } + setShowSwEntries(!showSwEntries); + }; + + const getFirstPathSegment = (url: string): string => { + try { + const urlObj = new URL(url); + const path = urlObj.pathname; + const segments = path.split('/').filter(Boolean); + + if (segments.length === 0) return '/'; + + return `/${segments[0]}`; + } catch { + return 'Autres'; + } + }; + + const groupEntriesByPath = (entries: ServiceWorkerCacheEntry[]) => { + const grouped = entries.reduce( + (acc, entry) => { + const pathGroup = getFirstPathSegment(entry.url); + if (!acc[pathGroup]) { + acc[pathGroup] = []; + } + acc[pathGroup].push(entry); + return acc; + }, + {} as Record + ); + + // Trier chaque groupe par taille décroissante + Object.keys(grouped).forEach((key) => { + grouped[key].sort((a, b) => b.size - a.size); + }); + + return grouped; + }; + + const getTotalSizeByType = (entries: ServiceWorkerCacheEntry[]): number => { + return entries.reduce((sum, entry) => sum + entry.size, 0); + }; + + const toggleGroup = (groupName: string) => { + setExpandedGroups((prev) => ({ + ...prev, + [groupName]: !prev[groupName], + })); + }; + useEffect(() => { fetchCacheSize(); }, []); @@ -107,8 +265,14 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { description: t("settings.cache.messages.cleared"), }); - // Rafraîchir la taille du cache + // Rafraîchir la taille du cache et les entrées await fetchCacheSize(); + if (showEntries) { + await fetchCacheEntries(); + } + if (showSwEntries) { + await fetchSwCacheEntries(); + } } catch (error) { console.error("Erreur:", error); toast({ @@ -132,8 +296,14 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { description: t("settings.cache.messages.serviceWorkerCleared"), }); - // Rafraîchir la taille du cache + // Rafraîchir la taille du cache et les entrées await fetchCacheSize(); + if (showEntries) { + await fetchCacheEntries(); + } + if (showSwEntries) { + await fetchSwCacheEntries(); + } } } catch (error) { console.error("Erreur lors de la suppression des caches:", error); @@ -242,6 +412,168 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { )} + {/* Aperçu des entrées du cache serveur */} +
+ + + {showEntries && ( +
+ {isLoadingEntries ? ( +
+ + {t("settings.cache.entries.loading")} +
+ ) : cacheEntries.length === 0 ? ( +
+ {t("settings.cache.entries.empty")} +
+ ) : ( +
+
+ {cacheEntries.map((entry, index) => ( +
+
+
+
+ {entry.key} +
+
+ + {getCacheType(entry.key)} + + {formatBytes(entry.size)} +
+
+
+
+ {getTimeRemaining(entry.expiry)} +
+
+ {new Date(entry.expiry).toLocaleDateString()} +
+
+
+
+ ))} +
+
+ )} +
+ )} +
+ + {/* Aperçu des entrées du cache service worker */} +
+ + + {showSwEntries && ( +
+ {isLoadingSwEntries ? ( +
+ + {t("settings.cache.entries.loading")} +
+ ) : swCacheEntries.length === 0 ? ( +
+ {t("settings.cache.entries.empty")} +
+ ) : ( +
+ {(() => { + const grouped = groupEntriesByPath(swCacheEntries); + return ( +
+ {Object.entries(grouped).map(([pathGroup, entries]) => { + const isExpanded = expandedGroups[pathGroup]; + return ( +
+ + {isExpanded && ( +
+ {entries.map((entry, index) => ( +
+
+
+
+ {entry.url.replace(/^https?:\/\/[^/]+/, "")} +
+
+
+ {formatBytes(entry.size)} +
+
+
+ ))} +
+ )} +
+ ); + })} +
+ ); + })()} +
+ )} +
+ )} +
+ {/* Formulaire TTL */}
diff --git a/src/i18n/messages/en/common.json b/src/i18n/messages/en/common.json index c5e4280..17e7c34 100644 --- a/src/i18n/messages/en/common.json +++ b/src/i18n/messages/en/common.json @@ -164,6 +164,18 @@ "message": "An error occurred while clearing the cache", "ttl": "Error saving TTL configuration", "serviceWorkerMessage": "An error occurred while clearing the service worker cache" + }, + "entries": { + "title": "Cache content preview", + "serverTitle": "Server cache preview", + "serviceWorkerTitle": "Service worker cache preview", + "loading": "Loading entries...", + "empty": "No entries in cache", + "expired": "Expired", + "daysRemaining": "{count} day(s) remaining", + "hoursRemaining": "{count} hour(s) remaining", + "minutesRemaining": "{count} minute(s) remaining", + "lessThanMinute": "Less than a minute" } } }, diff --git a/src/i18n/messages/fr/common.json b/src/i18n/messages/fr/common.json index d573010..05fb6af 100644 --- a/src/i18n/messages/fr/common.json +++ b/src/i18n/messages/fr/common.json @@ -164,6 +164,18 @@ "message": "Une erreur est survenue lors de la suppression du cache", "ttl": "Erreur lors de la sauvegarde de la configuration TTL", "serviceWorkerMessage": "Une erreur est survenue lors de la suppression du cache du service worker" + }, + "entries": { + "title": "Aperçu du contenu du cache", + "serverTitle": "Aperçu du cache serveur", + "serviceWorkerTitle": "Aperçu du cache service worker", + "loading": "Chargement des entrées...", + "empty": "Aucune entrée dans le cache", + "expired": "Expiré", + "daysRemaining": "{count} jour(s) restant(s)", + "hoursRemaining": "{count} heure(s) restante(s)", + "minutesRemaining": "{count} minute(s) restante(s)", + "lessThanMinute": "Moins d'une minute" } } }, diff --git a/src/lib/services/server-cache.service.ts b/src/lib/services/server-cache.service.ts index f6105c1..73dffac 100644 --- a/src/lib/services/server-cache.service.ts +++ b/src/lib/services/server-cache.service.ts @@ -593,6 +593,77 @@ class ServerCacheService { return { sizeInBytes, itemCount }; } + + /** + * Liste les entrées du cache avec leurs détails + */ + async getCacheEntries(): Promise< + Array<{ + key: string; + size: number; + expiry: number; + isExpired: boolean; + }> + > { + const entries: Array<{ + key: string; + size: number; + expiry: number; + isExpired: boolean; + }> = []; + + if (this.config.mode === "memory") { + this.memoryCache.forEach((value, key) => { + const size = this.calculateObjectSize(value.data) + 8; + entries.push({ + key, + size, + expiry: value.expiry, + isExpired: value.expiry <= Date.now(), + }); + }); + } else { + const collectEntries = (dirPath: string): void => { + if (!fs.existsSync(dirPath)) return; + + const items = fs.readdirSync(dirPath); + + for (const item of items) { + const itemPath = path.join(dirPath, item); + try { + const stats = fs.statSync(itemPath); + + if (stats.isDirectory()) { + collectEntries(itemPath); + } else if (stats.isFile() && item.endsWith(".json")) { + try { + const content = fs.readFileSync(itemPath, "utf-8"); + const cached = JSON.parse(content); + const key = path.relative(this.cacheDir, itemPath).slice(0, -5); + + entries.push({ + key, + size: stats.size, + expiry: cached.expiry, + isExpired: cached.expiry <= Date.now(), + }); + } catch (error) { + console.error(`Could not parse file ${itemPath}:`, error); + } + } + } catch (error) { + console.error(`Could not access ${itemPath}:`, error); + } + } + }; + + if (fs.existsSync(this.cacheDir)) { + collectEntries(this.cacheDir); + } + } + + return entries.sort((a, b) => b.expiry - a.expiry); + } } // Créer une instance initialisée du service