inspector

This commit is contained in:
seb
2026-09-21 04:30:11 +02:00
parent 366d2517d6
commit 1b6e3ec7e2
2 changed files with 549 additions and 0 deletions

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

View File

@@ -1,4 +1,5 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { lazy, Suspense } from 'react';
import CssBaseline from '@mui/material/CssBaseline'; import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles'; import { ThemeProvider } from '@mui/material/styles';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
@@ -10,6 +11,12 @@ import { store } from './store';
import { getActiveTheme } from './theme'; import { getActiveTheme } from './theme';
import './index.css'; import './index.css';
// Dev-only Automat inspector. The `import.meta.env.DEV` branch is statically
// eliminated in production builds, so this module never ships to users.
const AutomatInspector = import.meta.env.DEV
? lazy(() => import('./devtools/AutomatInspector'))
: null;
async function clearDevServiceWorker() { async function clearDevServiceWorker() {
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return; if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return;
const regs = await navigator.serviceWorker.getRegistrations(); const regs = await navigator.serviceWorker.getRegistrations();
@@ -36,6 +43,11 @@ createRoot(document.getElementById('root')).render(
<StorageManagerProvider> <StorageManagerProvider>
<AuthBootstrap> <AuthBootstrap>
<App /> <App />
{AutomatInspector ? (
<Suspense fallback={null}>
<AutomatInspector />
</Suspense>
) : null}
</AuthBootstrap> </AuthBootstrap>
</StorageManagerProvider> </StorageManagerProvider>
</SnackbarProvider> </SnackbarProvider>