1211 lines
45 KiB
JavaScript
1211 lines
45 KiB
JavaScript
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;
|