inspector
This commit is contained in:
537
src/devtools/AutomatInspector.jsx
Normal file
537
src/devtools/AutomatInspector.jsx
Normal file
@@ -0,0 +1,537 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Box from '@mui/material/Box';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import BugReportIcon from '@mui/icons-material/BugReport';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
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 UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
|
||||
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { notifyError } from '../utils/notify';
|
||||
|
||||
/**
|
||||
* Dev-only overlay that lists every Automat registered in the
|
||||
* `window.__AUTOMATS__` registry (react-automat writes named instances there).
|
||||
*
|
||||
* Loaded exclusively through a `import.meta.env.DEV` guarded dynamic import in
|
||||
* `main.jsx`, so the module never reaches the production bundle.
|
||||
*/
|
||||
|
||||
/** Registry polling interval in ms; lazy automats (`delivery-<id>`) appear over time. */
|
||||
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}]`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating dev button plus overlay listing all registered Automats with live
|
||||
* state, readiness flags, subscriber counts and available actions.
|
||||
*
|
||||
* @augments {PureComponent<{}, {open: boolean, names: Array<string>, expanded: Object<string, boolean>, query: string, revision: number}>}
|
||||
*/
|
||||
class AutomatInspector extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
open: false,
|
||||
names: [],
|
||||
expanded: {},
|
||||
query: '',
|
||||
revision: 0,
|
||||
};
|
||||
|
||||
/** @type {Map<string, () => void>} Unsubscribe functions per automat name. */
|
||||
this.unsubs = new Map();
|
||||
this.frame = null;
|
||||
|
||||
this.handleToggle = this.handleToggle.bind(this);
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
this.handleRefresh = this.handleRefresh.bind(this);
|
||||
this.handleToggleAll = this.handleToggleAll.bind(this);
|
||||
this.handleToggleEntry = this.handleToggleEntry.bind(this);
|
||||
this.handleQueryChange = this.handleQueryChange.bind(this);
|
||||
this.handleCopy = this.handleCopy.bind(this);
|
||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
||||
this.syncRegistry = this.syncRegistry.bind(this);
|
||||
this.scheduleUpdate = this.scheduleUpdate.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.syncRegistry();
|
||||
this.registryTimer = window.setInterval(this.syncRegistry, REGISTRY_POLL_MS);
|
||||
window.addEventListener('keydown', this.handleKeyDown);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.clearInterval(this.registryTimer);
|
||||
window.removeEventListener('keydown', this.handleKeyDown);
|
||||
this.unsubscribeAll();
|
||||
if (this.frame) cancelAnimationFrame(this.frame);
|
||||
}
|
||||
|
||||
/** Mirrors the registry keys into state and keeps subscriptions in sync. */
|
||||
syncRegistry() {
|
||||
const names = Array.from(getRegistry().keys()).sort((a, b) => a.localeCompare(b));
|
||||
const changed =
|
||||
names.length !== this.state.names.length || names.some((name, i) => name !== this.state.names[i]);
|
||||
if (changed) this.setState({ names });
|
||||
if (this.state.open) this.subscribeToAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to every registered automat so the overlay reflects live state.
|
||||
* Note: subscribing also flushes a pending deferred load of a dirty automat.
|
||||
*/
|
||||
subscribeToAll() {
|
||||
const registry = getRegistry();
|
||||
for (const [name, automat] of registry) {
|
||||
if (this.unsubs.has(name)) continue;
|
||||
this.unsubs.set(name, automat.subscribe(() => this.scheduleUpdate()));
|
||||
}
|
||||
for (const [name, unsub] of Array.from(this.unsubs)) {
|
||||
if (!registry.has(name)) {
|
||||
unsub();
|
||||
this.unsubs.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsubscribeAll() {
|
||||
for (const unsub of this.unsubs.values()) unsub();
|
||||
this.unsubs.clear();
|
||||
}
|
||||
|
||||
/** Coalesces automat notifications into one render per animation frame. */
|
||||
scheduleUpdate() {
|
||||
if (this.frame) return;
|
||||
this.frame = requestAnimationFrame(() => {
|
||||
this.frame = null;
|
||||
this.setState((prev) => ({ revision: prev.revision + 1 }));
|
||||
});
|
||||
}
|
||||
|
||||
handleKeyDown(event) {
|
||||
if (!event.altKey || !event.shiftKey) return;
|
||||
if (event.key?.toLowerCase() !== 'a') return;
|
||||
event.preventDefault();
|
||||
this.handleToggle();
|
||||
}
|
||||
|
||||
handleToggle() {
|
||||
const open = !this.state.open;
|
||||
this.setState({ open });
|
||||
if (open) {
|
||||
this.syncRegistry();
|
||||
this.subscribeToAll();
|
||||
} else {
|
||||
this.unsubscribeAll();
|
||||
}
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
this.setState({ open: false });
|
||||
this.unsubscribeAll();
|
||||
}
|
||||
|
||||
handleRefresh() {
|
||||
this.syncRegistry();
|
||||
}
|
||||
|
||||
handleToggleAll() {
|
||||
const { names, expanded } = this.state;
|
||||
const allExpanded = names.length > 0 && names.every((name) => expanded[name]);
|
||||
this.setState({
|
||||
expanded: allExpanded
|
||||
? {}
|
||||
: Object.fromEntries(names.map((name) => [name, true])),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name Automat name.
|
||||
*/
|
||||
handleToggleEntry(name) {
|
||||
this.setState((prev) => ({
|
||||
expanded: { ...prev.expanded, [name]: !prev.expanded[name] },
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {React.ChangeEvent<HTMLInputElement>} event Change event.
|
||||
*/
|
||||
handleQueryChange(event) {
|
||||
this.setState({ query: event.target.value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the current state of one automat as text.
|
||||
*
|
||||
* @param {string} name Automat name.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async handleCopy(name) {
|
||||
const automat = getRegistry().get(name);
|
||||
if (!automat) return;
|
||||
try {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
throw new Error('Clipboard API nicht verfügbar (sichere Origin erforderlich).');
|
||||
}
|
||||
await navigator.clipboard.writeText(`${name}\n${formatState(automat.state)}`);
|
||||
enqueueSnackbar(`State von "${name}" kopiert`, { variant: 'success' });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one automat entry.
|
||||
*
|
||||
* @param {import('react-automat').Automat} automat Automat instance.
|
||||
* @param {boolean} isExpanded Whether the details are visible.
|
||||
* @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>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { open, names, expanded, query } = this.state;
|
||||
const registry = getRegistry();
|
||||
const needle = query.trim().toLowerCase();
|
||||
const entries = names
|
||||
.map((name) => registry.get(name))
|
||||
.filter((automat) => automat && (!needle || automat.name.toLowerCase().includes(needle)));
|
||||
const allExpanded = names.length > 0 && names.every((name) => expanded[name]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Badge
|
||||
badgeContent={names.length}
|
||||
color="secondary"
|
||||
overlap="circular"
|
||||
invisible={names.length === 0}
|
||||
sx={{ position: 'fixed', right: 16, bottom: 16, zIndex: (theme) => theme.zIndex.modal + 1 }}
|
||||
>
|
||||
<Tooltip title="Automat-Inspector (Alt+Shift+A)">
|
||||
<Fab
|
||||
size="small"
|
||||
color="primary"
|
||||
aria-label="Automat-Inspector umschalten"
|
||||
aria-pressed={open}
|
||||
onClick={this.handleToggle}
|
||||
>
|
||||
<BugReportIcon fontSize="small" />
|
||||
</Fab>
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={open}
|
||||
onClose={this.handleClose}
|
||||
slotProps={{
|
||||
transition: { timeout: 0 },
|
||||
paper: {
|
||||
sx: {
|
||||
width: { xs: '100%', sm: 560 },
|
||||
maxWidth: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1}
|
||||
sx={{ px: 2, py: 1.25, borderBottom: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<BugReportIcon fontSize="small" color="primary" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, flexGrow: 1 }}>
|
||||
Automat-Inspector
|
||||
</Typography>
|
||||
<Chip size="small" label={`${names.length} registriert`} />
|
||||
<Tooltip title="Registry neu einlesen">
|
||||
<IconButton size="small" onClick={this.handleRefresh}>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={allExpanded ? 'Alle einklappen' : 'Alle ausklappen'}>
|
||||
<IconButton size="small" onClick={this.handleToggleAll}>
|
||||
{allExpanded ? <UnfoldLessIcon fontSize="small" /> : <UnfoldMoreIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Schließen">
|
||||
<IconButton size="small" onClick={this.handleClose}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ px: 2, py: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Name filtern …"
|
||||
value={query}
|
||||
onChange={this.handleQueryChange}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1, minHeight: 0, overflowY: 'auto', px: 1.5, pb: 2 }}>
|
||||
{entries.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
|
||||
{names.length === 0 ? 'Kein Automat registriert.' : 'Kein Automat passt zum Filter.'}
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={1}>{entries.map((automat) => this.renderEntry(automat, Boolean(expanded[automat.name])))}</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AutomatInspector;
|
||||
Reference in New Issue
Block a user