u
This commit is contained in:
822
src/devtools/ChunkCacheMonitor.jsx
Normal file
822
src/devtools/ChunkCacheMonitor.jsx
Normal file
@@ -0,0 +1,822 @@
|
||||
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 DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
|
||||
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
|
||||
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty';
|
||||
import CloudDownloadIcon from '@mui/icons-material/CloudDownload';
|
||||
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 orderAutomat, {
|
||||
CHUNK_SIZE,
|
||||
isOrderPaid,
|
||||
isOrderDelivered,
|
||||
subscribeChunkStats,
|
||||
} from '../automats/orderAutomat';
|
||||
import { formatPrice } from '../utils/price';
|
||||
|
||||
const PANEL_WIDTH = 620;
|
||||
const PANEL_HEIGHT = 480;
|
||||
|
||||
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);
|
||||
this.state = {
|
||||
open: false,
|
||||
position: null,
|
||||
tab: 'grid', // 'grid' | 'log'
|
||||
selectedChunk: null,
|
||||
pulseCounter: 0,
|
||||
stats: orderAutomat.getChunkStats?.() || {
|
||||
chunkSize: CHUNK_SIZE,
|
||||
total: 0,
|
||||
chunkCount: 0,
|
||||
loadedChunks: [],
|
||||
inflightChunks: [],
|
||||
pendingChunks: [],
|
||||
loadedItemsCount: 0,
|
||||
status: 'idle',
|
||||
queryEpoch: 0,
|
||||
filter: '',
|
||||
onlyUnpaid: false,
|
||||
onlyUndelivered: false,
|
||||
negativeSeekMonths: 0,
|
||||
items: null,
|
||||
events: [],
|
||||
},
|
||||
};
|
||||
|
||||
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.handlePrefetchAll = this.handlePrefetchAll.bind(this);
|
||||
this.handleReload = this.handleReload.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubscribeStats = subscribeChunkStats((stats) => {
|
||||
this.setState((prev) => ({
|
||||
stats,
|
||||
pulseCounter: prev.pulseCounter + 1,
|
||||
}));
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', this.handleKeyDown);
|
||||
this.customEventListener = () => this.handleToggle();
|
||||
window.addEventListener('dash:toggle-chunk-monitor', this.customEventListener);
|
||||
}
|
||||
|
||||
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')) 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, e) {
|
||||
e?.stopPropagation();
|
||||
void orderAutomat.actions.requireChunk(chunkIndex, chunkIndex, { immediate: true });
|
||||
}
|
||||
|
||||
handleEvictChunk(chunkIndex, e) {
|
||||
e?.stopPropagation();
|
||||
orderAutomat.evictChunk?.(chunkIndex);
|
||||
if (this.state.selectedChunk === chunkIndex) {
|
||||
this.setState({ selectedChunk: null });
|
||||
}
|
||||
}
|
||||
|
||||
handleClearCache() {
|
||||
orderAutomat.clearCache?.();
|
||||
this.setState({ selectedChunk: null });
|
||||
}
|
||||
|
||||
handlePrefetchAll() {
|
||||
orderAutomat.prefetchAllChunks?.();
|
||||
}
|
||||
|
||||
handleReload() {
|
||||
void orderAutomat.actions.reload();
|
||||
this.setState({ selectedChunk: null });
|
||||
}
|
||||
|
||||
renderChunkGrid() {
|
||||
const { stats, selectedChunk } = this.state;
|
||||
const { total, chunkCount, loadedChunks, inflightChunks, pendingChunks, items } = stats;
|
||||
const loadedSet = new Set(loadedChunks);
|
||||
const inflightSet = new Set(inflightChunks);
|
||||
const pendingSet = new Set(pendingChunks);
|
||||
|
||||
// If total is 0 and no chunks loaded, show uninitialized state
|
||||
if (chunkCount === 0 && (!items || items.length === 0)) {
|
||||
return (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Automat ist noch nicht initialisiert (keine Daten geladen).
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<CloudDownloadIcon />}
|
||||
onClick={() => this.handleLoadChunk(0)}
|
||||
>
|
||||
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 offsetStart = c * CHUNK_SIZE;
|
||||
const offsetEnd = Math.min((c + 1) * CHUNK_SIZE - 1, Math.max(0, total - 1));
|
||||
|
||||
// Calculate loaded items inside this chunk
|
||||
let itemsInChunk = 0;
|
||||
if (Array.isArray(items)) {
|
||||
for (let i = offsetStart; i <= offsetEnd; i++) {
|
||||
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';
|
||||
let statusLabel = 'Nicht geladen';
|
||||
|
||||
if (isLoaded) {
|
||||
statusLabel = `Geladen (${itemsInChunk}/${expectedInChunk})`;
|
||||
statusColor = 'success.main';
|
||||
borderColor = 'success.main';
|
||||
bgColor = 'rgba(46, 125, 50, 0.08)';
|
||||
} else if (isInflight) {
|
||||
statusLabel = 'Lädt über Netzwerk...';
|
||||
statusColor = 'info.main';
|
||||
borderColor = 'info.main';
|
||||
bgColor = 'rgba(2, 136, 209, 0.08)';
|
||||
} else if (isPending) {
|
||||
statusLabel = 'Vorgemerkt (Debounce)';
|
||||
statusColor = 'warning.main';
|
||||
borderStyle = '1px dashed';
|
||||
borderColor = 'warning.main';
|
||||
bgColor = 'rgba(237, 108, 2, 0.08)';
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<Box
|
||||
key={c}
|
||||
onClick={() => (isLoaded ? this.handleSelectChunk(c) : this.handleLoadChunk(c))}
|
||||
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: 74,
|
||||
}}
|
||||
>
|
||||
{/* 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>
|
||||
|
||||
{isLoaded ? (
|
||||
<Tooltip title="Im Cache">
|
||||
<CheckCircleOutlinedIcon fontSize="small" color="success" sx={{ fontSize: 18 }} />
|
||||
</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>
|
||||
|
||||
{/* Middle: Item range index */}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{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.7rem',
|
||||
}}
|
||||
>
|
||||
{isLoaded ? `${itemsInChunk} Aufträge` : isInflight ? 'Laden…' : isPending ? 'Wartet…' : 'Klick: Laden'}
|
||||
</Typography>
|
||||
|
||||
{isLoaded && (
|
||||
<Tooltip title="Diesen Chunk aus Cache verwerfen (Evict)">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`Chunk ${c} verwerfen`}
|
||||
onClick={(e) => this.handleEvictChunk(c, e)}
|
||||
sx={{ p: 0.25, ml: 0.5 }}
|
||||
>
|
||||
<DeleteOutlinedIcon sx={{ fontSize: 14 }} color="action" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(105px, 1fr))',
|
||||
gap: 1.25,
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{blocks}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
renderChunkDetail() {
|
||||
const { stats, selectedChunk } = this.state;
|
||||
if (selectedChunk === null) return null;
|
||||
|
||||
const { items, total } = stats;
|
||||
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} alignItems="center">
|
||||
<IconButton size="small" onClick={() => this.setState({ selectedChunk: null })}>
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
Chunk #{selectedChunk} Inspection
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${chunkOrders.length} Einträ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)}
|
||||
sx={{ textTransform: 'none', py: 0.25, fontSize: '0.75rem' }}
|
||||
>
|
||||
Evict (Aus Cache)
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="contained"
|
||||
onClick={() => this.handleLoadChunk(selectedChunk)}
|
||||
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 paid = isOrderPaid(order);
|
||||
const delivered = isOrderDelivered(order);
|
||||
const gross = orderGrossTotal(order.lines);
|
||||
|
||||
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' }}>
|
||||
Kunde #{order.entityId || '–'}
|
||||
</Typography>
|
||||
<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' }}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 600, width: 75, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{gross > 0 ? formatPrice(gross) : '–'}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</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 === 'start' || entry.type === 'prefetch') chipColor = 'info';
|
||||
if (entry.type === 'pending' || entry.type === 'debounce_fire') chipColor = 'warning';
|
||||
if (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 { total, chunkCount, loadedChunks, inflightChunks, pendingChunks, loadedItemsCount, status } = stats;
|
||||
|
||||
const loadedCount = loadedChunks.length;
|
||||
const expectedChunks = Math.max(chunkCount, 1);
|
||||
const progressPercent = expectedChunks > 0 ? Math.min(100, Math.round((loadedCount / expectedChunks) * 100)) : 0;
|
||||
const hasInflight = inflightChunks.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!open ? (
|
||||
<Tooltip title="Chunk-Cache-Monitor (Alt+Shift+C)" placement="right">
|
||||
<Badge
|
||||
badgeContent={loadedCount}
|
||||
color="success"
|
||||
overlap="circular"
|
||||
max={99}
|
||||
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>
|
||||
<Chip size="small" color="primary" variant="outlined" label="orderAutomat" />
|
||||
<Chip
|
||||
size="small"
|
||||
color={hasInflight ? 'info' : 'default'}
|
||||
label={`${loadedCount} / ${expectedChunks} Chunks`}
|
||||
/>
|
||||
|
||||
<Tooltip title="Prefetch All">
|
||||
<IconButton size="small" aria-label="Alle Chunks vorladen" onClick={this.handlePrefetchAll}>
|
||||
<PlaylistAddCheckIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="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 }}>
|
||||
Cache-Füllstand: {progressPercent}% ({loadedCount} von {expectedChunks} Chunks)
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{loadedItemsCount} von {total} Aufträgen im RAM · Chunk-Größe {CHUNK_SIZE}
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progressPercent}
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
bgcolor: 'divider',
|
||||
'& .MuiLinearProgress-bar': { borderRadius: 3 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 1, flexWrap: 'wrap', gap: 0.5 }}>
|
||||
<Chip size="small" label={`Status: ${status}`} sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||
{hasInflight && (
|
||||
<Chip
|
||||
size="small"
|
||||
color="info"
|
||||
icon={<CircularProgress size={12} color="inherit" />}
|
||||
label={`${inflightChunks.length} lädt`}
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
{pendingChunks.length > 0 && (
|
||||
<Chip
|
||||
size="small"
|
||||
color="warning"
|
||||
label={`${pendingChunks.length} vorgemerkt`}
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
{stats.filter && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`Filter: "${stats.filter}"`}
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
{stats.onlyUnpaid && (
|
||||
<Chip
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
label="Nur unbezahlt"
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
{stats.onlyUndelivered && (
|
||||
<Chip
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
label="Nur ungeliefert"
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
{stats.negativeSeekMonths !== 0 && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`Seek: ${stats.negativeSeekMonths}M`}
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* 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={<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() : this.renderEventLog()}
|
||||
{tab === 'grid' && this.renderChunkDetail()}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ChunkCacheMonitor;
|
||||
Reference in New Issue
Block a user