This commit is contained in:
seb
2026-10-13 17:19:33 +02:00
parent cd530ed448
commit 345d6f5752
11 changed files with 4482 additions and 4 deletions

View File

@@ -259,6 +259,9 @@ npm run build
# Preview production build
npm run preview
# Regenerate Automat Graph visualization (automat-graph.svg & automat-graph.dot)
npm run automat-graph
```
> [!NOTE]
@@ -488,6 +491,13 @@ npm run cert # Generates local certs in ./certs/
npm run dev:https # Runs Vite HTTPS server on port 4308
```
### Automat Graph Visualization (`automat-graph.svg` & `automat-graph.dot`)
- Visual topology diagram generated via `npm run automat-graph` (`generate-automat-graph.js`).
- Modeled after `schema-graph.svg` in `dashdbschema` and `api-graph.svg` in `dashback`.
- Visualizes 32 Automats across 12 domain clusters (Session & Real-Time, Catalog, Search, Properties, Purchasing, Sales, Inventory, Finance, POS, AI Workspace, Virtual Grids, System UI).
- Color-coded badges for `STATE` (purple), `ACTION` (green), `SELECT` (blue), `API` (amber), `SSE` (rose), and `HOOK` (orange).
- Traces 60 relational and dataflow links with dedicated arrival ports (`in#`), exit compass directions (`e`/`w`), and arc-fixed self-loops.
---
## 16. Agent Cheatsheet & Code Recipes

View File

@@ -58,10 +58,13 @@ future test cases.
The Vite dev proxy (`/api` → Dashback) satisfies the same-origin cookie
requirement without needing HTTPS on the frontend dev server.
## Architecture
## Architecture & State Management
- `src/automats/*` — reactive state containers powered by `react-automat` (no React Context, hooks, or HOCs).
- `automat-graph.svg` / `automat-graph.dot` — interactive visual topology of all 32 Automats across 12 domain clusters and 60 dataflow relationships.
- `npm run automat-graph` — regenerates the Automat Graph SVG and DOT files using Graphviz Wasm.
- `src/api/index.js` — all backend communication (`credentials: 'include'`).
- `src/auth/googleSignIn.js` — Google Identity Services helper.
- `src/auth/AuthProvider.jsx` — session state + login/logout.
- `src/auth/useAuth.js` — auth context hook.
- `src/components/*` — small function components with local state.
- `src/components/*` — components reading synchronously from automats and subscribing via fine-grained selectors.

162
automat-graph.dot Normal file

File diff suppressed because one or more lines are too long

2193
automat-graph.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 255 KiB

1034
generate-automat-graph.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,8 @@
"preview:https": "VITE_HTTPS=1 vite preview",
"rig:collect": "node test/collect.mjs",
"rig:reference": "node test/capture.mjs --out=test/screenshots/reference",
"rig:compare": "node test/capture.mjs --out=test/screenshots/current && node test/compare.mjs --tolerance=0.001"
"rig:compare": "node test/capture.mjs --out=test/screenshots/current && node test/compare.mjs --tolerance=0.001",
"automat-graph": "node generate-automat-graph.js"
},
"dependencies": {
"@emotion/react": "^11.14.0",

View File

@@ -77,6 +77,8 @@ export {
isOrderDelivered,
getNegativeSeekCutoff,
formatNegativeSeek,
getChunkStats,
subscribeChunkStats,
} from './orderAutomat';
export { default as entityAutomat } from './entityAutomat';
export { default as customergroupAutomat } from './customergroupAutomat';

View File

@@ -74,13 +74,91 @@ let pendingChunkReject = null;
const loadedChunks = new Set();
const inflightChunks = new Map();
// Dev / Observability: Chunk event history and subscriber registry
const MAX_CHUNK_EVENTS = 120;
let nextEventId = 0;
const chunkEvents = [];
const chunkStatsListeners = new Set();
function recordChunkEvent(entry) {
const event = {
id: ++nextEventId,
time: Date.now(),
epoch: queryEpoch,
...entry,
};
chunkEvents.push(event);
if (chunkEvents.length > MAX_CHUNK_EVENTS) {
chunkEvents.shift();
}
notifyChunkStats();
}
function notifyChunkStats() {
if (chunkStatsListeners.size === 0) return;
const stats = getChunkStats();
for (const listener of chunkStatsListeners) {
try {
listener(stats);
} catch (e) {
console.error('[orderAutomat] chunkStatsListener error', e);
}
}
}
export function getChunkStats() {
const total = automat?.state?.total ?? 0;
const chunkCount = total > 0 ? Math.ceil(total / CHUNK_SIZE) : (loadedChunks.size > 0 ? Math.max(...loadedChunks) + 1 : 0);
const items = automat?.state?.items;
let loadedItemsCount = 0;
if (Array.isArray(items)) {
for (let i = 0; i < items.length; i++) {
if (items[i] != null) loadedItemsCount++;
}
}
return {
automatName: 'order',
chunkSize: CHUNK_SIZE,
total,
chunkCount,
loadedChunks: Array.from(loadedChunks).sort((a, b) => a - b),
inflightChunks: Array.from(inflightChunks.keys()).sort((a, b) => a - b),
pendingChunks: Array.from(pendingTargetChunks).sort((a, b) => a - b),
loadedItemsCount,
status: automat?.state?.status ?? 'idle',
queryEpoch,
filter: automat?.state?.filter ?? '',
onlyUnpaid: automat?.state?.onlyUnpaid ?? false,
onlyUndelivered: automat?.state?.onlyUndelivered ?? false,
negativeSeekMonths: automat?.state?.negativeSeekMonths ?? 0,
items,
events: [...chunkEvents],
};
}
export function subscribeChunkStats(listener) {
chunkStatsListeners.add(listener);
try {
listener(getChunkStats());
} catch (e) {
console.error(e);
}
return () => {
chunkStatsListeners.delete(listener);
};
}
// Clears pending debounced chunk requests
function clearChunkDebounce() {
if (chunkDebounceTimer) {
clearTimeout(chunkDebounceTimer);
chunkDebounceTimer = null;
}
pendingTargetChunks.clear();
if (pendingTargetChunks.size > 0) {
pendingTargetChunks.clear();
notifyChunkStats();
}
pendingChunkPromise = null;
pendingChunkResolve = null;
pendingChunkReject = null;
@@ -97,6 +175,12 @@ function _executeRequiredChunks(chunks, targetEpoch, prefetch = true) {
return Promise.resolve(automat.state.items);
}
recordChunkEvent({
type: 'execute',
chunks: chunkList,
message: `Lade Chunks [${chunkList.join(', ')}] (Epoch ${targetEpoch})`,
});
const promises = [];
for (const c of chunkList) {
promises.push(actions.loadChunk(c, targetEpoch));
@@ -112,6 +196,11 @@ function _executeRequiredChunks(chunks, targetEpoch, prefetch = true) {
if (total > 0 && c * CHUNK_SIZE >= total) {
break;
}
recordChunkEvent({
type: 'prefetch',
chunkIndex: c,
message: `Prefetch Chunk #${c}`,
});
void actions.loadChunk(c, targetEpoch);
}
}
@@ -214,6 +303,12 @@ function applyFilterReload() {
clearChunkDebounce();
loadedChunks.clear();
inflightChunks.clear();
recordChunkEvent({
type: 'filter_reload',
epoch: queryEpoch,
message: `Filter angewendet (Epoch ${queryEpoch})`,
});
notifyChunkStats();
void actions.loadChunk(0, queryEpoch);
}
@@ -261,6 +356,11 @@ const actions = {
clearChunkDebounce();
const chunks = new Set();
for (let c = start; c <= end; c++) chunks.add(c);
recordChunkEvent({
type: 'require_immediate',
chunks: Array.from(chunks),
message: `Sofortige Anforderung: Chunks ${start}..${end}`,
});
return _executeRequiredChunks(chunks, queryEpoch, prefetch);
}
@@ -282,6 +382,12 @@ const actions = {
for (let c = start; c <= end; c++) {
pendingTargetChunks.add(c);
}
recordChunkEvent({
type: 'pending',
chunks: Array.from(pendingTargetChunks),
message: `Debounce vorgemerkt (${CHUNK_DEBOUNCE_MS}ms): Chunks [${Array.from(pendingTargetChunks).join(', ')}]`,
});
notifyChunkStats();
if (chunkDebounceTimer) {
clearTimeout(chunkDebounceTimer);
@@ -300,6 +406,7 @@ const actions = {
chunkDebounceTimer = null;
const chunksToLoad = new Set(pendingTargetChunks);
pendingTargetChunks.clear();
notifyChunkStats();
const resolve = pendingChunkResolve;
const reject = pendingChunkReject;
@@ -307,6 +414,12 @@ const actions = {
pendingChunkResolve = null;
pendingChunkReject = null;
recordChunkEvent({
type: 'debounce_fire',
chunks: Array.from(chunksToLoad),
message: `Debounce abgelaufen: Starte Chunks [${Array.from(chunksToLoad).join(', ')}]`,
});
_executeRequiredChunks(chunksToLoad, captureEpoch, true)
.then((res) => resolve?.(res))
.catch((err) => reject?.(err));
@@ -322,9 +435,19 @@ const actions = {
return Promise.resolve(automat.state.items);
}
if (loadedChunks.has(index)) {
recordChunkEvent({
type: 'hit',
chunkIndex: index,
message: `Cache-Hit: Chunk #${index} bereits im Speicher`,
});
return Promise.resolve(automat.state.items);
}
if (inflightChunks.has(index)) {
recordChunkEvent({
type: 'inflight_hit',
chunkIndex: index,
message: `Inflight-Hit: Chunk #${index} lädt bereits`,
});
return inflightChunks.get(index);
}
@@ -334,6 +457,13 @@ const actions = {
automat.setState({ status: 'loading', error: null });
}
const startTime = Date.now();
recordChunkEvent({
type: 'start',
chunkIndex: index,
message: `Netzwerk-Request: Lade Chunk #${index} (Offset ${offset}..${offset + limit - 1})`,
});
const promise = fetchOrdersPage(offset, limit)
.then(({ orders, total }) => {
// Discard if active filter query changed while request was in flight
@@ -359,6 +489,7 @@ const actions = {
}
}
const duration = Date.now() - startTime;
loadedChunks.add(index);
automat.setState({
@@ -368,11 +499,27 @@ const actions = {
error: null,
});
recordChunkEvent({
type: 'loaded',
chunkIndex: index,
count: orders.length,
duration,
message: `Chunk #${index} geladen (${orders.length} Aufträge in ${duration}ms)`,
});
return items;
})
.catch((err) => {
if (targetEpoch === queryEpoch) {
const duration = Date.now() - startTime;
console.error(`[orderAutomat] loadChunk(${index}) error:`, err);
recordChunkEvent({
type: 'error',
chunkIndex: index,
duration,
error: err?.message || String(err),
message: `Fehler bei Chunk #${index}: ${err?.message || err}`,
});
if (index === 0 && automat.state.items === null) {
automat.setState({ status: 'failed', error: err });
}
@@ -381,9 +528,11 @@ const actions = {
})
.finally(() => {
inflightChunks.delete(index);
notifyChunkStats();
});
inflightChunks.set(index, promise);
notifyChunkStats();
return promise;
},
@@ -478,6 +627,11 @@ const actions = {
loadedChunks.clear();
inflightChunks.clear();
const count = Array.isArray(items) ? items.length : 0;
recordChunkEvent({
type: 'replace',
message: `Items ersetzt (${count} Einträge)`,
});
notifyChunkStats();
automat.setState({
items,
total: count,
@@ -487,6 +641,63 @@ const actions = {
return items;
},
// MARK: evicts a specific chunk from cache for testing
evictChunk(chunkIndex) {
const index = Math.max(0, Number(chunkIndex) || 0);
if (!loadedChunks.has(index)) return automat.state.items;
loadedChunks.delete(index);
let items = automat.state.items;
if (Array.isArray(items)) {
items = [...items];
const offset = index * CHUNK_SIZE;
for (let i = offset; i < offset + CHUNK_SIZE && i < items.length; i++) {
items[i] = null;
}
automat.setState({ items });
}
recordChunkEvent({
type: 'evict',
chunkIndex: index,
message: `Chunk #${index} aus Cache entfernt (evicted)`,
});
notifyChunkStats();
return automat.state.items;
},
// MARK: clears all loaded chunks from cache
clearCache() {
clearChunkDebounce();
loadedChunks.clear();
inflightChunks.clear();
let items = automat.state.items;
if (Array.isArray(items)) {
items = new Array(automat.state.total || 0).fill(null);
automat.setState({ items });
}
recordChunkEvent({
type: 'clear',
message: 'Alle Chunks aus dem Cache entfernt',
});
notifyChunkStats();
return automat.state.items;
},
// MARK: prefetches all remaining chunks
prefetchAllChunks() {
const total = automat.state.total || 0;
const chunkCount = Math.ceil(total / CHUNK_SIZE);
recordChunkEvent({
type: 'prefetch_all',
message: `Starte Prefetch aller ${chunkCount} Chunks`,
});
for (let c = 0; c < chunkCount; c++) {
if (!loadedChunks.has(c) && !inflightChunks.has(c)) {
void actions.loadChunk(c, queryEpoch);
}
}
notifyChunkStats();
},
// MARK: patches an order locally in the automat items cache
patchOrder(id, patch) {
if (!Array.isArray(automat.state.items)) return automat.state.items;
@@ -514,6 +725,11 @@ const actions = {
clearChunkDebounce();
loadedChunks.clear();
inflightChunks.clear();
recordChunkEvent({
type: 'reset',
message: 'Order-Automat zurückgesetzt',
});
notifyChunkStats();
automat.setState(initialState);
},
};
@@ -533,6 +749,11 @@ export function requireChunk(chunkIndex, endOrOptions, maybeOptions) {
orderAutomat.getItems = getItems;
orderAutomat.requireChunk = requireChunk;
orderAutomat.CHUNK_SIZE = CHUNK_SIZE;
orderAutomat.getChunkStats = getChunkStats;
orderAutomat.subscribeChunkStats = subscribeChunkStats;
orderAutomat.evictChunk = actions.evictChunk;
orderAutomat.clearCache = actions.clearCache;
orderAutomat.prefetchAllChunks = actions.prefetchAllChunks;
// Reset on logout or company change
let lastCompanyId = null;

View File

@@ -11,7 +11,9 @@ import Typography from '@mui/material/Typography';
import Checkbox from '@mui/material/Checkbox';
import Switch from '@mui/material/Switch';
import Skeleton from '@mui/material/Skeleton';
import Tooltip from '@mui/material/Tooltip';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
import { enqueueSnackbar } from 'notistack';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace';
@@ -423,6 +425,26 @@ class OrderSearchField extends PureComponent {
Reset
</Button>
</Box>
{import.meta.env.DEV && (
<Tooltip title="Chunk-Cache-Monitor öffnen (Alt+Shift+C)">
<Chip
size="small"
icon={<ViewModuleIcon sx={{ fontSize: 16 }} />}
label="Chunk-Cache"
onClick={() => window.dispatchEvent(new CustomEvent('dash:toggle-chunk-monitor'))}
sx={{
cursor: 'pointer',
height: 28,
fontWeight: 600,
fontSize: '0.75rem',
bgcolor: 'background.paper',
boxShadow: 1,
'&:hover': { bgcolor: 'action.hover' },
}}
/>
</Tooltip>
)}
</Box>
</Box>
</Box>

View 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;

View File

@@ -27,6 +27,9 @@ const AutomatInspector = import.meta.env.DEV
const SseMonitor = import.meta.env.DEV
? lazy(() => import('./devtools/SseMonitor'))
: null;
const ChunkCacheMonitor = import.meta.env.DEV
? lazy(() => import('./devtools/ChunkCacheMonitor'))
: null;
async function clearDevServiceWorker() {
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return;
@@ -71,6 +74,11 @@ createRoot(document.getElementById('root')).render(
<SseMonitor />
</Suspense>
) : null}
{ChunkCacheMonitor ? (
<Suspense fallback={null}>
<ChunkCacheMonitor />
</Suspense>
) : null}
<PwaUpdateDialog />
</AuthBootstrap>
</SnackbarProvider>