feat: add cache entries API and enhance CacheSettings component with server and service worker cache previews

This commit is contained in:
Julien Froidefond
2025-10-18 13:45:15 +02:00
parent ba8f23b058
commit 816abe2b90
5 changed files with 457 additions and 3 deletions

View File

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

View File

@@ -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<CacheSizeInfo | null>(null);
const [swCacheSize, setSwCacheSize] = useState<number | null>(null);
const [isLoadingCacheSize, setIsLoadingCacheSize] = useState(true);
const [cacheEntries, setCacheEntries] = useState<CacheEntry[]>([]);
const [isLoadingEntries, setIsLoadingEntries] = useState(false);
const [showEntries, setShowEntries] = useState(false);
const [swCacheEntries, setSwCacheEntries] = useState<ServiceWorkerCacheEntry[]>([]);
const [isLoadingSwEntries, setIsLoadingSwEntries] = useState(false);
const [showSwEntries, setShowSwEntries] = useState(false);
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const [ttlConfig, setTTLConfig] = useState<TTLConfigData>(
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<string, ServiceWorkerCacheEntry[]>
);
// 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) {
)}
</div>
{/* Aperçu des entrées du cache serveur */}
<div className="space-y-3">
<Button
type="button"
variant="outline"
onClick={toggleShowEntries}
className="w-full flex items-center justify-between"
>
<span className="flex items-center gap-2">
<List className="h-4 w-4" />
{t("settings.cache.entries.serverTitle")}
</span>
{showEntries ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
{showEntries && (
<div className="rounded-md border bg-muted/30 backdrop-blur-md">
{isLoadingEntries ? (
<div className="p-4 text-center text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mx-auto mb-2" />
{t("settings.cache.entries.loading")}
</div>
) : cacheEntries.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("settings.cache.entries.empty")}
</div>
) : (
<div className="max-h-96 overflow-y-auto">
<div className="divide-y">
{cacheEntries.map((entry, index) => (
<div
key={index}
className={`p-3 space-y-1 ${entry.isExpired ? "opacity-50" : ""}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-mono text-xs truncate" title={entry.key}>
{entry.key}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium">
{getCacheType(entry.key)}
</span>
<span>{formatBytes(entry.size)}</span>
</div>
</div>
<div className="text-right text-xs">
<div
className={`font-medium ${entry.isExpired ? "text-destructive" : "text-muted-foreground"}`}
>
{getTimeRemaining(entry.expiry)}
</div>
<div className="text-muted-foreground/70" title={formatDate(entry.expiry)}>
{new Date(entry.expiry).toLocaleDateString()}
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Aperçu des entrées du cache service worker */}
<div className="space-y-3">
<Button
type="button"
variant="outline"
onClick={toggleShowSwEntries}
className="w-full flex items-center justify-between"
>
<span className="flex items-center gap-2">
<List className="h-4 w-4" />
{t("settings.cache.entries.serviceWorkerTitle")}
</span>
{showSwEntries ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
{showSwEntries && (
<div className="rounded-md border bg-muted/30 backdrop-blur-md">
{isLoadingSwEntries ? (
<div className="p-4 text-center text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mx-auto mb-2" />
{t("settings.cache.entries.loading")}
</div>
) : swCacheEntries.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("settings.cache.entries.empty")}
</div>
) : (
<div className="max-h-96 overflow-y-auto">
{(() => {
const grouped = groupEntriesByPath(swCacheEntries);
return (
<div className="divide-y">
{Object.entries(grouped).map(([pathGroup, entries]) => {
const isExpanded = expandedGroups[pathGroup];
return (
<div key={pathGroup} className="p-3 space-y-2">
<button
type="button"
onClick={() => toggleGroup(pathGroup)}
className="w-full flex items-center justify-between hover:bg-muted/50 rounded p-1 -m-1 transition-colors"
>
<div className="font-medium text-sm flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronUp className="h-3 w-3" />
)}
<span className="inline-flex items-center rounded-full bg-blue-500/10 px-2 py-0.5 text-xs font-medium font-mono">
{pathGroup}
</span>
<span className="text-xs text-muted-foreground">
({entries.length} {entries.length > 1 ? "éléments" : "élément"})
</span>
</div>
<div className="text-xs text-muted-foreground font-medium">
{formatBytes(getTotalSizeByType(entries))}
</div>
</button>
{isExpanded && (
<div className="space-y-1 pl-2">
{entries.map((entry, index) => (
<div key={index} className="py-1">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-mono text-xs truncate text-muted-foreground" title={entry.url}>
{entry.url.replace(/^https?:\/\/[^/]+/, "")}
</div>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap">
{formatBytes(entry.size)}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
);
})()}
</div>
)}
</div>
)}
</div>
{/* Formulaire TTL */}
<form onSubmit={handleSaveTTL} className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">

View File

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

View File

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

View File

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