diff --git a/src/automats/orderAutomat.js b/src/automats/orderAutomat.js index 948f77d..7a88368 100644 --- a/src/automats/orderAutomat.js +++ b/src/automats/orderAutomat.js @@ -2,6 +2,13 @@ * @file orderAutomat.js * @description Manages cached and chunk-paginated company sales orders (`GET /api/order`). * + * Implements a multi-filter chunk cache: + * - Each filter combination has its own isolated cache entry (total, items array, loadedChunks). + * - Total chunk capacity across all filter combinations is bounded by a global LRU (Least Recently Used) limit of 50 chunks. + * - When filter settings change, previously loaded chunks remain cached in memory up to the 50-chunk limit. + * - Switching back to a previously used filter is instant with zero network requests if its chunks are in RAM. + * - Real-time stats and events can be observed via `subscribeChunkStats` or the dev ChunkCacheMonitor. + * * Upstream Triggers: * - `authAutomat`: Triggers `actions.reset()` on `'anon'` or when `selectedCompanyId` changes. * - UI / Routes: Calls `getItems()`, `requireChunk(chunkIndex)`, `loadChunk(chunkIndex)`, or `reload()`. @@ -40,11 +47,18 @@ import authAutomat from './authAutomat'; * @typedef {Object} OrderAutomatState * @property {Order[]|null} items Pre-filled array of orders/nulls, initially null. * @property {number} total Total unpaged count for pagination calculation. + * @property {string} filter Search term. + * @property {boolean} onlyUnpaid Unpaid orders only. + * @property {boolean} onlyUndelivered Undelivered orders only. + * @property {number} seekMonths Seek month count. + * @property {number} seekYears Seek year count. + * @property {number} negativeSeekMonths Negative seek months offset. * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state. * @property {Error|string|null} error Last load error. */ export const CHUNK_SIZE = 50; +export const MAX_LRU_CHUNKS = 50; const PREFETCH_CHUNKS = 1; const CHUNK_DEBOUNCE_MS = 130; @@ -67,24 +81,194 @@ let filterDebounceTimer = null; let chunkDebounceTimer = null; let currentEventTick = 0; let lastRequireChunkTick = -1; + const pendingTargetChunks = new Set(); let pendingChunkPromise = null; let pendingChunkResolve = null; let pendingChunkReject = null; -const loadedChunks = new Set(); + +// Multi-Filter Cache & Global LRU Management +// filterCaches: Map +const filterCaches = new Map(); +// lruChunks: Map +// In JS, Map keys iteration preserves insertion order. Oldest (LRU) is lruChunks.keys().next().value. +const lruChunks = new Map(); +// inflightChunks: Map const inflightChunks = new Map(); // Dev / Observability: Chunk event history and subscriber registry -const MAX_CHUNK_EVENTS = 120; +const MAX_CHUNK_EVENTS = 150; let nextEventId = 0; const chunkEvents = []; const chunkStatsListeners = new Set(); +export function makeLruKey(filterKey, chunkIndex) { + return `${filterKey}:::${chunkIndex}`; +} + +export function parseLruKey(key) { + const [filterKey, chunkStr] = key.split(':::'); + return { filterKey, chunkIndex: Number(chunkStr) }; +} + +// MARK: pure filter helpers +export function getFilterParams(state = automat?.state || initialState) { + const filter = String(state.filter ?? '').trim(); + const onlyUnpaid = Boolean(state.onlyUnpaid); + const onlyUndelivered = Boolean(state.onlyUndelivered); + const negativeSeekMonths = (state.negativeSeekMonths != null && Number(state.negativeSeekMonths) < 0) + ? Number(state.negativeSeekMonths) + : 0; + return { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths }; +} + +export function getFilterKey(state = automat?.state || initialState) { + const { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths } = getFilterParams(state); + return JSON.stringify({ f: filter, u: onlyUnpaid, d: onlyUndelivered, s: negativeSeekMonths }); +} + +export function formatFilterLabel(filterKeyOrState) { + let f, u, d, s; + if (typeof filterKeyOrState === 'string') { + try { + const parsed = JSON.parse(filterKeyOrState); + f = parsed.f; + u = parsed.u; + d = parsed.d; + s = parsed.s; + } catch { + return filterKeyOrState; + } + } else if (filterKeyOrState && typeof filterKeyOrState === 'object') { + if ('f' in filterKeyOrState) { + f = filterKeyOrState.f; + u = filterKeyOrState.u; + d = filterKeyOrState.d; + s = filterKeyOrState.s; + } else { + const params = getFilterParams(filterKeyOrState); + f = params.filter; + u = params.onlyUnpaid; + d = params.onlyUndelivered; + s = params.negativeSeekMonths; + } + } + + const parts = []; + if (f) parts.push(`Suche: „${f}“`); + if (u) parts.push('Nur unbezahlt'); + if (d) parts.push('Nur ungeliefert'); + if (s < 0) parts.push(`Seek: ${formatNegativeSeek(s)}`); + if (parts.length === 0) return 'Standard (Alle Aufträge)'; + return parts.join(' · '); +} + +function countNonNullItems(items) { + if (!Array.isArray(items)) return 0; + let count = 0; + for (let i = 0; i < items.length; i++) { + if (items[i] != null) count++; + } + return count; +} + +function getOrCreateFilterCache(filterKey, filterState = automat?.state || initialState) { + let entry = filterCaches.get(filterKey); + if (!entry) { + const params = getFilterParams(filterState); + entry = { + key: filterKey, + label: formatFilterLabel(params), + params, + total: 0, + items: null, + loadedChunks: new Set(), + status: 'idle', + error: null, + lastAccessed: Date.now(), + epoch: queryEpoch, + }; + filterCaches.set(filterKey, entry); + } + return entry; +} + +// Touch an existing chunk in the global LRU cache to mark it as Most Recently Used +function touchChunkLRU(filterKey, chunkIndex) { + const key = makeLruKey(filterKey, chunkIndex); + const existing = lruChunks.get(key); + if (existing) { + lruChunks.delete(key); + lruChunks.set(key, { ...existing, accessedAt: Date.now() }); + } +} + +// Register a newly loaded chunk into the LRU cache and evict the oldest if limit exceeded +function registerChunkLRU(filterKey, chunkIndex, orderCount = 0) { + const key = makeLruKey(filterKey, chunkIndex); + if (lruChunks.has(key)) { + lruChunks.delete(key); + } + const now = Date.now(); + lruChunks.set(key, { + filterKey, + chunkIndex, + loadedAt: now, + accessedAt: now, + orderCount, + }); + + // Enforce global MAX_LRU_CHUNKS limit across all filter caches + while (lruChunks.size > MAX_LRU_CHUNKS) { + const oldestKey = lruChunks.keys().next().value; + const oldestInfo = lruChunks.get(oldestKey); + lruChunks.delete(oldestKey); + if (oldestInfo) { + evictChunkFromMemory(oldestInfo.filterKey, oldestInfo.chunkIndex, 'lru'); + } + } +} + +// Evicts a chunk from memory: sets items back to null in the filter cache and removes from loadedChunks +function evictChunkFromMemory(filterKey, chunkIndex, reason = 'manual') { + const entry = filterCaches.get(filterKey); + if (!entry) return; + + if (entry.loadedChunks.has(chunkIndex)) { + entry.loadedChunks.delete(chunkIndex); + if (Array.isArray(entry.items)) { + const offset = chunkIndex * CHUNK_SIZE; + for (let i = offset; i < offset + CHUNK_SIZE && i < entry.items.length; i++) { + entry.items[i] = null; + } + } + + const currentKey = getFilterKey(automat.state); + if (filterKey === currentKey) { + automat.setState({ items: [...entry.items] }); + } + + const eventType = reason === 'lru' ? 'lru_evict' : 'evict'; + const msg = reason === 'lru' + ? `LRU-Eviction: Chunk #${chunkIndex} von Filter "${entry.label}" verworfen (${lruChunks.size}/${MAX_LRU_CHUNKS} im Cache)` + : `Chunk #${chunkIndex} von Filter "${entry.label}" verworfen`; + + recordChunkEvent({ + type: eventType, + chunkIndex, + filterKey, + message: msg, + }); + notifyChunkStats(); + } +} + function recordChunkEvent(entry) { const event = { id: ++nextEventId, time: Date.now(), epoch: queryEpoch, + filterKey: entry.filterKey ?? getFilterKey(automat?.state), ...entry, }; chunkEvents.push(event); @@ -107,23 +291,61 @@ function notifyChunkStats() { } export function getChunkStats() { - const total = automat?.state?.total ?? 0; - const chunkCount = total > 0 ? Math.ceil(total / CHUNK_SIZE) : (loadedChunks.size > 0 ? Math.max(...loadedChunks) + 1 : 0); - const items = automat?.state?.items; - let loadedItemsCount = 0; - if (Array.isArray(items)) { - for (let i = 0; i < items.length; i++) { - if (items[i] != null) loadedItemsCount++; + const currentKey = getFilterKey(automat?.state); + const activeEntry = filterCaches.get(currentKey); + + const total = activeEntry?.total ?? automat?.state?.total ?? 0; + const activeLoaded = activeEntry?.loadedChunks ?? new Set(); + const chunkCount = total > 0 ? Math.ceil(total / CHUNK_SIZE) : (activeLoaded.size > 0 ? Math.max(...activeLoaded) + 1 : 0); + const items = activeEntry?.items ?? automat?.state?.items; + const loadedItemsCount = countNonNullItems(items); + + // Summarize all cached filter combinations + const cachedFiltersSummary = Array.from(filterCaches.values()).map((entry) => ({ + key: entry.key, + label: entry.label, + params: entry.params, + total: entry.total, + loadedChunksCount: entry.loadedChunks.size, + loadedChunks: Array.from(entry.loadedChunks).sort((a, b) => a - b), + chunkCount: entry.total > 0 ? Math.ceil(entry.total / CHUNK_SIZE) : (entry.loadedChunks.size > 0 ? Math.max(...entry.loadedChunks) + 1 : 0), + loadedItemsCount: countNonNullItems(entry.items), + lastAccessed: entry.lastAccessed, + isActive: entry.key === currentKey, + status: entry.status, + items: entry.items, + })); + + // LRU queue list from oldest (1) to newest (50) + const lruEntries = Array.from(lruChunks.entries()).map(([key, info], idx) => ({ + lruKey: key, + filterKey: info.filterKey, + filterLabel: filterCaches.get(info.filterKey)?.label || info.filterKey, + chunkIndex: info.chunkIndex, + loadedAt: info.loadedAt, + accessedAt: info.accessedAt, + orderCount: info.orderCount, + lruRank: idx + 1, + })); + + // Active filter's inflight chunks + const activeInflight = []; + for (const key of inflightChunks.keys()) { + if (key.startsWith(`${currentKey}:::`)) { + const parsed = parseLruKey(key); + activeInflight.push(parsed.chunkIndex); } } return { automatName: 'order', chunkSize: CHUNK_SIZE, + maxLruChunks: MAX_LRU_CHUNKS, + lruChunksCount: lruChunks.size, total, chunkCount, - loadedChunks: Array.from(loadedChunks).sort((a, b) => a - b), - inflightChunks: Array.from(inflightChunks.keys()).sort((a, b) => a - b), + loadedChunks: Array.from(activeLoaded).sort((a, b) => a - b), + inflightChunks: activeInflight.sort((a, b) => a - b), pendingChunks: Array.from(pendingTargetChunks).sort((a, b) => a - b), loadedItemsCount, status: automat?.state?.status ?? 'idle', @@ -133,6 +355,10 @@ export function getChunkStats() { onlyUndelivered: automat?.state?.onlyUndelivered ?? false, negativeSeekMonths: automat?.state?.negativeSeekMonths ?? 0, items, + activeFilterKey: currentKey, + activeFilterLabel: formatFilterLabel(currentKey), + filterCaches: cachedFiltersSummary, + lruEntries, events: [...chunkEvents], }; } @@ -165,7 +391,7 @@ function clearChunkDebounce() { } // Executes chunk loading for the standstill target and prefetches lookahead chunks -function _executeRequiredChunks(chunks, targetEpoch, prefetch = true) { +function _executeRequiredChunks(chunks, targetEpoch, prefetch = true, targetFilterKey = getFilterKey(automat.state)) { if (targetEpoch !== queryEpoch) { return Promise.resolve(automat.state.items); } @@ -178,12 +404,13 @@ function _executeRequiredChunks(chunks, targetEpoch, prefetch = true) { recordChunkEvent({ type: 'execute', chunks: chunkList, - message: `Lade Chunks [${chunkList.join(', ')}] (Epoch ${targetEpoch})`, + filterKey: targetFilterKey, + message: `Lade Chunks [${chunkList.join(', ')}] für "${formatFilterLabel(targetFilterKey)}" (Epoch ${targetEpoch})`, }); const promises = []; for (const c of chunkList) { - promises.push(actions.loadChunk(c, targetEpoch)); + promises.push(actions.loadChunk(c, targetEpoch, targetFilterKey)); } // Once active chunks finish loading, prefetch the next lookahead chunk in the background @@ -191,7 +418,8 @@ function _executeRequiredChunks(chunks, targetEpoch, prefetch = true) { if (targetEpoch !== queryEpoch) return automat.state.items; if (prefetch) { const maxChunk = chunkList[chunkList.length - 1]; - const total = automat.state.total || 0; + const entry = filterCaches.get(targetFilterKey); + const total = entry?.total || automat.state.total || 0; for (let c = maxChunk + 1; c <= maxChunk + PREFETCH_CHUNKS; c++) { if (total > 0 && c * CHUNK_SIZE >= total) { break; @@ -199,9 +427,10 @@ function _executeRequiredChunks(chunks, targetEpoch, prefetch = true) { recordChunkEvent({ type: 'prefetch', chunkIndex: c, - message: `Prefetch Chunk #${c}`, + filterKey: targetFilterKey, + message: `Prefetch Chunk #${c} für "${formatFilterLabel(targetFilterKey)}"`, }); - void actions.loadChunk(c, targetEpoch); + void actions.loadChunk(c, targetEpoch, targetFilterKey); } } return automat.state.items; @@ -260,23 +489,22 @@ export function formatNegativeSeek(months) { return `-${parts.join(' ')}`; } -// Builds server query parameters from current automat filter state -function buildQueryParams(offset = 0, limit = CHUNK_SIZE) { - const { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths } = automat.state; +// Builds server query parameters from filter params +function buildQueryParamsFor(filterParams, offset = 0, limit = CHUNK_SIZE) { const params = { offset, limit }; - const trimmed = String(filter ?? '').trim(); + const trimmed = String(filterParams.filter ?? '').trim(); if (trimmed) params.search = trimmed; - if (onlyUnpaid) params.onlyUnpaid = true; - if (onlyUndelivered) params.onlyUndelivered = true; - if (negativeSeekMonths != null && Number(negativeSeekMonths) < 0) { - params.seekMonths = Number(negativeSeekMonths); + if (filterParams.onlyUnpaid) params.onlyUnpaid = true; + if (filterParams.onlyUndelivered) params.onlyUndelivered = true; + if (filterParams.negativeSeekMonths != null && Number(filterParams.negativeSeekMonths) < 0) { + params.seekMonths = Number(filterParams.negativeSeekMonths); } return params; } -// MARK: fetches a single chunk from GET /api/order with server-side filters -async function fetchOrdersPage(offset = 0, limit = CHUNK_SIZE) { - const params = buildQueryParams(offset, limit); +// Fetches a single chunk from GET /api/order with server-side filters +async function fetchOrdersPageFor(filterParams, offset = 0, limit = CHUNK_SIZE) { + const params = buildQueryParamsFor(filterParams, offset, limit); const res = await orderApi.list(params); const orders = Array.isArray(res?.orders) ? res.orders : []; const total = Number(res?.total ?? orders.length); @@ -298,18 +526,62 @@ function onFilterChange(immediate = false) { } } +// Invoked when active filters change: switches to the matching filter cache without wiping! function applyFilterReload() { queryEpoch++; clearChunkDebounce(); - loadedChunks.clear(); - inflightChunks.clear(); - recordChunkEvent({ - type: 'filter_reload', - epoch: queryEpoch, - message: `Filter angewendet (Epoch ${queryEpoch})`, - }); - notifyChunkStats(); - void actions.loadChunk(0, queryEpoch); + + const currentKey = getFilterKey(automat.state); + const cached = filterCaches.get(currentKey); + + if (cached && cached.items !== null) { + // CACHE HIT FOR THIS FILTER COMBINATION: restore instantly from RAM! + cached.lastAccessed = Date.now(); + for (const c of cached.loadedChunks) { + touchChunkLRU(currentKey, c); + } + + automat.setState({ + items: cached.items, + total: cached.total, + status: cached.status || 'ready', + error: cached.error || null, + }); + + recordChunkEvent({ + type: 'filter_hit', + epoch: queryEpoch, + filterKey: currentKey, + message: `Filter gewechselt: "${cached.label}" (Cache-Hit: ${cached.loadedChunks.size} Chunks / ${countNonNullItems(cached.items)} Aufträge im RAM)`, + }); + notifyChunkStats(); + + // If chunk 0 was evicted or not yet loaded in this filter, fetch chunk 0 + if (!cached.loadedChunks.has(0)) { + void actions.loadChunk(0, queryEpoch, currentKey); + } + } else { + // CACHE MISS: initialize fresh filter entry and load chunk 0 + const entry = getOrCreateFilterCache(currentKey, automat.state); + entry.lastAccessed = Date.now(); + + automat.setState({ + items: null, + total: 0, + status: 'loading', + error: null, + }); + + recordChunkEvent({ + type: 'filter_miss', + epoch: queryEpoch, + filterKey: currentKey, + message: `Filter gewechselt: "${entry.label}" (Cache-Miss: Lade Chunk #0)`, + }); + notifyChunkStats(); + + void actions.loadChunk(0, queryEpoch, currentKey); + } } const actions = { @@ -350,6 +622,7 @@ const actions = { start = Math.max(0, Math.floor(start)); end = Math.max(start, Math.floor(end)); const prefetch = options.prefetch !== false; + const currentKey = getFilterKey(automat.state); // If initial items are null or immediate requested, bypass debounce and load immediately if (options.immediate || automat.state.items === null) { @@ -359,9 +632,10 @@ const actions = { recordChunkEvent({ type: 'require_immediate', chunks: Array.from(chunks), - message: `Sofortige Anforderung: Chunks ${start}..${end}`, + filterKey: currentKey, + message: `Sofortige Anforderung: Chunks ${start}..${end} (${formatFilterLabel(currentKey)})`, }); - return _executeRequiredChunks(chunks, queryEpoch, prefetch); + return _executeRequiredChunks(chunks, queryEpoch, prefetch, currentKey); } // New event tick (drag mousemove / pagination click) replaces previous target position @@ -385,6 +659,7 @@ const actions = { recordChunkEvent({ type: 'pending', chunks: Array.from(pendingTargetChunks), + filterKey: currentKey, message: `Debounce vorgemerkt (${CHUNK_DEBOUNCE_MS}ms): Chunks [${Array.from(pendingTargetChunks).join(', ')}]`, }); notifyChunkStats(); @@ -417,10 +692,11 @@ const actions = { recordChunkEvent({ type: 'debounce_fire', chunks: Array.from(chunksToLoad), + filterKey: currentKey, message: `Debounce abgelaufen: Starte Chunks [${Array.from(chunksToLoad).join(', ')}]`, }); - _executeRequiredChunks(chunksToLoad, captureEpoch, true) + _executeRequiredChunks(chunksToLoad, captureEpoch, true, currentKey) .then((res) => resolve?.(res)) .catch((err) => reject?.(err)); }, CHUNK_DEBOUNCE_MS); @@ -428,110 +704,134 @@ const actions = { return pendingChunkPromise; }, - // MARK: fetches a single chunk and populates prefilled array - loadChunk(chunkIndex, targetEpoch = queryEpoch) { + // MARK: fetches a single chunk and populates the filter cache and prefilled array + loadChunk(chunkIndex, targetEpoch = queryEpoch, targetFilterKey = getFilterKey(automat.state)) { const index = Math.max(0, Number(chunkIndex) || 0); - if (targetEpoch !== queryEpoch) { - return Promise.resolve(automat.state.items); + const entry = getOrCreateFilterCache(targetFilterKey, automat.state); + + if (targetEpoch !== queryEpoch && targetFilterKey !== getFilterKey(automat.state)) { + return Promise.resolve(entry.items); } - if (loadedChunks.has(index)) { + + if (entry.loadedChunks.has(index)) { + touchChunkLRU(targetFilterKey, index); recordChunkEvent({ type: 'hit', chunkIndex: index, - message: `Cache-Hit: Chunk #${index} bereits im Speicher`, + filterKey: targetFilterKey, + message: `Cache-Hit: Chunk #${index} für Filter "${entry.label}" bereits im Speicher`, }); - return Promise.resolve(automat.state.items); + return Promise.resolve(targetFilterKey === getFilterKey(automat.state) ? automat.state.items : entry.items); } - if (inflightChunks.has(index)) { + + const inflightKey = makeLruKey(targetFilterKey, index); + if (inflightChunks.has(inflightKey)) { recordChunkEvent({ type: 'inflight_hit', chunkIndex: index, - message: `Inflight-Hit: Chunk #${index} lädt bereits`, + filterKey: targetFilterKey, + message: `Inflight-Hit: Chunk #${index} für Filter "${entry.label}" lädt bereits`, }); - return inflightChunks.get(index); + return inflightChunks.get(inflightKey); } const offset = index * CHUNK_SIZE; const limit = CHUNK_SIZE; - if (index === 0 && automat.state.items === null) { - automat.setState({ status: 'loading', error: null }); + const isActiveFilter = targetFilterKey === getFilterKey(automat.state); + + if (index === 0 && (entry.items === null || (isActiveFilter && automat.state.items === null))) { + entry.status = 'loading'; + entry.error = null; + if (isActiveFilter) { + automat.setState({ status: 'loading', error: null }); + } } const startTime = Date.now(); recordChunkEvent({ type: 'start', chunkIndex: index, - message: `Netzwerk-Request: Lade Chunk #${index} (Offset ${offset}..${offset + limit - 1})`, + filterKey: targetFilterKey, + message: `Netzwerk-Request: Lade Chunk #${index} (Offset ${offset}..${offset + limit - 1}) für "${entry.label}"`, }); - const promise = fetchOrdersPage(offset, limit) + const filterParams = entry.params || getFilterParams(automat.state); + const promise = fetchOrdersPageFor(filterParams, offset, limit) .then(({ orders, total }) => { - // Discard if active filter query changed while request was in flight - if (targetEpoch !== queryEpoch) { - return automat.state.items; - } - - let items = automat.state.items; const currentTotal = total > 0 ? total : 0; + entry.total = currentTotal; - if (items === null || items.length !== currentTotal) { - // Pre-fill items array with empty (null) items based on server filtered total - items = new Array(currentTotal).fill(null); + if (entry.items === null || entry.items.length !== currentTotal) { + entry.items = new Array(currentTotal).fill(null); } else { - items = [...items]; + entry.items = [...entry.items]; } // Fill in real received items for this chunk for (let i = 0; i < orders.length; i++) { const idx = offset + i; - if (idx < items.length) { - items[idx] = orders[i]; + if (idx < entry.items.length) { + entry.items[idx] = orders[i]; } } + entry.loadedChunks.add(index); + entry.status = 'ready'; + entry.error = null; + entry.lastAccessed = Date.now(); + + // Register chunk in global LRU (evicts oldest chunk if > MAX_LRU_CHUNKS) + registerChunkLRU(targetFilterKey, index, orders.length); + + const currentActive = targetFilterKey === getFilterKey(automat.state); + if (currentActive) { + automat.setState({ + items: entry.items, + total: currentTotal, + status: 'ready', + error: null, + }); + } + const duration = Date.now() - startTime; - loadedChunks.add(index); - - automat.setState({ - items, - total: currentTotal, - status: 'ready', - error: null, - }); - recordChunkEvent({ type: 'loaded', chunkIndex: index, + filterKey: targetFilterKey, count: orders.length, duration, - message: `Chunk #${index} geladen (${orders.length} Aufträge in ${duration}ms)`, + message: `Chunk #${index} geladen (${orders.length} Aufträge in ${duration}ms) für "${entry.label}"`, }); - return items; + return currentActive ? automat.state.items : entry.items; }) .catch((err) => { - if (targetEpoch === queryEpoch) { - const duration = Date.now() - startTime; - console.error(`[orderAutomat] loadChunk(${index}) error:`, err); - recordChunkEvent({ - type: 'error', - chunkIndex: index, - duration, - error: err?.message || String(err), - message: `Fehler bei Chunk #${index}: ${err?.message || err}`, - }); + entry.status = 'failed'; + entry.error = err; + const currentActive = targetFilterKey === getFilterKey(automat.state); + if (currentActive) { if (index === 0 && automat.state.items === null) { automat.setState({ status: 'failed', error: err }); } } + const duration = Date.now() - startTime; + console.error(`[orderAutomat] loadChunk(${index}, ${targetFilterKey}) error:`, err); + recordChunkEvent({ + type: 'error', + chunkIndex: index, + filterKey: targetFilterKey, + duration, + error: err?.message || String(err), + message: `Fehler bei Chunk #${index} ("${entry.label}"): ${err?.message || err}`, + }); throw err; }) .finally(() => { - inflightChunks.delete(index); + inflightChunks.delete(inflightKey); notifyChunkStats(); }); - inflightChunks.set(index, promise); + inflightChunks.set(inflightKey, promise); notifyChunkStats(); return promise; }, @@ -600,7 +900,7 @@ const actions = { // MARK: initial load load() { - return actions.loadChunk(0, queryEpoch); + return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state)); }, // MARK: ensure loaded @@ -608,27 +908,43 @@ const actions = { if (automat.state.items !== null) { return Promise.resolve(automat.state.items); } - return actions.loadChunk(0, queryEpoch); + return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state)); }, - // MARK: reload with current active server filters - async reload() { + // MARK: reload active filter or invalidate all + async reload(onlyCurrent = false) { queryEpoch++; clearChunkDebounce(); - loadedChunks.clear(); - inflightChunks.clear(); - await actions.loadChunk(0, queryEpoch); + + if (onlyCurrent) { + // Invalidate only current filter's cached chunks + const currentKey = getFilterKey(automat.state); + actions.clearFilterCache(currentKey); + } else { + // Full reload (e.g. after SSE mutation or document deletion): clear all cached data + actions.clearCache(); + } + + await actions.loadChunk(0, queryEpoch, getFilterKey(automat.state)); return automat.state.items; }, // MARK: replace items directly replace(items) { clearChunkDebounce(); - loadedChunks.clear(); - inflightChunks.clear(); + const currentKey = getFilterKey(automat.state); + actions.clearFilterCache(currentKey); + const count = Array.isArray(items) ? items.length : 0; + const entry = getOrCreateFilterCache(currentKey, automat.state); + entry.items = items; + entry.total = count; + entry.status = 'ready'; + entry.error = null; + recordChunkEvent({ type: 'replace', + filterKey: currentKey, message: `Items ersetzt (${count} Einträge)`, }); notifyChunkStats(); @@ -641,81 +957,134 @@ const actions = { return items; }, - // MARK: evicts a specific chunk from cache for testing - evictChunk(chunkIndex) { + // MARK: evicts a specific chunk from cache (optional filterKey, defaults to active) + evictChunk(chunkIndex, filterKey = getFilterKey(automat.state)) { const index = Math.max(0, Number(chunkIndex) || 0); - if (!loadedChunks.has(index)) return automat.state.items; - loadedChunks.delete(index); - let items = automat.state.items; - if (Array.isArray(items)) { - items = [...items]; - const offset = index * CHUNK_SIZE; - for (let i = offset; i < offset + CHUNK_SIZE && i < items.length; i++) { - items[i] = null; - } - automat.setState({ items }); + const key = makeLruKey(filterKey, index); + if (lruChunks.has(key)) { + lruChunks.delete(key); } + evictChunkFromMemory(filterKey, index, 'manual'); + return automat.state.items; + }, + + // MARK: clears a specific filter cache and removes its chunks from LRU + clearFilterCache(filterKey = getFilterKey(automat.state)) { + // Remove all chunks of this filter from LRU + for (const [lruKey, info] of Array.from(lruChunks.entries())) { + if (info.filterKey === filterKey) { + lruChunks.delete(lruKey); + } + } + + const entry = filterCaches.get(filterKey); + if (entry) { + entry.loadedChunks.clear(); + entry.items = null; + entry.total = 0; + entry.status = 'idle'; + entry.error = null; + } + + if (filterKey === getFilterKey(automat.state)) { + automat.setState({ + items: null, + total: 0, + status: 'idle', + error: null, + }); + } + recordChunkEvent({ - type: 'evict', - chunkIndex: index, - message: `Chunk #${index} aus Cache entfernt (evicted)`, + type: 'clear_filter', + filterKey, + message: `Filter-Cache für "${formatFilterLabel(filterKey)}" geleert`, }); notifyChunkStats(); return automat.state.items; }, - // MARK: clears all loaded chunks from cache + // MARK: clears all loaded chunks and filter caches from RAM clearCache() { clearChunkDebounce(); - loadedChunks.clear(); + lruChunks.clear(); inflightChunks.clear(); - let items = automat.state.items; - if (Array.isArray(items)) { - items = new Array(automat.state.total || 0).fill(null); - automat.setState({ items }); + + for (const entry of filterCaches.values()) { + entry.loadedChunks.clear(); + entry.items = null; + entry.total = 0; + entry.status = 'idle'; + entry.error = null; } + + automat.setState({ + items: null, + total: 0, + status: 'idle', + error: null, + }); + recordChunkEvent({ type: 'clear', - message: 'Alle Chunks aus dem Cache entfernt', + message: 'Gesamter LRU-Cache und alle Filter-Caches geleert', }); notifyChunkStats(); return automat.state.items; }, - // MARK: prefetches all remaining chunks - prefetchAllChunks() { - const total = automat.state.total || 0; + // MARK: prefetches all remaining chunks for a filter (bounded by MAX_LRU_CHUNKS) + prefetchAllChunks(filterKey = getFilterKey(automat.state)) { + const entry = getOrCreateFilterCache(filterKey, automat.state); + const total = entry.total || automat.state.total || 0; const chunkCount = Math.ceil(total / CHUNK_SIZE); + recordChunkEvent({ type: 'prefetch_all', - message: `Starte Prefetch aller ${chunkCount} Chunks`, + filterKey, + message: `Starte Prefetch aller ${chunkCount} Chunks für "${entry.label}"`, }); + for (let c = 0; c < chunkCount; c++) { - if (!loadedChunks.has(c) && !inflightChunks.has(c)) { - void actions.loadChunk(c, queryEpoch); + const inflightKey = makeLruKey(filterKey, c); + if (!entry.loadedChunks.has(c) && !inflightChunks.has(inflightKey)) { + void actions.loadChunk(c, queryEpoch, filterKey); } } notifyChunkStats(); }, - // MARK: patches an order locally in the automat items cache + // MARK: patches an order locally across all filter caches patchOrder(id, patch) { - if (!Array.isArray(automat.state.items)) return automat.state.items; let changed = false; - const items = automat.state.items.map((o) => { - if (o && o.id === id) { - changed = true; - return { ...o, ...patch }; + for (const entry of filterCaches.values()) { + if (Array.isArray(entry.items)) { + entry.items = entry.items.map((o) => { + if (o && o.id === id) { + changed = true; + return { ...o, ...patch }; + } + return o; + }); } - return o; - }); - if (changed) { - automat.setState({ items }); } - return items; + + if (Array.isArray(automat.state.items)) { + const items = automat.state.items.map((o) => { + if (o && o.id === id) { + changed = true; + return { ...o, ...patch }; + } + return o; + }); + if (changed) { + automat.setState({ items }); + } + } + return automat.state.items; }, - // MARK: resets state + // MARK: resets state and all caches completely (e.g. on logout / company switch) reset() { queryEpoch++; if (filterDebounceTimer) { @@ -723,11 +1092,13 @@ const actions = { filterDebounceTimer = null; } clearChunkDebounce(); - loadedChunks.clear(); + filterCaches.clear(); + lruChunks.clear(); inflightChunks.clear(); + recordChunkEvent({ type: 'reset', - message: 'Order-Automat zurückgesetzt', + message: 'Order-Automat und sämtliche Caches zurückgesetzt', }); notifyChunkStats(); automat.setState(initialState); @@ -749,11 +1120,15 @@ export function requireChunk(chunkIndex, endOrOptions, maybeOptions) { orderAutomat.getItems = getItems; orderAutomat.requireChunk = requireChunk; orderAutomat.CHUNK_SIZE = CHUNK_SIZE; +orderAutomat.MAX_LRU_CHUNKS = MAX_LRU_CHUNKS; orderAutomat.getChunkStats = getChunkStats; orderAutomat.subscribeChunkStats = subscribeChunkStats; orderAutomat.evictChunk = actions.evictChunk; +orderAutomat.clearFilterCache = actions.clearFilterCache; orderAutomat.clearCache = actions.clearCache; orderAutomat.prefetchAllChunks = actions.prefetchAllChunks; +orderAutomat.getFilterKey = getFilterKey; +orderAutomat.formatFilterLabel = formatFilterLabel; // Reset on logout or company change let lastCompanyId = null; diff --git a/src/components/manifest/OrderList.jsx b/src/components/manifest/OrderList.jsx index 272e35e..71cc723 100644 --- a/src/components/manifest/OrderList.jsx +++ b/src/components/manifest/OrderList.jsx @@ -222,6 +222,7 @@ class OrderSearchField extends PureComponent { constructor(props) { super(props); const s = orderAutomat.state; + const stats = orderAutomat.getChunkStats?.(); this.state = { input: s.filter ?? '', onlyUnpaid: s.onlyUnpaid ?? false, @@ -229,6 +230,9 @@ class OrderSearchField extends PureComponent { seekMonths: s.seekMonths ?? 0, seekYears: s.seekYears ?? 0, negativeSeekMonths: s.negativeSeekMonths ?? 0, + lruChunksCount: stats?.lruChunksCount || 0, + maxLruChunks: stats?.maxLruChunks || 50, + filterCachesCount: stats?.filterCaches?.length || 1, }; } @@ -241,10 +245,20 @@ class OrderSearchField extends PureComponent { seekYears: state.seekYears ?? 0, negativeSeekMonths: state.negativeSeekMonths ?? 0, })); + if (import.meta.env.DEV) { + this.unsubStats = orderAutomat.subscribeChunkStats?.((stats) => { + this.setState({ + lruChunksCount: stats.lruChunksCount, + maxLruChunks: stats.maxLruChunks, + filterCachesCount: stats.filterCaches?.length || 1, + }); + }); + } } componentWillUnmount() { this.unsub?.(); + this.unsubStats?.(); } render() { @@ -427,11 +441,11 @@ class OrderSearchField extends PureComponent { {import.meta.env.DEV && ( - + } - label="Chunk-Cache" + label={this.state.lruChunksCount > 0 ? `Chunk-Cache (${this.state.lruChunksCount}/${this.state.maxLruChunks || 50})` : 'Chunk-Cache'} onClick={() => window.dispatchEvent(new CustomEvent('dash:toggle-chunk-monitor'))} sx={{ cursor: 'pointer', diff --git a/src/devtools/ChunkCacheMonitor.jsx b/src/devtools/ChunkCacheMonitor.jsx index 000f9c2..9528b31 100644 --- a/src/devtools/ChunkCacheMonitor.jsx +++ b/src/devtools/ChunkCacheMonitor.jsx @@ -19,7 +19,6 @@ import ViewModuleIcon from '@mui/icons-material/ViewModule'; import CloseIcon from '@mui/icons-material/Close'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import RefreshIcon from '@mui/icons-material/Refresh'; -import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'; import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined'; import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; @@ -29,17 +28,21 @@ import HistoryIcon from '@mui/icons-material/History'; import GridViewIcon from '@mui/icons-material/GridView'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import LayersIcon from '@mui/icons-material/Layers'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import orderAutomat, { CHUNK_SIZE, + MAX_LRU_CHUNKS, isOrderPaid, isOrderDelivered, subscribeChunkStats, } from '../automats/orderAutomat'; import { formatPrice } from '../utils/price'; -const PANEL_WIDTH = 620; -const PANEL_HEIGHT = 480; +const PANEL_WIDTH = 680; +const PANEL_HEIGHT = 520; const pulseAnimationA = keyframes` 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(25, 118, 210, 0); } @@ -69,29 +72,38 @@ function orderGrossTotal(lines) { class ChunkCacheMonitor extends PureComponent { constructor(props) { super(props); + const initialStats = orderAutomat.getChunkStats?.() || { + chunkSize: CHUNK_SIZE, + maxLruChunks: MAX_LRU_CHUNKS, + lruChunksCount: 0, + total: 0, + chunkCount: 0, + loadedChunks: [], + inflightChunks: [], + pendingChunks: [], + loadedItemsCount: 0, + status: 'idle', + queryEpoch: 0, + filter: '', + onlyUnpaid: false, + onlyUndelivered: false, + negativeSeekMonths: 0, + items: null, + activeFilterKey: '', + activeFilterLabel: 'Standard', + filterCaches: [], + lruEntries: [], + events: [], + }; + this.state = { open: false, position: null, - tab: 'grid', // 'grid' | 'log' + tab: 'grid', // 'grid' | 'lru' | 'log' + inspectFilterKey: null, // null means follow activeFilterKey selectedChunk: null, pulseCounter: 0, - stats: orderAutomat.getChunkStats?.() || { - chunkSize: CHUNK_SIZE, - total: 0, - chunkCount: 0, - loadedChunks: [], - inflightChunks: [], - pendingChunks: [], - loadedItemsCount: 0, - status: 'idle', - queryEpoch: 0, - filter: '', - onlyUnpaid: false, - onlyUndelivered: false, - negativeSeekMonths: 0, - items: null, - events: [], - }, + stats: initialStats, }; this.panelRef = createRef(); @@ -105,6 +117,8 @@ class ChunkCacheMonitor extends PureComponent { this.handleLoadChunk = this.handleLoadChunk.bind(this); this.handleEvictChunk = this.handleEvictChunk.bind(this); this.handleClearCache = this.handleClearCache.bind(this); + this.handleClearFilter = this.handleClearFilter.bind(this); + this.handleApplyFilter = this.handleApplyFilter.bind(this); this.handlePrefetchAll = this.handlePrefetchAll.bind(this); this.handleReload = this.handleReload.bind(this); } @@ -152,7 +166,7 @@ class ChunkCacheMonitor extends PureComponent { } handlePointerDown(event) { - if (event.target.closest('button') || event.target.closest('.MuiTab-root')) return; + if (event.target.closest('button') || event.target.closest('.MuiTab-root') || event.target.closest('.MuiChip-root')) return; const rect = this.panelRef.current?.getBoundingClientRect(); if (!rect) return; event.preventDefault(); @@ -188,14 +202,16 @@ class ChunkCacheMonitor extends PureComponent { })); } - handleLoadChunk(chunkIndex, e) { + handleLoadChunk(chunkIndex, filterKey, e) { e?.stopPropagation(); - void orderAutomat.actions.requireChunk(chunkIndex, chunkIndex, { immediate: true }); + const targetFilter = filterKey || this.getInspectedFilterKey(); + void orderAutomat.actions.loadChunk(chunkIndex, orderAutomat.state.queryEpoch, targetFilter); } - handleEvictChunk(chunkIndex, e) { + handleEvictChunk(chunkIndex, filterKey, e) { e?.stopPropagation(); - orderAutomat.evictChunk?.(chunkIndex); + const targetFilter = filterKey || this.getInspectedFilterKey(); + orderAutomat.evictChunk?.(chunkIndex, targetFilter); if (this.state.selectedChunk === chunkIndex) { this.setState({ selectedChunk: null }); } @@ -206,34 +222,145 @@ class ChunkCacheMonitor extends PureComponent { this.setState({ selectedChunk: null }); } - handlePrefetchAll() { - orderAutomat.prefetchAllChunks?.(); + handleClearFilter(filterKey, e) { + e?.stopPropagation(); + orderAutomat.clearFilterCache?.(filterKey); + if (this.state.inspectFilterKey === filterKey) { + this.setState({ inspectFilterKey: null, selectedChunk: null }); + } + } + + handleApplyFilter(filterKey) { + const entry = this.state.stats.filterCaches?.find((f) => f.key === filterKey); + if (!entry) return; + const p = entry.params || {}; + orderAutomat.actions.resetFilters(); + if (p.filter) orderAutomat.actions.filter(p.filter); + if (p.onlyUnpaid) orderAutomat.actions.setOnlyUnpaid(true); + if (p.onlyUndelivered) orderAutomat.actions.setOnlyUndelivered(true); + if (p.negativeSeekMonths) orderAutomat.actions.addNegativeSeek(p.negativeSeekMonths); + this.setState({ inspectFilterKey: null, selectedChunk: null }); + } + + handlePrefetchAll(filterKey) { + const targetFilter = filterKey || this.getInspectedFilterKey(); + orderAutomat.prefetchAllChunks?.(targetFilter); } handleReload() { - void orderAutomat.actions.reload(); + void orderAutomat.actions.reload(false); this.setState({ selectedChunk: null }); } + getInspectedFilterKey() { + return this.state.inspectFilterKey || this.state.stats.activeFilterKey; + } + + getInspectedFilterEntry() { + const key = this.getInspectedFilterKey(); + const entry = this.state.stats.filterCaches?.find((f) => f.key === key); + if (entry) return entry; + + return { + key, + label: this.state.stats.activeFilterLabel || 'Aktiver Filter', + total: this.state.stats.total, + loadedChunksCount: this.state.stats.loadedChunks.length, + loadedChunks: this.state.stats.loadedChunks, + chunkCount: this.state.stats.chunkCount, + loadedItemsCount: this.state.stats.loadedItemsCount, + isActive: true, + items: this.state.stats.items, + status: this.state.stats.status, + }; + } + + renderFilterSelector() { + const { stats, inspectFilterKey } = this.state; + const filterCaches = stats.filterCaches || []; + const activeKey = stats.activeFilterKey; + const currentInspected = this.getInspectedFilterKey(); + + return ( + + + Filter-Caches ({filterCaches.length}): + + + {filterCaches.map((entry) => { + const isInspected = entry.key === currentInspected; + const isActive = entry.key === activeKey; + + return ( + this.setState({ inspectFilterKey: entry.key, selectedChunk: null })} + onDelete={!isActive ? (e) => this.handleClearFilter(entry.key, e) : undefined} + sx={{ + fontWeight: isInspected ? 700 : 500, + fontSize: '0.72rem', + height: 26, + cursor: 'pointer', + whiteSpace: 'nowrap', + flexShrink: 0, + borderWidth: isInspected ? 2 : 1, + }} + /> + ); + })} + + ); + } + renderChunkGrid() { const { stats, selectedChunk } = this.state; - const { total, chunkCount, loadedChunks, inflightChunks, pendingChunks, items } = stats; - const loadedSet = new Set(loadedChunks); - const inflightSet = new Set(inflightChunks); - const pendingSet = new Set(pendingChunks); + const currentFilter = this.getInspectedFilterEntry(); + const isActiveFilter = currentFilter.key === stats.activeFilterKey; + + const { total, chunkCount, loadedChunks, items } = currentFilter; + const loadedSet = new Set(loadedChunks || []); + const inflightSet = new Set(isActiveFilter ? stats.inflightChunks : []); + const pendingSet = new Set(isActiveFilter ? stats.pendingChunks : []); + + // LRU rank lookup Map: lruKey -> rank (1..50) + const lruRankMap = new Map(); + if (Array.isArray(stats.lruEntries)) { + for (const entry of stats.lruEntries) { + if (entry.filterKey === currentFilter.key) { + lruRankMap.set(entry.chunkIndex, entry.lruRank); + } + } + } - // If total is 0 and no chunks loaded, show uninitialized state if (chunkCount === 0 && (!items || items.length === 0)) { return ( - Automat ist noch nicht initialisiert (keine Daten geladen). + Keine Daten für diesen Filter geladen ({currentFilter.label}). @@ -249,11 +376,11 @@ class ChunkCacheMonitor extends PureComponent { const isInflight = inflightSet.has(c); const isPending = pendingSet.has(c); const isSelected = selectedChunk === c; + const lruRank = lruRankMap.get(c); const offsetStart = c * CHUNK_SIZE; const offsetEnd = Math.min((c + 1) * CHUNK_SIZE - 1, Math.max(0, total - 1)); - // Calculate loaded items inside this chunk let itemsInChunk = 0; if (Array.isArray(items)) { for (let i = offsetStart; i <= offsetEnd; i++) { @@ -266,20 +393,16 @@ class ChunkCacheMonitor extends PureComponent { let borderStyle = '1px solid'; let borderColor = 'divider'; let bgColor = 'background.paper'; - let statusLabel = 'Nicht geladen'; if (isLoaded) { - statusLabel = `Geladen (${itemsInChunk}/${expectedInChunk})`; statusColor = 'success.main'; borderColor = 'success.main'; bgColor = 'rgba(46, 125, 50, 0.08)'; } else if (isInflight) { - statusLabel = 'Lädt über Netzwerk...'; statusColor = 'info.main'; borderColor = 'info.main'; bgColor = 'rgba(2, 136, 209, 0.08)'; } else if (isPending) { - statusLabel = 'Vorgemerkt (Debounce)'; statusColor = 'warning.main'; borderStyle = '1px dashed'; borderColor = 'warning.main'; @@ -289,7 +412,7 @@ class ChunkCacheMonitor extends PureComponent { blocks.push( (isLoaded ? this.handleSelectChunk(c) : this.handleLoadChunk(c))} + onClick={() => (isLoaded ? this.handleSelectChunk(c) : this.handleLoadChunk(c, currentFilter.key))} sx={{ position: 'relative', p: 1, @@ -309,7 +432,7 @@ class ChunkCacheMonitor extends PureComponent { display: 'flex', flexDirection: 'column', justifyContent: 'space-between', - minHeight: 74, + minHeight: 82, }} > {/* Top Row: Chunk Number & Status Indicator */} @@ -318,27 +441,46 @@ class ChunkCacheMonitor extends PureComponent { #{c} - {isLoaded ? ( - - - - ) : isInflight ? ( - - - - ) : isPending ? ( - - - - ) : ( - - - - )} + + {isLoaded && lruRank != null && ( + + + + )} + + {isLoaded ? ( + + + + ) : isInflight ? ( + + + + ) : isPending ? ( + + + + ) : ( + + + + )} + {/* Middle: Item range index */} - + {offsetStart}–{offsetEnd} @@ -350,18 +492,18 @@ class ChunkCacheMonitor extends PureComponent { fontWeight: 600, color: statusColor, fontVariantNumeric: 'tabular-nums', - fontSize: '0.7rem', + fontSize: '0.68rem', }} > - {isLoaded ? `${itemsInChunk} Aufträge` : isInflight ? 'Laden…' : isPending ? 'Wartet…' : 'Klick: Laden'} + {isLoaded ? `${itemsInChunk}/${expectedInChunk}` : isInflight ? 'Laden…' : isPending ? 'Wartet…' : 'Klick: Laden'} {isLoaded && ( - + this.handleEvictChunk(c, e)} + onClick={(e) => this.handleEvictChunk(c, currentFilter.key, e)} sx={{ p: 0.25, ml: 0.5 }} > @@ -374,26 +516,70 @@ class ChunkCacheMonitor extends PureComponent { } return ( - - {blocks} + + {/* Inactive filter inspector banner */} + {!isActiveFilter && ( + + + Inspektion: {currentFilter.label} + + + + + + + )} + + + {blocks} + ); } renderChunkDetail() { - const { stats, selectedChunk } = this.state; + const { selectedChunk } = this.state; if (selectedChunk === null) return null; - const { items, total } = stats; + const currentFilter = this.getInspectedFilterEntry(); + const { items, total } = currentFilter; const offsetStart = selectedChunk * CHUNK_SIZE; const offsetEnd = Math.min((selectedChunk + 1) * CHUNK_SIZE - 1, Math.max(0, total - 1)); @@ -432,11 +618,11 @@ class ChunkCacheMonitor extends PureComponent { - Chunk #{selectedChunk} Inspection + Chunk #{selectedChunk} Detail @@ -447,16 +633,16 @@ class ChunkCacheMonitor extends PureComponent { size="small" color="warning" variant="outlined" - onClick={() => this.handleEvictChunk(selectedChunk)} + onClick={() => this.handleEvictChunk(selectedChunk, currentFilter.key)} sx={{ textTransform: 'none', py: 0.25, fontSize: '0.75rem' }} > - Evict (Aus Cache) + Aus Cache verwerfen