Files
dashfront/src/devtools/ChunkCacheMonitor.jsx
2026-10-16 23:53:09 +02:00

1211 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<Box
sx={{
px: 1.5,
py: 0.75,
bgcolor: 'background.paper',
borderBottom: '1px solid',
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
gap: 1,
overflowX: 'auto',
'&::-webkit-scrollbar': { height: 4 },
'&::-webkit-scrollbar-thumb': { bgcolor: 'divider', borderRadius: 2 },
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', whiteSpace: 'nowrap' }}>
Filter-Caches ({filterCaches.length}):
</Typography>
{filterCaches.map((entry) => {
const isInspected = entry.key === currentInspected;
const isActive = entry.key === activeKey;
return (
<Chip
key={entry.key}
size="small"
variant={isInspected ? 'filled' : 'outlined'}
color={isActive ? 'primary' : isInspected ? 'secondary' : 'default'}
label={`${entry.label} (${entry.loadedChunksCount} Chunks)`}
onClick={() => 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,
}}
/>
);
})}
</Box>
);
}
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 (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Keine Daten für diesen Filter geladen ({currentFilter.label}).
</Typography>
<Button
size="small"
variant="contained"
startIcon={<CloudDownloadIcon />}
onClick={() => this.handleLoadChunk(0, currentFilter.key)}
>
Ersten Chunk (#0) laden
</Button>
</Box>
);
}
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(
<Box
key={c}
onClick={() => (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 */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
#{c}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{isLoaded && lruRank != null && (
<Tooltip title={lruRank === 1 ? 'Ältester Chunk im RAM: Wird bei Überschreiten von 50 Chunks als nächstes verworfen!' : `LRU-Position #${lruRank} von ${stats.lruChunksCount}`}>
<Chip
size="small"
label={`LRU #${lruRank}`}
color={lruRank === 1 ? 'warning' : 'default'}
sx={{
height: 18,
fontSize: '0.62rem',
fontWeight: 700,
px: 0.25,
'& .MuiChip-label': { px: 0.5 },
}}
/>
</Tooltip>
)}
{isLoaded ? (
<Tooltip title="Im RAM">
<CheckCircleOutlinedIcon fontSize="small" color="success" sx={{ fontSize: 16 }} />
</Tooltip>
) : isInflight ? (
<Tooltip title="Netzwerk-Request läuft">
<CircularProgress size={14} color="info" />
</Tooltip>
) : isPending ? (
<Tooltip title="Wartet auf Debounce">
<HourglassEmptyIcon fontSize="small" color="warning" sx={{ fontSize: 16 }} />
</Tooltip>
) : (
<Tooltip title="Klicken zum Laden">
<CloudDownloadIcon fontSize="small" color="action" sx={{ fontSize: 16, opacity: 0.6 }} />
</Tooltip>
)}
</Box>
</Box>
{/* Middle: Item range index */}
<Typography variant="caption" color="text.secondary" sx={{ fontVariantNumeric: 'tabular-nums', fontSize: '0.72rem' }}>
{offsetStart}–{offsetEnd}
</Typography>
{/* Bottom row: Item count / load action */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 0.5 }}>
<Typography
variant="caption"
sx={{
fontWeight: 600,
color: statusColor,
fontVariantNumeric: 'tabular-nums',
fontSize: '0.68rem',
}}
>
{isLoaded ? `${itemsInChunk}/${expectedInChunk}` : isInflight ? 'Laden…' : isPending ? 'Wartet…' : 'Klick: Laden'}
</Typography>
{isLoaded && (
<Tooltip title="Chunk aus RAM verwerfen (Evict)">
<IconButton
size="small"
aria-label={`Chunk ${c} verwerfen`}
onClick={(e) => this.handleEvictChunk(c, currentFilter.key, e)}
sx={{ p: 0.25, ml: 0.5 }}
>
<DeleteOutlinedIcon sx={{ fontSize: 14 }} color="action" />
</IconButton>
</Tooltip>
)}
</Box>
</Box>,
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
{/* Inactive filter inspector banner */}
{!isActiveFilter && (
<Box
sx={{
px: 1.5,
py: 0.6,
bgcolor: 'action.selected',
borderBottom: '1px solid',
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
Inspektion: {currentFilter.label}
</Typography>
<Stack direction="row" spacing={1}>
<Button
size="small"
variant="outlined"
color="primary"
startIcon={<PlayArrowIcon sx={{ fontSize: 14 }} />}
onClick={() => this.handleApplyFilter(currentFilter.key)}
sx={{ py: 0.1, px: 1, fontSize: '0.72rem', textTransform: 'none', height: 24 }}
>
In UI anwenden
</Button>
<Button
size="small"
variant="text"
color="inherit"
onClick={() => this.setState({ inspectFilterKey: null, selectedChunk: null })}
sx={{ py: 0.1, px: 1, fontSize: '0.72rem', textTransform: 'none', height: 24 }}
>
Zum aktiven Filter
</Button>
</Stack>
</Box>
)}
<Box
sx={{
p: 1.5,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(115px, 1fr))',
gap: 1.25,
overflowY: 'auto',
flex: 1,
}}
>
{blocks}
</Box>
</Box>
);
}
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 (
<Box
sx={{
borderTop: '1px solid',
borderColor: 'divider',
bgcolor: 'background.default',
maxHeight: 220,
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
px: 1.5,
py: 0.75,
bgcolor: 'action.hover',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<IconButton size="small" onClick={() => this.setState({ selectedChunk: null })}>
<ArrowBackIcon fontSize="small" />
</IconButton>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
Chunk #{selectedChunk} Detail
</Typography>
<Chip
size="small"
label={`${chunkOrders.length} geladene Aufträge (Index ${offsetStart}–${offsetEnd})`}
color="primary"
variant="outlined"
/>
</Stack>
<Stack direction="row" spacing={0.5}>
<Button
size="small"
color="warning"
variant="outlined"
onClick={() => this.handleEvictChunk(selectedChunk, currentFilter.key)}
sx={{ textTransform: 'none', py: 0.25, fontSize: '0.75rem' }}
>
Aus Cache verwerfen
</Button>
<Button
size="small"
color="primary"
variant="contained"
onClick={() => this.handleLoadChunk(selectedChunk, currentFilter.key)}
sx={{ textTransform: 'none', py: 0.25, fontSize: '0.75rem' }}
>
Neu laden
</Button>
</Stack>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', p: 1 }}>
{chunkOrders.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ p: 1 }}>
Keine Aufträge in diesem Chunk geladen.
</Typography>
) : (
<Stack spacing={0.5}>
{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 (
<Box
key={order.id ?? index}
sx={{
display: 'flex',
alignItems: 'center',
px: 1,
py: 0.5,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
gap: 1.5,
}}
>
<Typography
variant="caption"
sx={{ width: 44, color: 'text.secondary', fontVariantNumeric: 'tabular-nums' }}
>
#{index}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, width: 110 }}>
{order.number || `ID ${order.id}`}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ width: 85 }}>
{order.date ? new Date(order.date).toLocaleDateString() : '–'}
</Typography>
<Typography variant="caption" sx={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{customer}
</Typography>
<Chip
size="small"
label={`${posCount} Pos.`}
sx={{ height: 20, fontSize: '0.7rem' }}
/>
{isOrd && (
<>
<Chip
size="small"
label={paid ? 'Bezahlt' : 'Unbezahlt'}
color={paid ? 'success' : 'default'}
sx={{ height: 20, fontSize: '0.7rem' }}
/>
<Chip
size="small"
label={delivered ? 'Geliefert' : 'Offen'}
color={delivered ? 'primary' : 'default'}
sx={{ height: 20, fontSize: '0.7rem' }}
/>
</>
)}
{isDeliv && (
<Chip
size="small"
label={unbooked === 0 ? 'Gebucht' : `${unbooked} Ungebucht`}
color={unbooked === 0 ? 'success' : 'warning'}
sx={{ height: 20, fontSize: '0.7rem' }}
/>
)}
{isShip && (
<>
<Chip
size="small"
label={order.state === 'delivered' ? 'Geliefert' : order.state === 'returned' ? 'Retoure' : 'Neu'}
color={order.state === 'delivered' ? 'success' : order.state === 'returned' ? 'error' : 'info'}
sx={{ height: 20, fontSize: '0.7rem' }}
/>
{order.carrier && (
<Chip
size="small"
variant="outlined"
label={order.carrier}
sx={{ height: 20, fontSize: '0.7rem' }}
/>
)}
</>
)}
<Typography
variant="caption"
sx={{ fontWeight: 600, width: 75, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}
>
{!isShip && gross > 0 ? formatPrice(gross) : (isShip && order.totalQuantity ? `${order.totalQuantity} Stk` : '–')}
</Typography>
</Box>
);
})}
</Stack>
)}
</Box>
</Box>
);
}
renderLruQueue() {
const { stats } = this.state;
const lruEntries = stats.lruEntries || [];
if (lruEntries.length === 0) {
return (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Der globale LRU-Cache ist aktuell leer (keine Chunks im RAM).
</Typography>
</Box>
);
}
return (
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ px: 2, py: 1, bgcolor: 'action.hover', borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" color="text.secondary">
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.
</Typography>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', p: 1.5 }}>
<Stack spacing={1}>
{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 (
<Paper
key={entry.lruKey}
variant="outlined"
sx={{
p: 1.25,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
borderColor: isOldest ? 'warning.main' : isNewest ? 'primary.main' : 'divider',
bgcolor: isOldest ? 'rgba(237, 108, 2, 0.04)' : 'background.paper',
}}
>
<Stack direction="row" spacing={1.5} sx={{ minWidth: 0, flex: 1, alignItems: 'center' }}>
<Chip
size="small"
label={`#${entry.lruRank}`}
color={isOldest ? 'warning' : isNewest ? 'primary' : 'default'}
sx={{ fontWeight: 700, width: 44 }}
/>
{isOldest && (
<Tooltip title="Wird bei neuem Chunk als nächstes verworfen">
<WarningAmberIcon fontSize="small" color="warning" />
</Tooltip>
)}
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{entry.filterLabel}
</Typography>
<Typography variant="caption" color="text.secondary">
Chunk #{entry.chunkIndex} (Index {offsetStart}..{offsetEnd}) · {entry.orderCount} Aufträge geladen
</Typography>
</Box>
</Stack>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Typography variant="caption" color="text.secondary" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{new Date(entry.accessedAt).toLocaleTimeString()}
</Typography>
<Tooltip title="Diesen Chunk manuell aus dem Cache entfernen">
<IconButton
size="small"
color="warning"
onClick={(e) => this.handleEvictChunk(entry.chunkIndex, entry.filterKey, e)}
>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Paper>
);
})}
</Stack>
</Box>
</Box>
);
}
renderEventLog() {
const { stats } = this.state;
const events = stats.events || [];
if (events.length === 0) {
return (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Noch keine Chunk-Ereignisse protokolliert.
</Typography>
</Box>
);
}
return (
<Box sx={{ flex: 1, overflowY: 'auto', p: 1.25 }}>
<Stack spacing={0.75}>
{[...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 (
<Box
key={entry.id}
sx={{
display: 'flex',
alignItems: 'center',
px: 1.25,
py: 0.6,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
gap: 1.5,
}}
>
<Typography variant="caption" color="text.secondary" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{timeStr}
</Typography>
<Chip
size="small"
label={entry.type.toUpperCase()}
color={chipColor}
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700 }}
/>
<Typography variant="body2" sx={{ flex: 1, fontSize: '0.8rem' }}>
{entry.message}
</Typography>
</Box>
);
})}
</Stack>
</Box>
);
}
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 ? (
<Tooltip title={`Chunk-Cache-Monitor (Alt+Shift+C) — ${lruCount}/${maxLru} Chunks im RAM`} placement="right">
<Badge
badgeContent={`${lruCount}/${maxLru}`}
color={lruCount >= 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,
}}
>
<Fab
size="small"
color={hasInflight ? 'info' : 'primary'}
aria-label="Chunk-Cache-Monitor umschalten"
aria-pressed={open}
onClick={this.handleToggle}
sx={{
animation: hasInflight
? `${pulseCounter % 2 === 0 ? pulseAnimationA : pulseAnimationB} 800ms ease-out infinite`
: 'none',
}}
>
<ViewModuleIcon fontSize="small" />
</Fab>
</Badge>
</Tooltip>
) : (
<Paper
ref={this.panelRef}
component="aside"
aria-label="Chunk-Cache-Monitor"
elevation={12}
sx={{
position: 'fixed',
...(position ? { left: position.left, top: position.top } : { right: 16, bottom: 16 }),
width: `min(${PANEL_WIDTH}px, calc(100vw - 24px))`,
height: `min(${PANEL_HEIGHT}px, calc(100vh - 24px))`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
zIndex: (theme) => theme.zIndex.tooltip,
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
}}
>
{/* Header / Drag Bar */}
<Stack
direction="row"
alignItems="center"
spacing={1}
onPointerDown={this.handlePointerDown}
onPointerMove={this.handlePointerMove}
onPointerUp={this.handlePointerUp}
onPointerCancel={this.handlePointerUp}
sx={{
px: 1.5,
py: 0.75,
borderBottom: '1px solid',
borderColor: 'divider',
cursor: 'grab',
touchAction: 'none',
userSelect: 'none',
bgcolor: 'background.paper',
'&:active': { cursor: 'grabbing' },
}}
>
<DragIndicatorIcon fontSize="small" color="action" />
<ViewModuleIcon fontSize="small" color="primary" />
<Typography variant="subtitle2" sx={{ fontWeight: 700, flexGrow: 1 }}>
Chunk-Cache-Monitor
</Typography>
<Stack direction="row" spacing={0.5}>
<Chip
size="small"
color={this.state.target === 'quote' ? 'primary' : 'default'}
variant={this.state.target === 'quote' ? 'filled' : 'outlined'}
label="Angebote (quoteAutomat)"
onClick={() => this.switchTarget('quote')}
sx={{ cursor: 'pointer', height: 24, fontSize: '0.72rem', fontWeight: this.state.target === 'quote' ? 700 : 500 }}
/>
<Chip
size="small"
color={this.state.target === 'order' ? 'primary' : 'default'}
variant={this.state.target === 'order' ? 'filled' : 'outlined'}
label="Bestellungen (orderAutomat)"
onClick={() => this.switchTarget('order')}
sx={{ cursor: 'pointer', height: 24, fontSize: '0.72rem', fontWeight: this.state.target === 'order' ? 700 : 500 }}
/>
<Chip
size="small"
color={this.state.target === 'delivery' ? 'primary' : 'default'}
variant={this.state.target === 'delivery' ? 'filled' : 'outlined'}
label="Lieferungen (deliveryAutomat)"
onClick={() => this.switchTarget('delivery')}
sx={{ cursor: 'pointer', height: 24, fontSize: '0.72rem', fontWeight: this.state.target === 'delivery' ? 700 : 500 }}
/>
<Chip
size="small"
color={this.state.target === 'shipment' ? 'primary' : 'default'}
variant={this.state.target === 'shipment' ? 'filled' : 'outlined'}
label="Lieferscheine (shipmentAutomat)"
onClick={() => this.switchTarget('shipment')}
sx={{ cursor: 'pointer', height: 24, fontSize: '0.72rem', fontWeight: this.state.target === 'shipment' ? 700 : 500 }}
/>
</Stack>
<Chip
size="small"
color={lruCount >= maxLru ? 'warning' : 'success'}
label={`LRU: ${lruCount}/${maxLru} Chunks`}
/>
<Chip
size="small"
variant="outlined"
label={`${filterCachesCount} Filter`}
/>
<Tooltip title="Prefetch All Chunks">
<IconButton size="small" aria-label="Alle Chunks vorladen" onClick={() => this.handlePrefetchAll()}>
<PlaylistAddCheckIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Gesamten Cache leeren">
<IconButton size="small" aria-label="Cache leeren" onClick={this.handleClearCache}>
<LayersClearIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Neu laden">
<IconButton size="small" aria-label="Neu laden" onClick={this.handleReload}>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Schließen (Esc)">
<IconButton size="small" aria-label="Schließen" onClick={this.handleClose}>
<CloseIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
{/* Metrics & Progress Ribbon */}
<Box sx={{ px: 2, py: 1, bgcolor: 'action.hover', borderBottom: '1px solid', borderColor: 'divider' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
LRU-Speicher: {lruPercent}% ({lruCount} von {maxLru} Chunks belegt)
</Typography>
<Typography variant="caption" color="text.secondary">
Aktiver Filter: {stats.activeFilterLabel}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={lruPercent}
color={lruCount >= maxLru ? 'warning' : 'primary'}
sx={{
height: 6,
borderRadius: 3,
bgcolor: 'divider',
'& .MuiLinearProgress-bar': { borderRadius: 3 },
}}
/>
</Box>
{/* Filter Caches Switcher Bar */}
{this.renderFilterSelector()}
{/* View Tabs */}
<Box sx={{ borderBottom: '1px solid', borderColor: 'divider', px: 1 }}>
<Tabs
value={tab}
onChange={(_, val) => this.setState({ tab: val })}
sx={{ minHeight: 36, '& .MuiTab-root': { minHeight: 36, py: 0.5, fontSize: '0.75rem' } }}
>
<Tab icon={<GridViewIcon fontSize="small" />} iconPosition="start" label="Chunk-Map" value="grid" />
<Tab
icon={<LayersIcon fontSize="small" />}
iconPosition="start"
label={`LRU-Queue (${lruCount}/${maxLru})`}
value="lru"
/>
<Tab
icon={<HistoryIcon fontSize="small" />}
iconPosition="start"
label={`Ereignis-Log (${stats.events?.length || 0})`}
value="log"
/>
</Tabs>
</Box>
{/* Body */}
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{tab === 'grid' && this.renderChunkGrid()}
{tab === 'grid' && this.renderChunkDetail()}
{tab === 'lru' && this.renderLruQueue()}
{tab === 'log' && this.renderEventLog()}
</Box>
</Paper>
)}
</>
);
}
}
export default ChunkCacheMonitor;