import { PureComponent, createRef } from 'react'; import { keyframes } from '@emotion/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Chip from '@mui/material/Chip'; import CircularProgress from '@mui/material/CircularProgress'; import Divider from '@mui/material/Divider'; import Fab from '@mui/material/Fab'; import IconButton from '@mui/material/IconButton'; import LinearProgress from '@mui/material/LinearProgress'; import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; import Tab from '@mui/material/Tab'; import Tabs from '@mui/material/Tabs'; import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import Badge from '@mui/material/Badge'; 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 CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined'; import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; import LayersClearIcon from '@mui/icons-material/LayersClear'; import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck'; 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, } from '../automats/orderAutomat'; import quoteAutomat from '../automats/quoteAutomat'; import shipmentAutomat from '../automats/shipmentAutomat'; import deliveryAutomat from '../automats/deliveryAutomat'; import { formatPrice } from '../utils/price'; function getAutomatFor(target) { if (target === 'quote') return quoteAutomat; if (target === 'shipment') return shipmentAutomat; if (target === 'delivery') return deliveryAutomat; return orderAutomat; } function getInitialTarget() { if (typeof window !== 'undefined') { const p = window.location.pathname; if (p.startsWith('/lieferung')) return 'delivery'; if (p.startsWith('/auftrag/ausliefern')) return 'shipment'; if (p.startsWith('/auftrag/bestellung')) return 'order'; if (p.startsWith('/auftrag')) return 'quote'; } return 'order'; } 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); } 35% { transform: scale(1.15); box-shadow: 0 0 0 8px rgba(25, 118, 210, 0.45); } 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(25, 118, 210, 0); } `; const pulseAnimationB = keyframes` 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(25, 118, 210, 0); } 35% { transform: scale(1.15); box-shadow: 0 0 0 8px rgba(25, 118, 210, 0.45); } 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(25, 118, 210, 0); } `; const inflightGlow = keyframes` 0% { border-color: rgba(2, 136, 209, 0.4); box-shadow: 0 0 4px rgba(2, 136, 209, 0.2); } 50% { border-color: rgba(2, 136, 209, 1); box-shadow: 0 0 10px rgba(2, 136, 209, 0.6); } 100% { border-color: rgba(2, 136, 209, 0.4); box-shadow: 0 0 4px rgba(2, 136, 209, 0.2); } `; function orderGrossTotal(lines) { if (!Array.isArray(lines)) return 0; return lines.reduce((acc, line) => { const net = (Number(line.amount) || 0) * (Number(line.net) || 0) * (1 - (Number(line.rebate) || 0) / 100); return acc + net * (1 + (Number(line.taxrate) || 0) / 100); }, 0); } class ChunkCacheMonitor extends PureComponent { constructor(props) { super(props); const target = getInitialTarget(); const automat = getAutomatFor(target); const initialStats = automat.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' | 'lru' | 'log' target, // 'quote' | 'order' inspectFilterKey: null, // null means follow activeFilterKey selectedChunk: null, pulseCounter: 0, stats: initialStats, }; this.panelRef = createRef(); this.handleToggle = this.handleToggle.bind(this); this.handleClose = this.handleClose.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.handlePointerDown = this.handlePointerDown.bind(this); this.handlePointerMove = this.handlePointerMove.bind(this); this.handlePointerUp = this.handlePointerUp.bind(this); this.handleSelectChunk = this.handleSelectChunk.bind(this); 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); this.switchTarget = this.switchTarget.bind(this); } componentDidMount() { const automat = getAutomatFor(this.state.target); this.unsubscribeStats = automat.subscribeChunkStats?.((stats) => { this.setState((prev) => ({ stats, pulseCounter: prev.pulseCounter + 1, })); }); window.addEventListener('keydown', this.handleKeyDown); this.customEventListener = (event) => { const reqTarget = event?.detail?.target; if (reqTarget === 'quote' || reqTarget === 'order' || reqTarget === 'shipment') { this.switchTarget(reqTarget); } else { const detected = getInitialTarget(); if (detected !== this.state.target) { this.switchTarget(detected); } } this.handleToggle(); }; window.addEventListener('dash:toggle-chunk-monitor', this.customEventListener); } switchTarget(newTarget) { if (newTarget === this.state.target) return; this.unsubscribeStats?.(); const automat = getAutomatFor(newTarget); this.unsubscribeStats = automat.subscribeChunkStats?.((stats) => { this.setState((prev) => ({ stats, pulseCounter: prev.pulseCounter + 1, })); }); this.setState({ target: newTarget, stats: automat.getChunkStats?.() || this.state.stats, selectedChunk: null, inspectFilterKey: null, }); } componentWillUnmount() { this.unsubscribeStats?.(); window.removeEventListener('keydown', this.handleKeyDown); if (this.customEventListener) { window.removeEventListener('dash:toggle-chunk-monitor', this.customEventListener); } } handleKeyDown(event) { if (event.altKey && event.shiftKey && event.key?.toLowerCase() === 'c') { event.preventDefault(); this.handleToggle(); } else if (event.key === 'Escape' && this.state.open) { if (this.state.selectedChunk !== null) { this.setState({ selectedChunk: null }); } else { this.handleClose(); } } } handleToggle() { this.setState((state) => ({ open: !state.open })); } handleClose() { this.setState({ open: false, selectedChunk: null }); } handlePointerDown(event) { 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(); event.currentTarget.setPointerCapture(event.pointerId); this.drag = { pointerId: event.pointerId, pointerX: event.clientX, pointerY: event.clientY, left: rect.left, top: rect.top, }; } handlePointerMove(event) { if (!this.drag || event.pointerId !== this.drag.pointerId) return; const maxLeft = Math.max(0, window.innerWidth - PANEL_WIDTH); const maxTop = Math.max(0, window.innerHeight - 120); this.setState({ position: { left: Math.max(0, Math.min(maxLeft, this.drag.left + event.clientX - this.drag.pointerX)), top: Math.max(0, Math.min(maxTop, this.drag.top + event.clientY - this.drag.pointerY)), }, }); } handlePointerUp(event) { if (this.drag?.pointerId === event.pointerId) this.drag = null; } handleSelectChunk(chunkIndex) { this.setState((prev) => ({ selectedChunk: prev.selectedChunk === chunkIndex ? null : chunkIndex, })); } handleLoadChunk(chunkIndex, filterKey, e) { e?.stopPropagation(); const automat = getAutomatFor(this.state.target); const targetFilter = filterKey || this.getInspectedFilterKey(); void automat.actions.loadChunk(chunkIndex, automat.state.queryEpoch, targetFilter); } handleEvictChunk(chunkIndex, filterKey, e) { e?.stopPropagation(); const automat = getAutomatFor(this.state.target); const targetFilter = filterKey || this.getInspectedFilterKey(); automat.evictChunk?.(chunkIndex, targetFilter); if (this.state.selectedChunk === chunkIndex) { this.setState({ selectedChunk: null }); } } handleClearCache() { const automat = getAutomatFor(this.state.target); automat.clearCache?.(); this.setState({ selectedChunk: null }); } handleClearFilter(filterKey, e) { e?.stopPropagation(); const automat = getAutomatFor(this.state.target); automat.clearFilterCache?.(filterKey); if (this.state.inspectFilterKey === filterKey) { this.setState({ inspectFilterKey: null, selectedChunk: null }); } } handleApplyFilter(filterKey) { const automat = getAutomatFor(this.state.target); const entry = this.state.stats.filterCaches?.find((f) => f.key === filterKey); if (!entry) return; const p = entry.params || {}; automat.actions.resetFilters(); if (p.filter) automat.actions.filter(p.filter); if (this.state.target === 'order') { if (p.onlyUnpaid) automat.actions.setOnlyUnpaid(true); if (p.onlyUndelivered) automat.actions.setOnlyUndelivered(true); } if (this.state.target === 'delivery') { if (p.onlyUnbooked) automat.actions.setOnlyUnbooked(true); if (p.supplierId) automat.actions.setSupplierId(p.supplierId); } if (this.state.target === 'shipment') { if (p.stateFilter) automat.actions.setStateFilter(p.stateFilter); } if (p.negativeSeekMonths) automat.actions.addNegativeSeek(p.negativeSeekMonths); this.setState({ inspectFilterKey: null, selectedChunk: null }); } handlePrefetchAll(filterKey) { const automat = getAutomatFor(this.state.target); const targetFilter = filterKey || this.getInspectedFilterKey(); automat.prefetchAllChunks?.(targetFilter); } handleReload() { const automat = getAutomatFor(this.state.target); void automat.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 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 (chunkCount === 0 && (!items || items.length === 0)) { return ( Keine Daten für diesen Filter geladen ({currentFilter.label}). ); } const blocks = []; const count = Math.max(chunkCount, 1); for (let c = 0; c < count; c++) { const isLoaded = loadedSet.has(c); 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)); let itemsInChunk = 0; if (Array.isArray(items)) { for (let i = offsetStart; i <= offsetEnd; i++) { if (items[i] != null) itemsInChunk++; } } const expectedInChunk = Math.max(0, offsetEnd - offsetStart + 1); let statusColor = 'text.secondary'; let borderStyle = '1px solid'; let borderColor = 'divider'; let bgColor = 'background.paper'; if (isLoaded) { statusColor = 'success.main'; borderColor = 'success.main'; bgColor = 'rgba(46, 125, 50, 0.08)'; } else if (isInflight) { statusColor = 'info.main'; borderColor = 'info.main'; bgColor = 'rgba(2, 136, 209, 0.08)'; } else if (isPending) { statusColor = 'warning.main'; borderStyle = '1px dashed'; borderColor = 'warning.main'; bgColor = 'rgba(237, 108, 2, 0.08)'; } blocks.push( (isLoaded ? this.handleSelectChunk(c) : this.handleLoadChunk(c, currentFilter.key))} sx={{ position: 'relative', p: 1, borderRadius: 1.5, border: borderStyle, borderColor: isSelected ? 'primary.main' : borderColor, boxShadow: isSelected ? '0 0 0 2px rgba(25, 118, 210, 0.4)' : 1, bgcolor: isSelected ? 'rgba(25, 118, 210, 0.12)' : bgColor, cursor: 'pointer', transition: 'all 0.15s ease-in-out', animation: isInflight ? `${inflightGlow} 1.5s infinite` : 'none', '&:hover': { borderColor: 'primary.main', transform: 'translateY(-1px)', boxShadow: 2, }, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', minHeight: 82, }} > {/* Top Row: Chunk Number & Status Indicator */} #{c} {isLoaded && lruRank != null && ( )} {isLoaded ? ( ) : isInflight ? ( ) : isPending ? ( ) : ( )} {/* Middle: Item range index */} {offsetStart}–{offsetEnd} {/* Bottom row: Item count / load action */} {isLoaded ? `${itemsInChunk}/${expectedInChunk}` : isInflight ? 'Laden…' : isPending ? 'Wartet…' : 'Klick: Laden'} {isLoaded && ( this.handleEvictChunk(c, currentFilter.key, e)} sx={{ p: 0.25, ml: 0.5 }} > )} , ); } return ( {/* Inactive filter inspector banner */} {!isActiveFilter && ( Inspektion: {currentFilter.label} )} {blocks} ); } renderChunkDetail() { const { selectedChunk } = this.state; if (selectedChunk === null) return null; 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)); const chunkOrders = []; if (Array.isArray(items)) { for (let i = offsetStart; i <= offsetEnd; i++) { if (items[i] != null) { chunkOrders.push({ item: items[i], index: i }); } } } return ( this.setState({ selectedChunk: null })}> Chunk #{selectedChunk} Detail {chunkOrders.length === 0 ? ( Keine Aufträge in diesem Chunk geladen. ) : ( {chunkOrders.map(({ item: order, index }) => { const isOrd = this.state.target === 'order'; const isShip = this.state.target === 'shipment'; const isDeliv = this.state.target === 'delivery'; const paid = isOrd ? isOrderPaid(order) : false; const delivered = isOrd ? isOrderDelivered(order) : false; const gross = order.grossTotal != null ? Number(order.grossTotal) : (order.lines ? orderGrossTotal(order.lines) : 0); const customer = isDeliv ? (order.supplierName || (order.supplierId ? `Lieferant #${order.supplierId}` : '–')) : (order.customerName || (order.entityId ? `Kunde #${order.entityId}` : '–')); const posCount = order.positions != null ? order.positions : (order.lines?.length ?? 0); const unbooked = order.ledger?.unbookedPositions ?? 0; return ( #{index} {order.number || `ID ${order.id}`} {order.date ? new Date(order.date).toLocaleDateString() : '–'} {customer} {isOrd && ( <> )} {isDeliv && ( )} {isShip && ( <> {order.carrier && ( )} )} {!isShip && gross > 0 ? formatPrice(gross) : (isShip && order.totalQuantity ? `${order.totalQuantity} Stk` : '–')} ); })} )} ); } renderLruQueue() { const { stats } = this.state; const lruEntries = stats.lruEntries || []; if (lruEntries.length === 0) { return ( Der globale LRU-Cache ist aktuell leer (keine Chunks im RAM). ); } return ( Geordnet nach Alter (LRU). Position #1 ist der älteste Chunk und wird als nächstes automatisch verworfen, sobald das Gesamtlimit von {stats.maxLruChunks || 50} Chunks überschritten wird. {lruEntries.map((entry) => { const isOldest = entry.lruRank === 1; const isNewest = entry.lruRank === lruEntries.length; const offsetStart = entry.chunkIndex * CHUNK_SIZE; const offsetEnd = (entry.chunkIndex + 1) * CHUNK_SIZE - 1; return ( {isOldest && ( )} {entry.filterLabel} Chunk #{entry.chunkIndex} (Index {offsetStart}..{offsetEnd}) · {entry.orderCount} Aufträge geladen {new Date(entry.accessedAt).toLocaleTimeString()} this.handleEvictChunk(entry.chunkIndex, entry.filterKey, e)} > ); })} ); } renderEventLog() { const { stats } = this.state; const events = stats.events || []; if (events.length === 0) { return ( Noch keine Chunk-Ereignisse protokolliert. ); } return ( {[...events].reverse().map((entry) => { const timeStr = new Date(entry.time).toLocaleTimeString(); let chipColor = 'default'; if (entry.type === 'loaded' || entry.type === 'hit') chipColor = 'success'; if (entry.type === 'filter_hit') chipColor = 'secondary'; if (entry.type === 'filter_miss' || entry.type === 'start') chipColor = 'info'; if (entry.type === 'pending' || entry.type === 'debounce_fire') chipColor = 'warning'; if (entry.type === 'lru_evict' || entry.type === 'evict' || entry.type === 'error') chipColor = 'error'; return ( {timeStr} {entry.message} ); })} ); } render() { const { open, position, tab, stats, pulseCounter } = this.state; const { lruChunksCount, maxLruChunks, inflightChunks } = stats; const maxLru = maxLruChunks || MAX_LRU_CHUNKS; const lruCount = lruChunksCount || 0; const lruPercent = Math.min(100, Math.round((lruCount / maxLru) * 100)); const hasInflight = inflightChunks.length > 0; const filterCachesCount = stats.filterCaches?.length || 1; return ( <> {!open ? ( = maxLru ? 'warning' : 'success'} overlap="circular" max={999} sx={{ position: 'fixed', left: { xs: 27, sm: 171 }, bottom: { xs: 180, sm: 190 }, zIndex: (theme) => theme.zIndex.tooltip, }} > ) : ( theme.zIndex.tooltip, border: '1px solid', borderColor: 'divider', borderRadius: 2, }} > {/* Header / Drag Bar */} Chunk-Cache-Monitor this.switchTarget('quote')} sx={{ cursor: 'pointer', height: 24, fontSize: '0.72rem', fontWeight: this.state.target === 'quote' ? 700 : 500 }} /> this.switchTarget('order')} sx={{ cursor: 'pointer', height: 24, fontSize: '0.72rem', fontWeight: this.state.target === 'order' ? 700 : 500 }} /> this.switchTarget('delivery')} sx={{ cursor: 'pointer', height: 24, fontSize: '0.72rem', fontWeight: this.state.target === 'delivery' ? 700 : 500 }} /> this.switchTarget('shipment')} sx={{ cursor: 'pointer', height: 24, fontSize: '0.72rem', fontWeight: this.state.target === 'shipment' ? 700 : 500 }} /> = maxLru ? 'warning' : 'success'} label={`LRU: ${lruCount}/${maxLru} Chunks`} /> this.handlePrefetchAll()}> {/* Metrics & Progress Ribbon */} LRU-Speicher: {lruPercent}% ({lruCount} von {maxLru} Chunks belegt) Aktiver Filter: {stats.activeFilterLabel} = maxLru ? 'warning' : 'primary'} sx={{ height: 6, borderRadius: 3, bgcolor: 'divider', '& .MuiLinearProgress-bar': { borderRadius: 3 }, }} /> {/* Filter Caches Switcher Bar */} {this.renderFilterSelector()} {/* View Tabs */} this.setState({ tab: val })} sx={{ minHeight: 36, '& .MuiTab-root': { minHeight: 36, py: 0.5, fontSize: '0.75rem' } }} > } iconPosition="start" label="Chunk-Map" value="grid" /> } iconPosition="start" label={`LRU-Queue (${lruCount}/${maxLru})`} value="lru" /> } iconPosition="start" label={`Ereignis-Log (${stats.events?.length || 0})`} value="log" /> {/* Body */} {tab === 'grid' && this.renderChunkGrid()} {tab === 'grid' && this.renderChunkDetail()} {tab === 'lru' && this.renderLruQueue()} {tab === 'log' && this.renderEventLog()} )} ); } } export default ChunkCacheMonitor;