u
This commit is contained in:
10
AGENTS.md
10
AGENTS.md
@@ -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
|
||||
|
||||
@@ -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
162
automat-graph.dot
Normal file
File diff suppressed because one or more lines are too long
2193
automat-graph.svg
Normal file
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
1034
generate-automat-graph.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -77,6 +77,8 @@ export {
|
||||
isOrderDelivered,
|
||||
getNegativeSeekCutoff,
|
||||
formatNegativeSeek,
|
||||
getChunkStats,
|
||||
subscribeChunkStats,
|
||||
} from './orderAutomat';
|
||||
export { default as entityAutomat } from './entityAutomat';
|
||||
export { default as customergroupAutomat } from './customergroupAutomat';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
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;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user