diff --git a/pmoapp/webapp/src/composables/apiCache.ts b/pmoapp/webapp/src/composables/apiCache.ts new file mode 100644 index 00000000..bc925ce2 --- /dev/null +++ b/pmoapp/webapp/src/composables/apiCache.ts @@ -0,0 +1,272 @@ +/** + * Cache API centralisé pour les requêtes HTTP + * + * Fonctionnalités: + * - Cache mémoire avec TTL configurable + * - Dédupplication des requêtes en cours (une seule requête pour plusieurs callers) + * - Invalidation par pattern (ex: invalidate('renderers/*')) + * - Subscribe aux changements de données pour reactivity + */ + +export interface CacheEntry { + data: T; + timestamp: number; + etag?: string; +} + +export interface ApiCacheOptions { + ttl?: number; + staleWhileRevalidate?: boolean; +} + +interface PendingRequest { + promise: Promise; + subscribers: Set<(data: unknown) => void>; +} + +/** + * Classe principale du cache API + */ +class ApiCacheService { + private cache = new Map>(); + private pendingRequests = new Map(); + private subscriptions = new Map void>>(); + + private options: Required = { + ttl: 2000, + staleWhileRevalidate: true, + }; + + configure(options: Partial) { + this.options = { ...this.options, ...options }; + } + + private makeKey(endpoint: string, params?: Record): string { + if (!params) return endpoint; + const sorted = Object.entries(params).sort(([a], [b]) => a.localeCompare(b)); + const query = sorted.map(([k, v]) => `${k}=${v}`).join('&'); + return `${endpoint}?${query}`; + } + + private isFresh(key: string): boolean { + const entry = this.cache.get(key); + if (!entry) return false; + return Date.now() - entry.timestamp < this.options.ttl; + } + + get(endpoint: string, params?: Record): T | null { + const key = this.makeKey(endpoint, params); + const entry = this.cache.get(key) as CacheEntry | undefined; + + if (!entry) return null; + if (!this.isFresh(key)) { + return this.options.staleWhileRevalidate ? entry.data : null; + } + + return entry.data; + } + + set(endpoint: string, data: T, params?: Record, etag?: string): void { + const key = this.makeKey(endpoint, params); + + this.cache.set(key, { + data, + timestamp: Date.now(), + etag, + }); + + this.notifySubscribers(key, data); + } + + async fetch( + endpoint: string, + params: Record | undefined, + fetcher: () => Promise, + options: { force?: boolean; ttl?: number } = {} + ): Promise { + const key = this.makeKey(endpoint, params); + const { force = false, ttl } = options; + + if (!force && this.isFresh(key)) { + const cached = this.get(endpoint, params); + if (cached) return cached; + } + + const existing = this.pendingRequests.get(key); + if (existing) { + return existing.promise as Promise; + } + + let resolvePromise!: (value: unknown) => void; + let rejectPromise!: (reason: unknown) => void; + + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + + this.pendingRequests.set(key, { + promise, + subscribers: new Set(), + }); + + try { + const data = await fetcher(); + + if (ttl) { + const originalTtl = this.options.ttl; + this.options.ttl = ttl; + this.set(endpoint, data, params); + this.options.ttl = originalTtl; + } else { + this.set(endpoint, data, params); + } + + resolvePromise(data); + + const pending = this.pendingRequests.get(key); + if (pending) { + pending.subscribers.forEach(cb => cb(data)); + } + + } catch (error) { + rejectPromise(error); + throw error; + } finally { + this.pendingRequests.delete(key); + } + + return Promise.reject(new Error('Unreachable')); + } + + subscribe(endpoint: string, params: Record, callback: (data: T) => void): () => void { + const key = this.makeKey(endpoint, params); + + if (!this.subscriptions.has(key)) { + this.subscriptions.set(key, new Set()); + } + + this.subscriptions.get(key)!.add(callback as (data: unknown) => void); + + const cached = this.get(endpoint, params); + if (cached) { + callback(cached); + } + + return () => { + const subs = this.subscriptions.get(key); + if (subs) { + subs.delete(callback as (data: unknown) => void); + if (subs.size === 0) { + this.subscriptions.delete(key); + } + } + }; + } + + invalidate(pattern: string): void { + const keysToDelete: string[] = []; + + if (pattern.includes('*')) { + const prefix = pattern.replace('*', ''); + this.cache.forEach((_, key) => { + if (key.startsWith(prefix)) { + keysToDelete.push(key); + } + }); + } else { + if (this.cache.has(pattern)) { + keysToDelete.push(pattern); + } + } + + keysToDelete.forEach(key => { + this.cache.delete(key); + this.subscriptions.delete(key); + }); + } + + clear(): void { + this.cache.clear(); + this.subscriptions.clear(); + } + + async invalidateAndFetch( + endpoint: string, + params: Record, + fetcher: () => Promise + ): Promise { + this.invalidate(this.makeKey(endpoint, params)); + return this.fetch(endpoint, params, fetcher, { force: true }); + } + + getStats() { + let fresh = 0; + let stale = 0; + const now = Date.now(); + + this.cache.forEach((entry) => { + if (now - entry.timestamp < this.options.ttl) { + fresh++; + } else { + stale++; + } + }); + + return { + total: this.cache.size, + fresh, + stale, + pending: this.pendingRequests.size, + subscriptions: this.subscriptions.size, + }; + } + + private notifySubscribers(key: string, data: unknown) { + const subs = this.subscriptions.get(key); + if (subs) { + subs.forEach(cb => { + try { + cb(data); + } catch (e) { + console.error('[ApiCache] Error in subscriber:', e); + } + }); + } + } +} + +export const apiCache = new ApiCacheService(); + +export function useApiCache() { + return { + fetch( + endpoint: string, + params: Record | undefined, + fetcher: () => Promise, + options?: { force?: boolean; ttl?: number } + ): Promise { + return apiCache.fetch(endpoint, params, fetcher, options); + }, + + subscribe( + endpoint: string, + params: Record, + callback: (data: T) => void + ): () => void { + return apiCache.subscribe(endpoint, params, callback); + }, + + invalidate(pattern: string): void { + apiCache.invalidate(pattern); + }, + + clear(): void { + apiCache.clear(); + }, + + getStats() { + return apiCache.getStats(); + }, + }; +} \ No newline at end of file diff --git a/pmoapp/webapp/src/composables/useMediaServers.ts b/pmoapp/webapp/src/composables/useMediaServers.ts index 7a0a0b4d..12169055 100644 --- a/pmoapp/webapp/src/composables/useMediaServers.ts +++ b/pmoapp/webapp/src/composables/useMediaServers.ts @@ -5,6 +5,7 @@ import { ref, computed } from 'vue' import { api } from '../services/pmocontrol/api' import { useSSE } from './useSSE' +import { apiCache } from './apiCache' import type { MediaServerSummary, ContainerEntry, @@ -28,11 +29,6 @@ const currentPath = ref([]) const searchResults = ref(null) const searchQuery = ref('') -// Timestamps -const lastFetch = { - servers: 0 -} - const CACHE_DURATION_MS = 2000 // Initialiser SSE une seule fois via le composable centralisé @@ -122,19 +118,20 @@ export function useMediaServers() { // Fetch servers list async function fetchServers(force = false) { - const now = Date.now() - if (!force && now - lastFetch.servers < CACHE_DURATION_MS) { - return - } - try { loading.value = true error.value = null - const data = await api.getServers() + + // Utiliser le cache API centralisé + const data = await apiCache.fetch( + '/servers', + undefined, + () => api.getServers(), + { force, ttl: CACHE_DURATION_MS } + ) serversCache.value.clear() data.forEach(s => serversCache.value.set(s.id, s)) - lastFetch.servers = now } catch (e) { error.value = e instanceof Error ? e.message : 'Erreur fetch servers' console.error('[useMediaServers] Erreur fetch:', e) diff --git a/pmoapp/webapp/src/composables/useRenderers.ts b/pmoapp/webapp/src/composables/useRenderers.ts index e38c40a9..b7702bf7 100644 --- a/pmoapp/webapp/src/composables/useRenderers.ts +++ b/pmoapp/webapp/src/composables/useRenderers.ts @@ -7,6 +7,7 @@ import { ref, reactive, computed, toRaw, type Ref } from "vue"; import { api } from "../services/pmocontrol/api"; import { useSSE } from "./useSSE"; +import { apiCache } from "./apiCache"; import type { RendererSummary, RendererState, @@ -26,7 +27,6 @@ interface RendererSnapshotState { const renderersCache = ref>(new Map()); const RENDERERS_CACHE_MS = 2000; -const lastRenderersFetch = ref(0); const snapshotState = reactive({ snapshots: reactive(new Map()), @@ -292,19 +292,21 @@ function selectRenderer(id: string | null) { async function fetchRenderers(force = false) { ensureSSEInitialized(); - const now = Date.now(); - if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) { - return; - } - try { loading.value = true; error.value = null; - const data = await api.getRenderers(); + + // Utiliser le cache API centralisé + const data = await apiCache.fetch( + '/renderers', + undefined, + () => api.getRenderers(), + { force, ttl: RENDERERS_CACHE_MS } + ); + renderersCache.value = new Map( data.map((renderer) => [renderer.id, renderer]), ); - lastRenderersFetch.value = now; } catch (err) { error.value = err instanceof Error ? err.message : "Erreur fetch renderers"; console.error("[useRenderers] Erreur fetch:", err);