u
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import ButtonBase from '@mui/material/ButtonBase';
|
import ButtonBase from '@mui/material/ButtonBase';
|
||||||
@@ -5,8 +6,205 @@ import Typography from '@mui/material/Typography';
|
|||||||
import { Tile, tileBaseSx } from 'dashgui/tiles';
|
import { Tile, tileBaseSx } from 'dashgui/tiles';
|
||||||
import { formatPrice } from '../../utils/price';
|
import { formatPrice } from '../../utils/price';
|
||||||
import { articleSku } from '../article/articleRoute';
|
import { articleSku } from '../article/articleRoute';
|
||||||
import ArticlePhoto from '../article/ArticlePhoto';
|
import { decodeThumbHash } from '../article/ThumbHashImage';
|
||||||
import { BrandLabel } from '../brand';
|
import { BrandLabel } from '../brand';
|
||||||
|
import { thumb } from '../../api';
|
||||||
|
|
||||||
|
const MIN_EDGE_STRETCH = 10;
|
||||||
|
|
||||||
|
function drawContainedWithStretchedEdges(
|
||||||
|
ctx,
|
||||||
|
source,
|
||||||
|
srcW,
|
||||||
|
srcH,
|
||||||
|
dstW,
|
||||||
|
dstH,
|
||||||
|
minStretch = MIN_EDGE_STRETCH,
|
||||||
|
) {
|
||||||
|
ctx.clearRect(0, 0, dstW, dstH);
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
|
||||||
|
const stretch = Math.min(minStretch, Math.max(0, Math.floor(Math.min(dstW, dstH) / 4)));
|
||||||
|
const innerW = dstW - stretch * 2;
|
||||||
|
const innerH = dstH - stretch * 2;
|
||||||
|
const scale = Math.min(innerW / srcW, innerH / srcH);
|
||||||
|
const fitW = srcW * scale;
|
||||||
|
const fitH = srcH * scale;
|
||||||
|
const offsetX = (dstW - fitW) / 2;
|
||||||
|
const offsetY = (dstH - fitH) / 2;
|
||||||
|
|
||||||
|
const leftW = Math.ceil(offsetX);
|
||||||
|
const rightX = Math.floor(offsetX + fitW);
|
||||||
|
const rightW = dstW - rightX;
|
||||||
|
const topH = Math.ceil(offsetY);
|
||||||
|
const botY = Math.floor(offsetY + fitH);
|
||||||
|
const botH = dstH - botY;
|
||||||
|
|
||||||
|
// Edge fill: a strongly blurred layer, crossfaded smoothly into a lightly
|
||||||
|
// blurred layer near the image (masked by a blurred rect, no visible seams).
|
||||||
|
// Rects overdraw past the canvas and under the center so blur never fades
|
||||||
|
// into transparency.
|
||||||
|
const maxBlur = Math.max(2, stretch * 0.6);
|
||||||
|
const drawEdges = (c, blur) => {
|
||||||
|
const pad = Math.ceil(blur * 3);
|
||||||
|
c.filter = `blur(${blur}px)`;
|
||||||
|
|
||||||
|
// 1. Four corners (1x1 source border pixels stretched)
|
||||||
|
c.drawImage(source, 0, 0, 1, 1, -pad, -pad, leftW + pad * 2, topH + pad * 2);
|
||||||
|
c.drawImage(source, srcW - 1, 0, 1, 1, rightX - pad, -pad, rightW + pad * 2, topH + pad * 2);
|
||||||
|
c.drawImage(source, 0, srcH - 1, 1, 1, -pad, botY - pad, leftW + pad * 2, botH + pad * 2);
|
||||||
|
c.drawImage(
|
||||||
|
source, srcW - 1, srcH - 1, 1, 1,
|
||||||
|
rightX - pad, botY - pad, rightW + pad * 2, botH + pad * 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Four edge bars (stretched 1px border rows/columns)
|
||||||
|
c.drawImage(source, 0, 0, srcW, 1, offsetX, -pad, fitW, topH + pad * 2);
|
||||||
|
c.drawImage(source, 0, srcH - 1, srcW, 1, offsetX, botY - pad, fitW, botH + pad * 2);
|
||||||
|
c.drawImage(source, 0, 0, 1, srcH, -pad, offsetY, leftW + pad * 2, fitH);
|
||||||
|
c.drawImage(source, srcW - 1, 0, 1, srcH, rightX - pad, offsetY, rightW + pad * 2, fitH);
|
||||||
|
|
||||||
|
c.filter = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
drawEdges(ctx, maxBlur);
|
||||||
|
|
||||||
|
const light = document.createElement('canvas');
|
||||||
|
light.width = dstW;
|
||||||
|
light.height = dstH;
|
||||||
|
const lctx = light.getContext('2d');
|
||||||
|
if (lctx) {
|
||||||
|
drawEdges(lctx, Math.max(0.5, maxBlur * 0.15));
|
||||||
|
// Mask: blurred rect around the image -> opaque near it, fading outward.
|
||||||
|
const grow = maxBlur * 0.5;
|
||||||
|
lctx.globalCompositeOperation = 'destination-in';
|
||||||
|
lctx.filter = `blur(${maxBlur}px)`;
|
||||||
|
lctx.fillStyle = '#000';
|
||||||
|
lctx.fillRect(
|
||||||
|
offsetX - grow,
|
||||||
|
offsetY - grow,
|
||||||
|
fitW + grow * 2,
|
||||||
|
fitH + grow * 2,
|
||||||
|
);
|
||||||
|
lctx.filter = 'none';
|
||||||
|
ctx.drawImage(light, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Center image (aspect ratio preserved / contained)
|
||||||
|
ctx.drawImage(source, 0, 0, srcW, srcH, offsetX, offsetY, fitW, fitH);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ArticleTileBackdrop extends PureComponent {
|
||||||
|
canvasRef = null;
|
||||||
|
img = null;
|
||||||
|
observer = null;
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.loadImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (prevProps.fullHash !== this.props.fullHash) {
|
||||||
|
this.loadImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.observer?.disconnect();
|
||||||
|
this.img = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadImage() {
|
||||||
|
const { fullHash } = this.props;
|
||||||
|
if (fullHash) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
if (this.img === img) {
|
||||||
|
this.paint();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.src = thumb.url(fullHash);
|
||||||
|
this.img = img;
|
||||||
|
if (img.complete && img.naturalWidth) {
|
||||||
|
this.paint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.paint();
|
||||||
|
}
|
||||||
|
|
||||||
|
setCanvas = (canvas) => {
|
||||||
|
this.canvasRef = canvas;
|
||||||
|
if (canvas && typeof ResizeObserver !== 'undefined') {
|
||||||
|
this.observer?.disconnect();
|
||||||
|
this.observer = new ResizeObserver(() => this.paint());
|
||||||
|
this.observer.observe(canvas);
|
||||||
|
}
|
||||||
|
this.paint();
|
||||||
|
};
|
||||||
|
|
||||||
|
paint = () => {
|
||||||
|
const canvas = this.canvasRef;
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const dstW = Math.round(rect.width);
|
||||||
|
const dstH = Math.round(rect.height);
|
||||||
|
if (!dstW || !dstH) return;
|
||||||
|
|
||||||
|
canvas.width = dstW;
|
||||||
|
canvas.height = dstH;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const img = this.img;
|
||||||
|
if (img && img.complete && img.naturalWidth && img.naturalHeight) {
|
||||||
|
drawContainedWithStretchedEdges(
|
||||||
|
ctx,
|
||||||
|
img,
|
||||||
|
img.naturalWidth,
|
||||||
|
img.naturalHeight,
|
||||||
|
dstW,
|
||||||
|
dstH,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { thumbHash } = this.props;
|
||||||
|
if (thumbHash) {
|
||||||
|
const decoded = decodeThumbHash(thumbHash);
|
||||||
|
if (decoded) {
|
||||||
|
const { w, h, rgba } = decoded;
|
||||||
|
const temp = document.createElement('canvas');
|
||||||
|
temp.width = w;
|
||||||
|
temp.height = h;
|
||||||
|
temp.getContext('2d').putImageData(
|
||||||
|
new ImageData(new Uint8ClampedArray(rgba), w, h),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
drawContainedWithStretchedEdges(ctx, temp, w, h, dstW, dstH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="canvas"
|
||||||
|
ref={this.setCanvas}
|
||||||
|
aria-hidden
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ArticleTile extends Tile {
|
export class ArticleTile extends Tile {
|
||||||
hasPhoto() {
|
hasPhoto() {
|
||||||
@@ -81,10 +279,7 @@ export class ArticleTile extends Tile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (!this.hasPhoto()) {
|
const hasPhoto = this.hasPhoto();
|
||||||
return super.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { article, label, sx, disabled } = this.props;
|
const { article, label, sx, disabled } = this.props;
|
||||||
const sku = articleSku(article);
|
const sku = articleSku(article);
|
||||||
|
|
||||||
@@ -104,15 +299,25 @@ export class ArticleTile extends Tile {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ position: 'absolute', inset: 0 }}>
|
<Box sx={{ position: 'absolute', inset: 0 }}>
|
||||||
<ArticlePhoto
|
{hasPhoto ? (
|
||||||
fill
|
<ArticleTileBackdrop
|
||||||
photo={{
|
fullHash={article.fullHash}
|
||||||
fullHash: article.fullHash,
|
thumbHash={article.thumbHash}
|
||||||
thumbW: article.thumbW,
|
/>
|
||||||
thumbH: article.thumbH,
|
) : (
|
||||||
thumbHash: article.thumbHash,
|
<Box
|
||||||
}}
|
sx={{
|
||||||
/>
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
pb: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.renderIcon()}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{this.renderExtra()}
|
{this.renderExtra()}
|
||||||
<Box
|
<Box
|
||||||
@@ -125,6 +330,8 @@ export class ArticleTile extends Tile {
|
|||||||
py: 0.75,
|
py: 0.75,
|
||||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.65))',
|
background: 'linear-gradient(transparent, rgba(0,0,0,0.65))',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
|
color: '#fff',
|
||||||
|
textShadow: '0 1px 3px rgba(0, 0, 0, 0.95), 0 0 2px rgba(0, 0, 0, 0.85)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{article?.brand?.name ? (
|
{article?.brand?.name ? (
|
||||||
@@ -136,7 +343,13 @@ export class ArticleTile extends Tile {
|
|||||||
typographyProps={{
|
typographyProps={{
|
||||||
variant: 'caption',
|
variant: 'caption',
|
||||||
noWrap: true,
|
noWrap: true,
|
||||||
sx: { color: 'rgba(255,255,255,0.9)', fontWeight: 500, lineHeight: 1.1 },
|
sx: {
|
||||||
|
color: '#b3e5fc',
|
||||||
|
opacity: 1,
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
textShadow: '0 1px 3px rgba(0, 0, 0, 0.95), 0 0 2px rgba(0, 0, 0, 0.85)',
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
sx={{ mb: 0.25 }}
|
sx={{ mb: 0.25 }}
|
||||||
/>
|
/>
|
||||||
@@ -146,16 +359,40 @@ export class ArticleTile extends Tile {
|
|||||||
variant="body2"
|
variant="body2"
|
||||||
fontWeight={600}
|
fontWeight={600}
|
||||||
noWrap
|
noWrap
|
||||||
sx={{ color: '#fff', lineHeight: 1.2 }}
|
sx={{
|
||||||
|
color: '#111',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
textShadow: '0 0 3px rgba(255, 255, 255, 1), 0 0 6px rgba(255, 255, 255, 0.9), 0 1px 2px rgba(255, 255, 255, 0.9)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
{sku ? (
|
{sku ? (
|
||||||
<Typography variant="caption" noWrap sx={{ color: 'rgba(255,255,255,0.85)' }}>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
noWrap
|
||||||
|
sx={{
|
||||||
|
color: '#ffffff',
|
||||||
|
textShadow: '0 1px 3px rgba(0, 0, 0, 0.95), 0 0 2px rgba(0, 0, 0, 0.85)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{sku}
|
{sku}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
|
{article.net != null && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
fontWeight={600}
|
||||||
|
component="div"
|
||||||
|
sx={{
|
||||||
|
color: '#b9f6ca',
|
||||||
|
textShadow: '0 1px 3px rgba(0, 0, 0, 0.95), 0 0 2px rgba(0, 0, 0, 0.85)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatPrice(article.net)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</ButtonBase>
|
</ButtonBase>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { PureComponent, createRef } from 'react';
|
import { PureComponent, createRef } from 'react';
|
||||||
import Badge from '@mui/material/Badge';
|
import Badge from '@mui/material/Badge';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import ButtonBase from '@mui/material/ButtonBase';
|
|
||||||
import Chip from '@mui/material/Chip';
|
import Chip from '@mui/material/Chip';
|
||||||
import Fab from '@mui/material/Fab';
|
import Fab from '@mui/material/Fab';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
@@ -10,16 +9,27 @@ import TextField from '@mui/material/TextField';
|
|||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import BugReportIcon from '@mui/icons-material/BugReport';
|
import BugReportIcon from '@mui/icons-material/BugReport';
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
|
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
|
||||||
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { notifyError } from '../utils/notify';
|
import { notifyError } from '../utils/notify';
|
||||||
|
import AutomatEntry from './inspector/AutomatEntry';
|
||||||
|
import ActionLogPanel from './inspector/ActionLogPanel';
|
||||||
|
import {
|
||||||
|
actionLog,
|
||||||
|
callListeners,
|
||||||
|
formatTime,
|
||||||
|
getRegistry,
|
||||||
|
installActionLogging,
|
||||||
|
logListeners,
|
||||||
|
notifyLog,
|
||||||
|
watchRegistry,
|
||||||
|
} from './inspector/actionLog';
|
||||||
|
import { formatState } from './inspector/serialize';
|
||||||
|
|
||||||
|
export { formatState };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dev-only overlay that lists every Automat registered in the
|
* Dev-only overlay that lists every Automat registered in the
|
||||||
@@ -32,295 +42,6 @@ import { notifyError } from '../utils/notify';
|
|||||||
/** Registry polling interval in ms; lazy automats (`delivery-<id>`) appear over time. */
|
/** Registry polling interval in ms; lazy automats (`delivery-<id>`) appear over time. */
|
||||||
const REGISTRY_POLL_MS = 2000;
|
const REGISTRY_POLL_MS = 2000;
|
||||||
|
|
||||||
/** Serialization limits so huge states stay renderable. */
|
|
||||||
const MAX_DEPTH = 6;
|
|
||||||
const MAX_STRING = 1500;
|
|
||||||
const MAX_ITEMS = 50;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Live registry view. Named automats register themselves in the constructor,
|
|
||||||
* so the map may be absent until the first one is created.
|
|
||||||
*
|
|
||||||
* @returns {Map<string, import('react-automat').Automat>} Registered automats.
|
|
||||||
*/
|
|
||||||
function getRegistry() {
|
|
||||||
const registry = window.__AUTOMATS__;
|
|
||||||
return registry instanceof Map ? registry : new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Quotes a string and truncates oversized values.
|
|
||||||
*
|
|
||||||
* @param {string} text Value to quote.
|
|
||||||
* @returns {string} Quoted, possibly truncated string.
|
|
||||||
*/
|
|
||||||
function quote(text) {
|
|
||||||
if (text.length <= MAX_STRING) return JSON.stringify(text);
|
|
||||||
const rest = text.length - MAX_STRING;
|
|
||||||
return `${JSON.stringify(text.slice(0, MAX_STRING))} … (+${rest} Zeichen)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders `entries` as an indented block.
|
|
||||||
*
|
|
||||||
* @param {string} open Opening bracket.
|
|
||||||
* @param {string} close Closing bracket.
|
|
||||||
* @param {Array<string>} entries Already serialized entries.
|
|
||||||
* @param {string} pad Indentation of the enclosing level.
|
|
||||||
* @returns {string} Indented block.
|
|
||||||
*/
|
|
||||||
function block(open, close, entries, pad) {
|
|
||||||
if (entries.length === 0) return `${open}${close}`;
|
|
||||||
const body = entries.map((entry) => `${pad} ${entry}`).join(',\n');
|
|
||||||
return `${open}\n${body}\n${pad}${close}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serializes an arbitrary state value for display. Handles functions, blobs,
|
|
||||||
* DOM nodes, maps, sets and circular references, which plain `JSON.stringify`
|
|
||||||
* would drop or throw on.
|
|
||||||
*
|
|
||||||
* @param {*} value Value to serialize.
|
|
||||||
* @param {number} depth Current nesting depth.
|
|
||||||
* @param {WeakSet<object>} ancestors Objects on the current path, for cycle detection.
|
|
||||||
* @param {string} pad Indentation of the current level.
|
|
||||||
* @returns {string} Human-readable representation.
|
|
||||||
*/
|
|
||||||
function serialize(value, depth, ancestors, pad) {
|
|
||||||
if (value === null) return 'null';
|
|
||||||
if (value === undefined) return 'undefined';
|
|
||||||
|
|
||||||
const type = typeof value;
|
|
||||||
if (type === 'string') return quote(value);
|
|
||||||
if (type === 'number' || type === 'boolean') return String(value);
|
|
||||||
if (type === 'bigint') return `${value}n`;
|
|
||||||
if (type === 'function') return `[Function ${value.name || 'anonym'}]`;
|
|
||||||
if (type === 'symbol') return `[Symbol ${value.description ?? ''}]`;
|
|
||||||
if (type !== 'object') return String(value);
|
|
||||||
|
|
||||||
if (value instanceof Date) return `[Date ${value.toISOString()}]`;
|
|
||||||
if (value instanceof Blob) return `[Blob ${value.size}B ${value.type || 'ohne Typ'}]`;
|
|
||||||
if (value instanceof ArrayBuffer) return `[ArrayBuffer ${value.byteLength}B]`;
|
|
||||||
if (value instanceof Promise) return '[Promise]';
|
|
||||||
if (typeof Node !== 'undefined' && value instanceof Node) return `[Node ${value.nodeName}]`;
|
|
||||||
|
|
||||||
if (ancestors.has(value)) return '[Circular]';
|
|
||||||
if (depth >= MAX_DEPTH) return Array.isArray(value) ? '[Array …]' : '[Object …]';
|
|
||||||
|
|
||||||
ancestors.add(value);
|
|
||||||
try {
|
|
||||||
if (value instanceof Map) {
|
|
||||||
const shown = Array.from(value.entries()).slice(0, MAX_ITEMS);
|
|
||||||
const entries = shown.map(
|
|
||||||
([k, v]) =>
|
|
||||||
`${serialize(k, depth + 1, ancestors, `${pad} `)}: ${serialize(v, depth + 1, ancestors, `${pad} `)}`,
|
|
||||||
);
|
|
||||||
if (value.size > shown.length) entries.push(`${JSON.stringify('…')}: +${value.size - shown.length} weitere`);
|
|
||||||
return block(`Map(${value.size}) {`, '}', entries, pad);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value instanceof Set) {
|
|
||||||
const shown = Array.from(value.values()).slice(0, MAX_ITEMS);
|
|
||||||
const entries = shown.map((item) => serialize(item, depth + 1, ancestors, `${pad} `));
|
|
||||||
if (value.size > shown.length) entries.push(`${JSON.stringify('…')}: +${value.size - shown.length} weitere`);
|
|
||||||
return block(`Set(${value.size}) {`, '}', entries, pad);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
const shown = value.slice(0, MAX_ITEMS);
|
|
||||||
const entries = shown.map((item) => serialize(item, depth + 1, ancestors, `${pad} `));
|
|
||||||
if (value.length > shown.length) entries.push(`${JSON.stringify('…')}: +${value.length - shown.length} weitere`);
|
|
||||||
return block('[', ']', entries, pad);
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = Object.keys(value);
|
|
||||||
const shown = keys.slice(0, MAX_ITEMS);
|
|
||||||
const entries = shown.map(
|
|
||||||
(key) => `${JSON.stringify(key)}: ${serialize(value[key], depth + 1, ancestors, `${pad} `)}`,
|
|
||||||
);
|
|
||||||
if (keys.length > shown.length) entries.push(`${JSON.stringify('…')}: +${keys.length - shown.length} weitere Schlüssel`);
|
|
||||||
|
|
||||||
const label = value.constructor && value.constructor !== Object ? `${value.constructor.name} ` : '';
|
|
||||||
return block(`${label}{`, '}', entries, pad);
|
|
||||||
} finally {
|
|
||||||
ancestors.delete(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats an automat state for the overlay. Serialization failures are surfaced
|
|
||||||
* instead of being swallowed.
|
|
||||||
*
|
|
||||||
* @param {*} value Automat state.
|
|
||||||
* @returns {string} Display text.
|
|
||||||
*/
|
|
||||||
export function formatState(value) {
|
|
||||||
try {
|
|
||||||
return serialize(value, 0, new WeakSet(), '');
|
|
||||||
} catch (err) {
|
|
||||||
return `[Serialisierung fehlgeschlagen: ${err?.message ?? err}]`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Maximum number of retained action log entries (ring buffer). */
|
|
||||||
const MAX_LOG = 300;
|
|
||||||
|
|
||||||
/** Marks action functions that already carry the logging proxy. */
|
|
||||||
const LOGGED = Symbol('automatInspectorLogged');
|
|
||||||
|
|
||||||
/** Compact single-line rendering limit for args/results in the log. */
|
|
||||||
const LOG_VALUE_LIMIT = 240;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ring buffer of captured action calls, oldest first. Kept at module scope so
|
|
||||||
* entries survive component re-renders (and are shared by every inspector).
|
|
||||||
*
|
|
||||||
* @type {Array<{id: number, time: Date, automat: string, action: string, args: string, status: string, duration: number|null, result: string, error: string|null}>}
|
|
||||||
*/
|
|
||||||
const actionLog = [];
|
|
||||||
|
|
||||||
/** Monotonic id source for log entries. */
|
|
||||||
let logSeq = 0;
|
|
||||||
|
|
||||||
/** @type {Set<() => void>} Listeners notified when the log buffer changes. */
|
|
||||||
const logListeners = new Set();
|
|
||||||
|
|
||||||
/** Notifies every log listener, swallowing individual errors. */
|
|
||||||
function notifyLog() {
|
|
||||||
for (const listener of logListeners) {
|
|
||||||
try {
|
|
||||||
listener();
|
|
||||||
} catch {
|
|
||||||
/* listeners are dev-only; never let one break logging */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends an entry to the ring buffer and notifies listeners.
|
|
||||||
*
|
|
||||||
* @param {object} entry Log entry.
|
|
||||||
*/
|
|
||||||
function pushLog(entry) {
|
|
||||||
actionLog.push(entry);
|
|
||||||
if (actionLog.length > MAX_LOG) actionLog.splice(0, actionLog.length - MAX_LOG);
|
|
||||||
notifyLog();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders a value as a compact single-line string for the log.
|
|
||||||
*
|
|
||||||
* @param {*} value Value to summarize.
|
|
||||||
* @param {number} [limit] Maximum length before truncation.
|
|
||||||
* @returns {string} Compact representation.
|
|
||||||
*/
|
|
||||||
function compact(value, limit = LOG_VALUE_LIMIT) {
|
|
||||||
const text = formatState(value).replace(/\s+/g, ' ').trim();
|
|
||||||
return text.length > limit ? `${text.slice(0, limit)} …` : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a timestamp as `HH:MM:SS.mmm`.
|
|
||||||
*
|
|
||||||
* @param {Date} date Timestamp.
|
|
||||||
* @returns {string} Formatted time.
|
|
||||||
*/
|
|
||||||
function formatTime(date) {
|
|
||||||
const pad = (n, len = 2) => String(n).padStart(len, '0');
|
|
||||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps every action of an automat with a logging proxy that records call
|
|
||||||
* arguments, duration, return value and errors. Idempotent: already wrapped
|
|
||||||
* actions are left untouched, and the original function is always invoked with
|
|
||||||
* the caller's `this` and arguments.
|
|
||||||
*
|
|
||||||
* @param {import('react-automat').Automat} automat Automat instance.
|
|
||||||
*/
|
|
||||||
function installActionLogging(automat) {
|
|
||||||
const actions = automat.actions;
|
|
||||||
if (!actions) return;
|
|
||||||
for (const key of Object.keys(actions)) {
|
|
||||||
const original = actions[key];
|
|
||||||
if (typeof original !== 'function' || original[LOGGED]) continue;
|
|
||||||
|
|
||||||
const wrapped = function loggedAction(...args) {
|
|
||||||
const started = performance.now();
|
|
||||||
const entry = {
|
|
||||||
id: ++logSeq,
|
|
||||||
time: new Date(),
|
|
||||||
automat: automat.name ?? '(anonym)',
|
|
||||||
action: key,
|
|
||||||
args: compact(args),
|
|
||||||
status: 'running',
|
|
||||||
duration: null,
|
|
||||||
result: '',
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
pushLog(entry);
|
|
||||||
|
|
||||||
const settle = (status, payload, err) => {
|
|
||||||
entry.status = status;
|
|
||||||
entry.duration = performance.now() - started;
|
|
||||||
if (status === 'error') entry.error = err?.message ?? String(err);
|
|
||||||
else entry.result = compact(payload);
|
|
||||||
notifyLog();
|
|
||||||
};
|
|
||||||
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = original.apply(this, args);
|
|
||||||
} catch (err) {
|
|
||||||
settle('error', undefined, err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result && typeof result.then === 'function') {
|
|
||||||
return result.then(
|
|
||||||
(value) => {
|
|
||||||
settle('ok', value);
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
settle('error', undefined, err);
|
|
||||||
throw err;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
settle('ok', result);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
Object.defineProperty(wrapped, 'name', { value: key });
|
|
||||||
} catch {
|
|
||||||
/* name is cosmetic */
|
|
||||||
}
|
|
||||||
wrapped[LOGGED] = true;
|
|
||||||
actions[key] = wrapped;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Marks the registry Map once its `set` has been wrapped. */
|
|
||||||
const WATCHED = Symbol('automatInspectorWatched');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps the live registry's `set` so automats created after mount get their
|
|
||||||
* actions instrumented immediately instead of on the next poll tick.
|
|
||||||
*/
|
|
||||||
function watchRegistry() {
|
|
||||||
const registry = window.__AUTOMATS__;
|
|
||||||
if (!(registry instanceof Map) || registry[WATCHED]) return;
|
|
||||||
const originalSet = registry.set.bind(registry);
|
|
||||||
registry.set = (name, automat) => {
|
|
||||||
const result = originalSet(name, automat);
|
|
||||||
installActionLogging(automat);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
registry[WATCHED] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Floating dev button plus a docked side panel listing all registered Automats
|
* Floating dev button plus a docked side panel listing all registered Automats
|
||||||
@@ -343,7 +64,10 @@ class AutomatInspector extends PureComponent {
|
|||||||
revision: 0,
|
revision: 0,
|
||||||
logOpen: false,
|
logOpen: false,
|
||||||
log: [],
|
log: [],
|
||||||
|
flash: {},
|
||||||
};
|
};
|
||||||
|
this.handleActionCall = this.handleActionCall.bind(this);
|
||||||
|
this.handleRunAction = this.handleRunAction.bind(this);
|
||||||
|
|
||||||
/** @type {Map<string, () => void>} Unsubscribe functions per automat name. */
|
/** @type {Map<string, () => void>} Unsubscribe functions per automat name. */
|
||||||
this.unsubs = new Map();
|
this.unsubs = new Map();
|
||||||
@@ -372,13 +96,42 @@ class AutomatInspector extends PureComponent {
|
|||||||
this.registryTimer = window.setInterval(this.syncRegistry, REGISTRY_POLL_MS);
|
this.registryTimer = window.setInterval(this.syncRegistry, REGISTRY_POLL_MS);
|
||||||
window.addEventListener('keydown', this.handleKeyDown);
|
window.addEventListener('keydown', this.handleKeyDown);
|
||||||
logListeners.add(this.scheduleLogUpdate);
|
logListeners.add(this.scheduleLogUpdate);
|
||||||
|
callListeners.add(this.handleActionCall);
|
||||||
this.scheduleLogUpdate();
|
this.scheduleLogUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bumps the flash counter of an action so its chip replays the flash animation.
|
||||||
|
*
|
||||||
|
* @param {string} automat Automat name.
|
||||||
|
* @param {string} action Action name.
|
||||||
|
*/
|
||||||
|
handleActionCall(automat, action) {
|
||||||
|
if (!this.state.open) return;
|
||||||
|
const id = `${automat}.${action}`;
|
||||||
|
this.setState((prev) => ({ flash: { ...prev.flash, [id]: (prev.flash[id] ?? 0) + 1 } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers a parameterless action manually.
|
||||||
|
*
|
||||||
|
* @param {string} name Automat name.
|
||||||
|
* @param {string} action Action name.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async handleRunAction(name, action) {
|
||||||
|
try {
|
||||||
|
await getRegistry().get(name)?.actions?.[action]?.();
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
window.clearInterval(this.registryTimer);
|
window.clearInterval(this.registryTimer);
|
||||||
window.removeEventListener('keydown', this.handleKeyDown);
|
window.removeEventListener('keydown', this.handleKeyDown);
|
||||||
logListeners.delete(this.scheduleLogUpdate);
|
logListeners.delete(this.scheduleLogUpdate);
|
||||||
|
callListeners.delete(this.handleActionCall);
|
||||||
this.unsubscribeAll();
|
this.unsubscribeAll();
|
||||||
if (this.frame) cancelAnimationFrame(this.frame);
|
if (this.frame) cancelAnimationFrame(this.frame);
|
||||||
if (this.logFrame) cancelAnimationFrame(this.logFrame);
|
if (this.logFrame) cancelAnimationFrame(this.logFrame);
|
||||||
@@ -561,221 +314,10 @@ class AutomatInspector extends PureComponent {
|
|||||||
* @param {boolean} isExpanded Whether the details are visible.
|
* @param {boolean} isExpanded Whether the details are visible.
|
||||||
* @returns {React.ReactElement} Entry element.
|
* @returns {React.ReactElement} Entry element.
|
||||||
*/
|
*/
|
||||||
renderEntry(automat, isExpanded) {
|
|
||||||
const name = automat.name;
|
|
||||||
const actionNames = Object.keys(automat.actions ?? {});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={name}
|
|
||||||
sx={{
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
borderRadius: 1,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ButtonBase
|
|
||||||
component="div"
|
|
||||||
onClick={() => this.handleToggleEntry(name)}
|
|
||||||
sx={{
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
gap: 1,
|
|
||||||
px: 1,
|
|
||||||
py: 0.75,
|
|
||||||
textAlign: 'left',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isExpanded ? <ExpandMoreIcon fontSize="small" /> : <ChevronRightIcon fontSize="small" />}
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
noWrap
|
|
||||||
sx={{ fontFamily: 'monospace', fontWeight: 600, flexGrow: 1, minWidth: 0 }}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</Typography>
|
|
||||||
<Chip size="small" variant="outlined" label={`subs ${automat.subscriberCount}`} />
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
color={automat.isReady ? 'success' : 'default'}
|
|
||||||
variant={automat.isReady ? 'filled' : 'outlined'}
|
|
||||||
label={automat.isReady ? 'ready' : 'unready'}
|
|
||||||
/>
|
|
||||||
{automat.isDirty ? <Chip size="small" color="warning" label="dirty" /> : null}
|
|
||||||
</ButtonBase>
|
|
||||||
|
|
||||||
{isExpanded ? (
|
|
||||||
<Box sx={{ borderTop: '1px solid', borderColor: 'divider', px: 1.25, py: 1 }}>
|
|
||||||
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap" sx={{ mb: 1 }}>
|
|
||||||
{automat.url ? <Chip size="small" variant="outlined" label={`url: ${automat.url}`} /> : null}
|
|
||||||
{automat.persist ? <Chip size="small" variant="outlined" label="persist" /> : null}
|
|
||||||
{automat.error ? (
|
|
||||||
<Chip size="small" color="error" label={`error: ${automat.error?.message ?? automat.error}`} />
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mb: 0.5 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ flexGrow: 1 }}>
|
|
||||||
Aktionen ({actionNames.length})
|
|
||||||
</Typography>
|
|
||||||
<Tooltip title="State als Text kopieren">
|
|
||||||
<IconButton size="small" onClick={() => this.handleCopy(name)}>
|
|
||||||
<ContentCopyIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap" sx={{ mb: 1 }}>
|
|
||||||
{actionNames.length === 0 ? (
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
keine
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
actionNames.map((action) => <Chip key={action} size="small" label={action} />)
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
State
|
|
||||||
</Typography>
|
|
||||||
<Box
|
|
||||||
component="pre"
|
|
||||||
sx={{
|
|
||||||
m: 0,
|
|
||||||
mt: 0.5,
|
|
||||||
p: 1,
|
|
||||||
borderRadius: 1,
|
|
||||||
bgcolor: 'action.hover',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 11,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
maxHeight: 320,
|
|
||||||
overflow: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatState(automat.state)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders one captured action call as a single log line.
|
|
||||||
*
|
|
||||||
* @param {object} entry Log entry.
|
|
||||||
* @returns {React.ReactElement} Log line.
|
|
||||||
*/
|
|
||||||
renderLogLine(entry) {
|
|
||||||
const color =
|
|
||||||
entry.status === 'error' ? 'error.main' : entry.status === 'running' ? 'text.secondary' : 'success.main';
|
|
||||||
return (
|
|
||||||
<Box key={entry.id} component="div">
|
|
||||||
<Box component="span" sx={{ color: 'text.disabled' }}>
|
|
||||||
{formatTime(entry.time)}{' '}
|
|
||||||
</Box>
|
|
||||||
<Box component="span" sx={{ fontWeight: 600 }}>
|
|
||||||
{entry.automat}.{entry.action}
|
|
||||||
</Box>
|
|
||||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
|
||||||
({entry.args})
|
|
||||||
</Box>
|
|
||||||
{entry.status === 'error' ? (
|
|
||||||
<Box component="span" sx={{ color: 'error.main' }}>
|
|
||||||
{' '}
|
|
||||||
✗ {entry.error}
|
|
||||||
</Box>
|
|
||||||
) : entry.status === 'ok' ? (
|
|
||||||
<Box component="span" sx={{ color: 'text.disabled' }}>
|
|
||||||
{' '}
|
|
||||||
→ {entry.result}
|
|
||||||
</Box>
|
|
||||||
) : null}{' '}
|
|
||||||
<Box component="span" sx={{ color, ml: 0.5 }}>
|
|
||||||
{entry.status === 'running'
|
|
||||||
? '…'
|
|
||||||
: `${entry.status === 'error' ? '✗' : '✓'} ${Math.round(entry.duration)}ms`}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bottom-docked, collapsible log of every captured automat action call. */
|
|
||||||
renderLog() {
|
|
||||||
const { logOpen, log } = this.state;
|
|
||||||
const errorCount = log.filter((entry) => entry.status === 'error').length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ borderTop: '1px solid', borderColor: 'divider', flexShrink: 0 }}>
|
|
||||||
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: 1.5, py: 0.5 }}>
|
|
||||||
<ButtonBase
|
|
||||||
component="div"
|
|
||||||
onClick={this.handleToggleLog}
|
|
||||||
sx={{ flexGrow: 1, display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-start', py: 0.5 }}
|
|
||||||
>
|
|
||||||
{logOpen ? <ExpandMoreIcon fontSize="small" /> : <ChevronRightIcon fontSize="small" />}
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
|
||||||
Action-Log
|
|
||||||
</Typography>
|
|
||||||
<Chip size="small" variant="outlined" label={log.length} />
|
|
||||||
{errorCount > 0 ? <Chip size="small" color="error" label={`${errorCount} Fehler`} /> : null}
|
|
||||||
</ButtonBase>
|
|
||||||
<Tooltip title="Log als Text kopieren">
|
|
||||||
<span>
|
|
||||||
<IconButton size="small" onClick={this.handleCopyLog} disabled={log.length === 0}>
|
|
||||||
<ContentCopyIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="Log leeren">
|
|
||||||
<span>
|
|
||||||
<IconButton size="small" onClick={this.handleClearLog} disabled={log.length === 0}>
|
|
||||||
<ClearIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{logOpen ? (
|
|
||||||
<Box
|
|
||||||
ref={this.logRef}
|
|
||||||
component="div"
|
|
||||||
sx={{
|
|
||||||
m: 0,
|
|
||||||
mx: 1.5,
|
|
||||||
mb: 1,
|
|
||||||
p: 1,
|
|
||||||
borderRadius: 1,
|
|
||||||
bgcolor: 'action.hover',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 11,
|
|
||||||
lineHeight: 1.6,
|
|
||||||
maxHeight: '40vh',
|
|
||||||
overflow: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{log.length === 0 ? (
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Noch keine Action-Aufrufe protokolliert.
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
log.map((entry) => this.renderLogLine(entry))
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Docked side panel with the automat list, filter and action log. */
|
/** Docked side panel with the automat list, filter and action log. */
|
||||||
renderPanel() {
|
renderPanel() {
|
||||||
const { names, expanded, query } = this.state;
|
const { names, expanded, query, flash, logOpen, log } = this.state;
|
||||||
const registry = getRegistry();
|
const registry = getRegistry();
|
||||||
const needle = query.trim().toLowerCase();
|
const needle = query.trim().toLowerCase();
|
||||||
const entries = names
|
const entries = names
|
||||||
@@ -848,12 +390,29 @@ class AutomatInspector extends PureComponent {
|
|||||||
</Typography>
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
{entries.map((automat) => this.renderEntry(automat, Boolean(expanded[automat.name])))}
|
{entries.map((automat) => (
|
||||||
|
<AutomatEntry
|
||||||
|
key={automat.name}
|
||||||
|
automat={automat}
|
||||||
|
isExpanded={Boolean(expanded[automat.name])}
|
||||||
|
flash={flash}
|
||||||
|
onToggle={this.handleToggleEntry}
|
||||||
|
onCopy={this.handleCopy}
|
||||||
|
onRunAction={this.handleRunAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{this.renderLog()}
|
<ActionLogPanel
|
||||||
|
logOpen={logOpen}
|
||||||
|
log={log}
|
||||||
|
logRef={this.logRef}
|
||||||
|
onToggle={this.handleToggleLog}
|
||||||
|
onCopy={this.handleCopyLog}
|
||||||
|
onClear={this.handleClearLog}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -892,3 +451,4 @@ class AutomatInspector extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default AutomatInspector;
|
export default AutomatInspector;
|
||||||
|
|
||||||
|
|||||||
129
src/devtools/inspector/ActionLogPanel.jsx
Normal file
129
src/devtools/inspector/ActionLogPanel.jsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import ButtonBase from '@mui/material/ButtonBase';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import { formatTime } from './actionLog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders one captured action call as a single log line.
|
||||||
|
*
|
||||||
|
* @param {{entry: object}} props Component props.
|
||||||
|
* @returns {React.ReactElement} Log line.
|
||||||
|
*/
|
||||||
|
function LogLine({ entry }) {
|
||||||
|
const color =
|
||||||
|
entry.status === 'error' ? 'error.main' : entry.status === 'running' ? 'text.secondary' : 'success.main';
|
||||||
|
return (
|
||||||
|
<Box component="div">
|
||||||
|
<Box component="span" sx={{ color: 'text.disabled' }}>
|
||||||
|
{formatTime(entry.time)}{' '}
|
||||||
|
</Box>
|
||||||
|
<Box component="span" sx={{ fontWeight: 600 }}>
|
||||||
|
{entry.automat}.{entry.action}
|
||||||
|
</Box>
|
||||||
|
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||||
|
({entry.args})
|
||||||
|
</Box>
|
||||||
|
{entry.status === 'error' ? (
|
||||||
|
<Box component="span" sx={{ color: 'error.main' }}>
|
||||||
|
{' '}
|
||||||
|
✗ {entry.error}
|
||||||
|
</Box>
|
||||||
|
) : entry.status === 'ok' ? (
|
||||||
|
<Box component="span" sx={{ color: 'text.disabled' }}>
|
||||||
|
{' '}
|
||||||
|
→ {entry.result}
|
||||||
|
</Box>
|
||||||
|
) : null}{' '}
|
||||||
|
<Box component="span" sx={{ color, ml: 0.5 }}>
|
||||||
|
{entry.status === 'running'
|
||||||
|
? '…'
|
||||||
|
: `${entry.status === 'error' ? '✗' : '✓'} ${Math.round(entry.duration)}ms`}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom-docked, collapsible log of every captured automat action call.
|
||||||
|
*
|
||||||
|
* @param {object} props Component props.
|
||||||
|
* @param {boolean} props.logOpen Whether the log body is visible.
|
||||||
|
* @param {Array<object>} props.log Log entries.
|
||||||
|
* @param {import('react').RefObject<HTMLElement>} props.logRef Ref of the scroll container.
|
||||||
|
* @param {() => void} props.onToggle Toggles the log.
|
||||||
|
* @param {() => void} props.onCopy Copies the log.
|
||||||
|
* @param {() => void} props.onClear Clears the log.
|
||||||
|
* @returns {React.ReactElement} Log panel.
|
||||||
|
*/
|
||||||
|
export default function ActionLogPanel({ logOpen, log, logRef, onToggle, onCopy, onClear }) {
|
||||||
|
const errorCount = log.filter((entry) => entry.status === 'error').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ borderTop: '1px solid', borderColor: 'divider', flexShrink: 0 }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: 1.5, py: 0.5 }}>
|
||||||
|
<ButtonBase
|
||||||
|
component="div"
|
||||||
|
onClick={onToggle}
|
||||||
|
sx={{ flexGrow: 1, display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-start', py: 0.5 }}
|
||||||
|
>
|
||||||
|
{logOpen ? <ExpandMoreIcon fontSize="small" /> : <ChevronRightIcon fontSize="small" />}
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||||
|
Action-Log
|
||||||
|
</Typography>
|
||||||
|
<Chip size="small" variant="outlined" label={log.length} />
|
||||||
|
{errorCount > 0 ? <Chip size="small" color="error" label={`${errorCount} Fehler`} /> : null}
|
||||||
|
</ButtonBase>
|
||||||
|
<Tooltip title="Log als Text kopieren">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" onClick={onCopy} disabled={log.length === 0}>
|
||||||
|
<ContentCopyIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Log leeren">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" onClick={onClear} disabled={log.length === 0}>
|
||||||
|
<ClearIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{logOpen ? (
|
||||||
|
<Box
|
||||||
|
ref={logRef}
|
||||||
|
component="div"
|
||||||
|
sx={{
|
||||||
|
m: 0,
|
||||||
|
mx: 1.5,
|
||||||
|
mb: 1,
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
maxHeight: '40vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{log.length === 0 ? (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Noch keine Action-Aufrufe protokolliert.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
log.map((entry) => <LogLine key={entry.id} entry={entry} />)
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
src/devtools/inspector/AutomatEntry.jsx
Normal file
154
src/devtools/inspector/AutomatEntry.jsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import ButtonBase from '@mui/material/ButtonBase';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import { ARITY } from './actionLog';
|
||||||
|
import { formatState } from './serialize';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One automat entry: header chips, flashing/clickable actions and the state dump.
|
||||||
|
*
|
||||||
|
* @param {object} props Component props.
|
||||||
|
* @param {import('react-automat').Automat} props.automat Automat instance.
|
||||||
|
* @param {boolean} props.isExpanded Whether the details are visible.
|
||||||
|
* @param {Object<string, number>} props.flash Per-action call counters driving the flash animation.
|
||||||
|
* @param {(name: string) => void} props.onToggle Toggles the entry.
|
||||||
|
* @param {(name: string) => void} props.onCopy Copies the state.
|
||||||
|
* @param {(name: string, action: string) => void} props.onRunAction Runs a parameterless action.
|
||||||
|
* @returns {React.ReactElement} Entry element.
|
||||||
|
*/
|
||||||
|
export default function AutomatEntry({ automat, isExpanded, flash, onToggle, onCopy, onRunAction }) {
|
||||||
|
const name = automat.name;
|
||||||
|
const actionNames = Object.keys(automat.actions ?? {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ButtonBase
|
||||||
|
component="div"
|
||||||
|
onClick={() => onToggle(name)}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
gap: 1,
|
||||||
|
px: 1,
|
||||||
|
py: 0.75,
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ExpandMoreIcon fontSize="small" /> : <ChevronRightIcon fontSize="small" />}
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
noWrap
|
||||||
|
sx={{ fontFamily: 'monospace', fontWeight: 600, flexGrow: 1, minWidth: 0 }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Typography>
|
||||||
|
<Chip size="small" variant="outlined" label={`subs ${automat.subscriberCount}`} />
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
color={automat.isReady ? 'success' : 'default'}
|
||||||
|
variant={automat.isReady ? 'filled' : 'outlined'}
|
||||||
|
label={automat.isReady ? 'ready' : 'unready'}
|
||||||
|
/>
|
||||||
|
{automat.isDirty ? <Chip size="small" color="warning" label="dirty" /> : null}
|
||||||
|
</ButtonBase>
|
||||||
|
|
||||||
|
{isExpanded ? (
|
||||||
|
<Box sx={{ borderTop: '1px solid', borderColor: 'divider', px: 1.25, py: 1 }}>
|
||||||
|
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap" sx={{ mb: 1 }}>
|
||||||
|
{automat.url ? <Chip size="small" variant="outlined" label={`url: ${automat.url}`} /> : null}
|
||||||
|
{automat.persist ? <Chip size="small" variant="outlined" label="persist" /> : null}
|
||||||
|
{automat.error ? (
|
||||||
|
<Chip size="small" color="error" label={`error: ${automat.error?.message ?? automat.error}`} />
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mb: 0.5 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ flexGrow: 1 }}>
|
||||||
|
Aktionen ({actionNames.length})
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title="State als Text kopieren">
|
||||||
|
<IconButton size="small" onClick={() => onCopy(name)}>
|
||||||
|
<ContentCopyIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap" sx={{ mb: 1 }}>
|
||||||
|
{actionNames.length === 0 ? (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
keine
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
actionNames.map((action) => {
|
||||||
|
const fn = automat.actions[action];
|
||||||
|
const runnable = fn?.[ARITY] === 0;
|
||||||
|
const count = flash[`${name}.${action}`] ?? 0;
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
// Changing key remounts the chip so the CSS animation replays on every call.
|
||||||
|
key={`${action}-${count}`}
|
||||||
|
size="small"
|
||||||
|
label={action}
|
||||||
|
color={runnable ? 'primary' : 'default'}
|
||||||
|
variant={runnable ? 'outlined' : 'filled'}
|
||||||
|
onClick={runnable ? () => onRunAction(name, action) : undefined}
|
||||||
|
sx={
|
||||||
|
count > 0
|
||||||
|
? {
|
||||||
|
'@keyframes automatFlash': {
|
||||||
|
'0%': { backgroundColor: 'warning.main', boxShadow: '0 0 8px 2px orange' },
|
||||||
|
'100%': { boxShadow: '0 0 0 0 transparent' },
|
||||||
|
},
|
||||||
|
animation: 'automatFlash 0.7s ease-out',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
State
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
m: 0,
|
||||||
|
mt: 0.5,
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
maxHeight: 320,
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatState(automat.state)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/devtools/inspector/actionLog.js
Normal file
187
src/devtools/inspector/actionLog.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { formatState } from './serialize';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live registry view. Named automats register themselves in the constructor,
|
||||||
|
* so the map may be absent until the first one is created.
|
||||||
|
*
|
||||||
|
* @returns {Map<string, import('react-automat').Automat>} Registered automats.
|
||||||
|
*/
|
||||||
|
export function getRegistry() {
|
||||||
|
const registry = window.__AUTOMATS__;
|
||||||
|
return registry instanceof Map ? registry : new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Maximum number of retained action log entries (ring buffer). */
|
||||||
|
const MAX_LOG = 300;
|
||||||
|
|
||||||
|
/** Marks action functions that already carry the logging proxy. */
|
||||||
|
const LOGGED = Symbol('automatInspectorLogged');
|
||||||
|
|
||||||
|
/** Compact single-line rendering limit for args/results in the log. */
|
||||||
|
const LOG_VALUE_LIMIT = 240;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ring buffer of captured action calls, oldest first. Kept at module scope so
|
||||||
|
* entries survive component re-renders (and are shared by every inspector).
|
||||||
|
*
|
||||||
|
* @type {Array<{id: number, time: Date, automat: string, action: string, args: string, status: string, duration: number|null, result: string, error: string|null}>}
|
||||||
|
*/
|
||||||
|
export const actionLog = [];
|
||||||
|
|
||||||
|
/** Monotonic id source for log entries. */
|
||||||
|
let logSeq = 0;
|
||||||
|
|
||||||
|
/** @type {Set<() => void>} Listeners notified when the log buffer changes. */
|
||||||
|
export const logListeners = new Set();
|
||||||
|
|
||||||
|
/** Marks the declared parameter count on a wrapped action. */
|
||||||
|
export const ARITY = Symbol('automatInspectorArity');
|
||||||
|
|
||||||
|
/** @type {Set<(automat: string, action: string) => void>} Listeners notified on every action call. */
|
||||||
|
export const callListeners = new Set();
|
||||||
|
|
||||||
|
/** Notifies every log listener, swallowing individual errors. */
|
||||||
|
export function notifyLog() {
|
||||||
|
for (const listener of logListeners) {
|
||||||
|
try {
|
||||||
|
listener();
|
||||||
|
} catch {
|
||||||
|
/* listeners are dev-only; never let one break logging */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends an entry to the ring buffer and notifies listeners.
|
||||||
|
*
|
||||||
|
* @param {object} entry Log entry.
|
||||||
|
*/
|
||||||
|
function pushLog(entry) {
|
||||||
|
actionLog.push(entry);
|
||||||
|
if (actionLog.length > MAX_LOG) actionLog.splice(0, actionLog.length - MAX_LOG);
|
||||||
|
notifyLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a value as a compact single-line string for the log.
|
||||||
|
*
|
||||||
|
* @param {*} value Value to summarize.
|
||||||
|
* @param {number} [limit] Maximum length before truncation.
|
||||||
|
* @returns {string} Compact representation.
|
||||||
|
*/
|
||||||
|
function compact(value, limit = LOG_VALUE_LIMIT) {
|
||||||
|
const text = formatState(value).replace(/\s+/g, ' ').trim();
|
||||||
|
return text.length > limit ? `${text.slice(0, limit)} …` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a timestamp as `HH:MM:SS.mmm`.
|
||||||
|
*
|
||||||
|
* @param {Date} date Timestamp.
|
||||||
|
* @returns {string} Formatted time.
|
||||||
|
*/
|
||||||
|
export function formatTime(date) {
|
||||||
|
const pad = (n, len = 2) => String(n).padStart(len, '0');
|
||||||
|
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps every action of an automat with a logging proxy that records call
|
||||||
|
* arguments, duration, return value and errors. Idempotent: already wrapped
|
||||||
|
* actions are left untouched, and the original function is always invoked with
|
||||||
|
* the caller's `this` and arguments.
|
||||||
|
*
|
||||||
|
* @param {import('react-automat').Automat} automat Automat instance.
|
||||||
|
*/
|
||||||
|
export function installActionLogging(automat) {
|
||||||
|
const actions = automat.actions;
|
||||||
|
if (!actions) return;
|
||||||
|
for (const key of Object.keys(actions)) {
|
||||||
|
const original = actions[key];
|
||||||
|
if (typeof original !== 'function' || original[LOGGED]) continue;
|
||||||
|
|
||||||
|
const wrapped = function loggedAction(...args) {
|
||||||
|
const started = performance.now();
|
||||||
|
const entry = {
|
||||||
|
id: ++logSeq,
|
||||||
|
time: new Date(),
|
||||||
|
automat: automat.name ?? '(anonym)',
|
||||||
|
action: key,
|
||||||
|
args: compact(args),
|
||||||
|
status: 'running',
|
||||||
|
duration: null,
|
||||||
|
result: '',
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
pushLog(entry);
|
||||||
|
for (const listener of callListeners) {
|
||||||
|
try {
|
||||||
|
listener(entry.automat, key);
|
||||||
|
} catch {
|
||||||
|
/* dev-only */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const settle = (status, payload, err) => {
|
||||||
|
entry.status = status;
|
||||||
|
entry.duration = performance.now() - started;
|
||||||
|
if (status === 'error') entry.error = err?.message ?? String(err);
|
||||||
|
else entry.result = compact(payload);
|
||||||
|
notifyLog();
|
||||||
|
};
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = original.apply(this, args);
|
||||||
|
} catch (err) {
|
||||||
|
settle('error', undefined, err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result && typeof result.then === 'function') {
|
||||||
|
return result.then(
|
||||||
|
(value) => {
|
||||||
|
settle('ok', value);
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
settle('error', undefined, err);
|
||||||
|
throw err;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
settle('ok', result);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object.defineProperty(wrapped, 'name', { value: key });
|
||||||
|
} catch {
|
||||||
|
/* name is cosmetic */
|
||||||
|
}
|
||||||
|
wrapped[LOGGED] = true;
|
||||||
|
wrapped[ARITY] = original.length;
|
||||||
|
actions[key] = wrapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks the registry Map once its `set` has been wrapped. */
|
||||||
|
const WATCHED = Symbol('automatInspectorWatched');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the live registry's `set` so automats created after mount get their
|
||||||
|
* actions instrumented immediately instead of on the next poll tick.
|
||||||
|
*/
|
||||||
|
export function watchRegistry() {
|
||||||
|
const registry = window.__AUTOMATS__;
|
||||||
|
if (!(registry instanceof Map) || registry[WATCHED]) return;
|
||||||
|
const originalSet = registry.set.bind(registry);
|
||||||
|
registry.set = (name, automat) => {
|
||||||
|
const result = originalSet(name, automat);
|
||||||
|
installActionLogging(automat);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
registry[WATCHED] = true;
|
||||||
|
}
|
||||||
118
src/devtools/inspector/serialize.js
Normal file
118
src/devtools/inspector/serialize.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/** Serialization limits so huge states stay renderable. */
|
||||||
|
const MAX_DEPTH = 6;
|
||||||
|
const MAX_STRING = 1500;
|
||||||
|
const MAX_ITEMS = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quotes a string and truncates oversized values.
|
||||||
|
*
|
||||||
|
* @param {string} text Value to quote.
|
||||||
|
* @returns {string} Quoted, possibly truncated string.
|
||||||
|
*/
|
||||||
|
function quote(text) {
|
||||||
|
if (text.length <= MAX_STRING) return JSON.stringify(text);
|
||||||
|
const rest = text.length - MAX_STRING;
|
||||||
|
return `${JSON.stringify(text.slice(0, MAX_STRING))} … (+${rest} Zeichen)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders `entries` as an indented block.
|
||||||
|
*
|
||||||
|
* @param {string} open Opening bracket.
|
||||||
|
* @param {string} close Closing bracket.
|
||||||
|
* @param {Array<string>} entries Already serialized entries.
|
||||||
|
* @param {string} pad Indentation of the enclosing level.
|
||||||
|
* @returns {string} Indented block.
|
||||||
|
*/
|
||||||
|
function block(open, close, entries, pad) {
|
||||||
|
if (entries.length === 0) return `${open}${close}`;
|
||||||
|
const body = entries.map((entry) => `${pad} ${entry}`).join(',\n');
|
||||||
|
return `${open}\n${body}\n${pad}${close}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes an arbitrary state value for display. Handles functions, blobs,
|
||||||
|
* DOM nodes, maps, sets and circular references, which plain `JSON.stringify`
|
||||||
|
* would drop or throw on.
|
||||||
|
*
|
||||||
|
* @param {*} value Value to serialize.
|
||||||
|
* @param {number} depth Current nesting depth.
|
||||||
|
* @param {WeakSet<object>} ancestors Objects on the current path, for cycle detection.
|
||||||
|
* @param {string} pad Indentation of the current level.
|
||||||
|
* @returns {string} Human-readable representation.
|
||||||
|
*/
|
||||||
|
function serialize(value, depth, ancestors, pad) {
|
||||||
|
if (value === null) return 'null';
|
||||||
|
if (value === undefined) return 'undefined';
|
||||||
|
|
||||||
|
const type = typeof value;
|
||||||
|
if (type === 'string') return quote(value);
|
||||||
|
if (type === 'number' || type === 'boolean') return String(value);
|
||||||
|
if (type === 'bigint') return `${value}n`;
|
||||||
|
if (type === 'function') return `[Function ${value.name || 'anonym'}]`;
|
||||||
|
if (type === 'symbol') return `[Symbol ${value.description ?? ''}]`;
|
||||||
|
if (type !== 'object') return String(value);
|
||||||
|
|
||||||
|
if (value instanceof Date) return `[Date ${value.toISOString()}]`;
|
||||||
|
if (value instanceof Blob) return `[Blob ${value.size}B ${value.type || 'ohne Typ'}]`;
|
||||||
|
if (value instanceof ArrayBuffer) return `[ArrayBuffer ${value.byteLength}B]`;
|
||||||
|
if (value instanceof Promise) return '[Promise]';
|
||||||
|
if (typeof Node !== 'undefined' && value instanceof Node) return `[Node ${value.nodeName}]`;
|
||||||
|
|
||||||
|
if (ancestors.has(value)) return '[Circular]';
|
||||||
|
if (depth >= MAX_DEPTH) return Array.isArray(value) ? '[Array …]' : '[Object …]';
|
||||||
|
|
||||||
|
ancestors.add(value);
|
||||||
|
try {
|
||||||
|
if (value instanceof Map) {
|
||||||
|
const shown = Array.from(value.entries()).slice(0, MAX_ITEMS);
|
||||||
|
const entries = shown.map(
|
||||||
|
([k, v]) =>
|
||||||
|
`${serialize(k, depth + 1, ancestors, `${pad} `)}: ${serialize(v, depth + 1, ancestors, `${pad} `)}`,
|
||||||
|
);
|
||||||
|
if (value.size > shown.length) entries.push(`${JSON.stringify('…')}: +${value.size - shown.length} weitere`);
|
||||||
|
return block(`Map(${value.size}) {`, '}', entries, pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Set) {
|
||||||
|
const shown = Array.from(value.values()).slice(0, MAX_ITEMS);
|
||||||
|
const entries = shown.map((item) => serialize(item, depth + 1, ancestors, `${pad} `));
|
||||||
|
if (value.size > shown.length) entries.push(`${JSON.stringify('…')}: +${value.size - shown.length} weitere`);
|
||||||
|
return block(`Set(${value.size}) {`, '}', entries, pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const shown = value.slice(0, MAX_ITEMS);
|
||||||
|
const entries = shown.map((item) => serialize(item, depth + 1, ancestors, `${pad} `));
|
||||||
|
if (value.length > shown.length) entries.push(`${JSON.stringify('…')}: +${value.length - shown.length} weitere`);
|
||||||
|
return block('[', ']', entries, pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
const shown = keys.slice(0, MAX_ITEMS);
|
||||||
|
const entries = shown.map(
|
||||||
|
(key) => `${JSON.stringify(key)}: ${serialize(value[key], depth + 1, ancestors, `${pad} `)}`,
|
||||||
|
);
|
||||||
|
if (keys.length > shown.length) entries.push(`${JSON.stringify('…')}: +${keys.length - shown.length} weitere Schlüssel`);
|
||||||
|
|
||||||
|
const label = value.constructor && value.constructor !== Object ? `${value.constructor.name} ` : '';
|
||||||
|
return block(`${label}{`, '}', entries, pad);
|
||||||
|
} finally {
|
||||||
|
ancestors.delete(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats an automat state for the overlay. Serialization failures are surfaced
|
||||||
|
* instead of being swallowed.
|
||||||
|
*
|
||||||
|
* @param {*} value Automat state.
|
||||||
|
* @returns {string} Display text.
|
||||||
|
*/
|
||||||
|
export function formatState(value) {
|
||||||
|
try {
|
||||||
|
return serialize(value, 0, new WeakSet(), '');
|
||||||
|
} catch (err) {
|
||||||
|
return `[Serialisierung fehlgeschlagen: ${err?.message ?? err}]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,9 @@ export function primaryPrice(article) {
|
|||||||
|
|
||||||
export function priceBrutto(price) {
|
export function priceBrutto(price) {
|
||||||
if (price?.net == null || price?.taxRate == null) return null;
|
if (price?.net == null || price?.taxRate == null) return null;
|
||||||
return price.net * (1 + price.taxRate / 100);
|
const gross = price.net * (1 + price.taxRate / 100);
|
||||||
|
// Round to cents; epsilon guards float noise (e.g. 9.994999999 vs 9.995).
|
||||||
|
return Math.round((gross + Number.EPSILON) * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -240,9 +240,14 @@ export function autoCropWhiteAndTransparent(img) {
|
|||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crop tightly to bounding box
|
// Restore up to 5 pixels border around the detected content
|
||||||
const cropW = maxX - minX + 1;
|
const PADDING = 5;
|
||||||
const cropH = maxY - minY + 1;
|
const cropX = Math.max(0, minX - PADDING);
|
||||||
|
const cropY = Math.max(0, minY - PADDING);
|
||||||
|
const cropMaxX = Math.min(width - 1, maxX + PADDING);
|
||||||
|
const cropMaxY = Math.min(height - 1, maxY + PADDING);
|
||||||
|
const cropW = cropMaxX - cropX + 1;
|
||||||
|
const cropH = cropMaxY - cropY + 1;
|
||||||
|
|
||||||
const croppedCanvas = document.createElement('canvas');
|
const croppedCanvas = document.createElement('canvas');
|
||||||
croppedCanvas.width = cropW;
|
croppedCanvas.width = cropW;
|
||||||
@@ -253,7 +258,7 @@ export function autoCropWhiteAndTransparent(img) {
|
|||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
croppedCtx.drawImage(canvas, minX, minY, cropW, cropH, 0, 0, cropW, cropH);
|
croppedCtx.drawImage(canvas, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH);
|
||||||
return croppedCanvas;
|
return croppedCanvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user