This commit is contained in:
seb
2026-09-21 20:21:57 +02:00
parent 2046f96768
commit 536e7b1e2a
20 changed files with 3047 additions and 672 deletions

View File

@@ -1,9 +1,8 @@
import { PureComponent } from 'react';
import { PureComponent, createRef } 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';
@@ -12,6 +11,7 @@ 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 ClearIcon from '@mui/icons-material/Clear';
import CloseIcon from '@mui/icons-material/Close';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
@@ -162,11 +162,175 @@ export function formatState(value) {
}
}
/** 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;
/**
* Floating dev button plus overlay listing all registered Automats with live
* state, readiness flags, subscriber counts and available actions.
* Ring buffer of captured action calls, oldest first. Kept at module scope so
* entries survive component re-renders (and are shared by every inspector).
*
* @augments {PureComponent<{}, {open: boolean, names: Array<string>, expanded: Object<string, boolean>, query: string, revision: number}>}
* @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
* with live state, readiness flags, subscriber counts and available actions.
*
* The panel is rendered in-flow (not as an overlay), so the page stays fully
* interactive while the action log is watched live. It relies on being placed
* as a flex sibling of the app content (see `main.jsx`).
*
* @augments {PureComponent<{}, {open: boolean, names: Array<string>, expanded: Object<string, boolean>, query: string, revision: number, logOpen: boolean, log: Array<object>}>}
*/
class AutomatInspector extends PureComponent {
constructor(props) {
@@ -177,11 +341,15 @@ class AutomatInspector extends PureComponent {
expanded: {},
query: '',
revision: 0,
logOpen: false,
log: [],
};
/** @type {Map<string, () => void>} Unsubscribe functions per automat name. */
this.unsubs = new Map();
this.frame = null;
this.logFrame = null;
this.logRef = createRef();
this.handleToggle = this.handleToggle.bind(this);
this.handleClose = this.handleClose.bind(this);
@@ -193,24 +361,35 @@ class AutomatInspector extends PureComponent {
this.handleKeyDown = this.handleKeyDown.bind(this);
this.syncRegistry = this.syncRegistry.bind(this);
this.scheduleUpdate = this.scheduleUpdate.bind(this);
this.scheduleLogUpdate = this.scheduleLogUpdate.bind(this);
this.handleToggleLog = this.handleToggleLog.bind(this);
this.handleClearLog = this.handleClearLog.bind(this);
this.handleCopyLog = this.handleCopyLog.bind(this);
}
componentDidMount() {
this.syncRegistry();
this.registryTimer = window.setInterval(this.syncRegistry, REGISTRY_POLL_MS);
window.addEventListener('keydown', this.handleKeyDown);
logListeners.add(this.scheduleLogUpdate);
this.scheduleLogUpdate();
}
componentWillUnmount() {
window.clearInterval(this.registryTimer);
window.removeEventListener('keydown', this.handleKeyDown);
logListeners.delete(this.scheduleLogUpdate);
this.unsubscribeAll();
if (this.frame) cancelAnimationFrame(this.frame);
if (this.logFrame) cancelAnimationFrame(this.logFrame);
}
/** Mirrors the registry keys into state and keeps subscriptions in sync. */
syncRegistry() {
const names = Array.from(getRegistry().keys()).sort((a, b) => a.localeCompare(b));
watchRegistry();
const registry = getRegistry();
for (const automat of registry.values()) installActionLogging(automat);
const names = Array.from(registry.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 });
@@ -249,6 +428,58 @@ class AutomatInspector extends PureComponent {
});
}
/**
* Copies the module-level log ring buffer into state, coalesced per frame so
* bursts of action calls do not cause a render each. Skipped while the drawer
* is closed; opening it re-syncs via `handleToggle`.
*/
scheduleLogUpdate() {
if (!this.state.open || this.logFrame) return;
this.logFrame = requestAnimationFrame(() => {
this.logFrame = null;
this.setState({ log: actionLog.slice() });
});
}
componentDidUpdate(prevProps, prevState) {
const grew = this.state.log.length > prevState.log.length;
const opened = this.state.logOpen && !prevState.logOpen;
if (this.state.logOpen && (grew || opened)) {
const node = this.logRef.current;
if (node) node.scrollTop = node.scrollHeight;
}
}
handleToggleLog() {
this.setState((prev) => ({ logOpen: !prev.logOpen, log: actionLog.slice() }));
}
handleClearLog() {
actionLog.length = 0;
notifyLog();
}
/** Copies the whole log buffer to the clipboard as plain text. */
async handleCopyLog() {
try {
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API nicht verfügbar (sichere Origin erforderlich).');
}
const text = actionLog
.map(
(entry) =>
`${formatTime(entry.time)} ${entry.automat}.${entry.action}(${entry.args})` +
`${entry.status === 'error' ? ` ! ${entry.error}` : ` → ${entry.result}`}` +
`${entry.duration != null ? ` [${Math.round(entry.duration)}ms]` : ''}`,
)
.join('\n');
await navigator.clipboard.writeText(text);
enqueueSnackbar(`Log kopiert (${actionLog.length} Einträge)`, { variant: 'success' });
} catch (err) {
notifyError(err);
}
}
handleKeyDown(event) {
if (!event.altKey || !event.shiftKey) return;
if (event.key?.toLowerCase() !== 'a') return;
@@ -262,6 +493,7 @@ class AutomatInspector extends PureComponent {
if (open) {
this.syncRegistry();
this.subscribeToAll();
this.scheduleLogUpdate();
} else {
this.unsubscribeAll();
}
@@ -434,8 +666,116 @@ class AutomatInspector extends PureComponent {
);
}
render() {
const { open, names, expanded, query } = this.state;
/**
* 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. */
renderPanel() {
const { names, expanded, query } = this.state;
const registry = getRegistry();
const needle = query.trim().toLowerCase();
const entries = names
@@ -444,91 +784,108 @@ class AutomatInspector extends PureComponent {
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 }}
<Box
component="aside"
aria-label="Automat-Inspector"
sx={{
width: { xs: '100%', sm: 560 },
maxWidth: '100%',
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
position: 'sticky',
top: 0,
alignSelf: 'flex-start',
height: '100vh',
borderLeft: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
zIndex: (theme) => theme.zIndex.appBar,
}}
>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ px: 2, py: 1.25, borderBottom: '1px solid', borderColor: 'divider' }}
>
<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>
<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>
</Badge>
<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>
<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
<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>
<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>
) : (
<Stack spacing={1}>
{entries.map((automat) => this.renderEntry(automat, Boolean(expanded[automat.name])))}
</Stack>
)}
</Box>
<Box sx={{ px: 2, py: 1 }}>
<TextField
size="small"
fullWidth
placeholder="Name filtern …"
value={query}
onChange={this.handleQueryChange}
/>
</Box>
{this.renderLog()}
</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>
render() {
const { open, names } = this.state;
return (
<>
{open ? null : (
<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>
)}
{open ? this.renderPanel() : null}
</>
);
}