This commit is contained in:
seb
2026-10-09 10:42:24 +02:00
parent 1933ac794d
commit 38339b13a2
8 changed files with 353 additions and 37 deletions

251
src/devtools/SseMonitor.jsx Normal file
View File

@@ -0,0 +1,251 @@
import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Fab from '@mui/material/Fab';
import IconButton from '@mui/material/IconButton';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import CloseIcon from '@mui/icons-material/Close';
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import SensorsIcon from '@mui/icons-material/Sensors';
import sseAutomat from '../automats/sseAutomat';
const PANEL_WIDTH = 420;
const PANEL_HEIGHT = 380;
function formatEventData(data) {
if (!data) return '';
try {
return JSON.stringify(JSON.parse(data), null, 2);
} catch {
return data;
}
}
class SseMonitor extends PureComponent {
constructor(props) {
super(props);
this.state = {
status: sseAutomat.state.status,
companyId: sseAutomat.state.companyId,
events: sseAutomat.state.events,
open: false,
position: null,
};
this.panelRef = createRef();
this.handleToggle = this.handleToggle.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handlePointerDown = this.handlePointerDown.bind(this);
this.handlePointerMove = this.handlePointerMove.bind(this);
this.handlePointerUp = this.handlePointerUp.bind(this);
this.handleClear = this.handleClear.bind(this);
this.handleClose = this.handleClose.bind(this);
}
componentDidMount() {
this.unsubscribe = sseAutomat.subscribe(this, (state) => ({
status: state.status,
companyId: state.companyId,
events: state.events,
}));
window.addEventListener('keydown', this.handleKeyDown);
}
componentWillUnmount() {
this.unsubscribe?.();
window.removeEventListener('keydown', this.handleKeyDown);
}
handleKeyDown(event) {
if (!event.altKey || !event.shiftKey || event.key?.toLowerCase() !== 's') return;
event.preventDefault();
this.handleToggle();
}
handleToggle() {
this.setState((state) => ({ open: !state.open }));
}
handleClose() {
this.setState({ open: false });
}
handleClear() {
sseAutomat.actions.clearEvents();
}
handlePointerDown(event) {
if (event.target.closest('button')) return;
const rect = this.panelRef.current?.getBoundingClientRect();
if (!rect) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
this.drag = {
pointerId: event.pointerId,
pointerX: event.clientX,
pointerY: event.clientY,
left: rect.left,
top: rect.top,
};
}
handlePointerMove(event) {
if (!this.drag || event.pointerId !== this.drag.pointerId) return;
const maxLeft = Math.max(0, window.innerWidth - PANEL_WIDTH);
const maxTop = Math.max(0, window.innerHeight - 120);
this.setState({
position: {
left: Math.max(0, Math.min(maxLeft, this.drag.left + event.clientX - this.drag.pointerX)),
top: Math.max(0, Math.min(maxTop, this.drag.top + event.clientY - this.drag.pointerY)),
},
});
}
handlePointerUp(event) {
if (this.drag?.pointerId === event.pointerId) this.drag = null;
}
renderEvent(entry) {
const data = formatEventData(entry.data);
return (
<Box
key={entry.id}
sx={{ px: 1.25, py: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" spacing={1} alignItems="baseline">
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>
{new Date(entry.time).toLocaleTimeString()}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700, overflowWrap: 'anywhere' }}>
{entry.type}
</Typography>
{entry.lastEventId ? (
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto !important' }}>
#{entry.lastEventId}
</Typography>
) : null}
</Stack>
{data ? (
<Typography
component="pre"
variant="caption"
sx={{
m: 0,
mt: 0.5,
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
color: 'text.secondary',
}}
>
{data}
</Typography>
) : null}
</Box>
);
}
render() {
const { open, position, status, companyId, events } = this.state;
const statusColor =
status === 'connected' ? 'success' : status === 'error' ? 'error' : status === 'connecting' ? 'warning' : 'default';
return (
<>
{!open ? (
<Tooltip title="SSE-Monitor (Alt+Shift+S)" placement="left">
<Fab
size="small"
color="primary"
aria-label="SSE-Monitor umschalten"
aria-pressed={open}
onClick={this.handleToggle}
sx={{
position: 'fixed',
right: 16,
bottom: 16,
zIndex: (theme) => theme.zIndex.tooltip,
}}
>
<SensorsIcon fontSize="small" />
</Fab>
</Tooltip>
) : (
<Paper
ref={this.panelRef}
component="aside"
aria-label="SSE-Monitor"
elevation={12}
sx={{
position: 'fixed',
...(position ? { left: position.left, top: position.top } : { right: 16, bottom: 16 }),
width: `min(${PANEL_WIDTH}px, calc(100vw - 24px))`,
height: `min(${PANEL_HEIGHT}px, calc(100vh - 24px))`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
zIndex: (theme) => theme.zIndex.tooltip,
border: '1px solid',
borderColor: 'divider',
}}
>
<Stack
direction="row"
alignItems="center"
spacing={1}
onPointerDown={this.handlePointerDown}
onPointerMove={this.handlePointerMove}
onPointerUp={this.handlePointerUp}
onPointerCancel={this.handlePointerUp}
sx={{
px: 1,
py: 0.75,
borderBottom: '1px solid',
borderColor: 'divider',
cursor: 'grab',
touchAction: 'none',
userSelect: 'none',
'&:active': { cursor: 'grabbing' },
}}
>
<DragIndicatorIcon fontSize="small" color="action" />
<SensorsIcon fontSize="small" color="primary" />
<Typography variant="subtitle2" sx={{ fontWeight: 700, flexGrow: 1 }}>
SSE-Monitor
</Typography>
<Chip size="small" color={statusColor} label={status} />
<Tooltip title="Ereignisse löschen">
<IconButton size="small" aria-label="Ereignisse löschen" onClick={this.handleClear}>
<DeleteSweepIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Schließen">
<IconButton size="small" aria-label="Schließen" onClick={this.handleClose}>
<CloseIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<Box sx={{ px: 1.25, py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" color="text.secondary">
{companyId ? `Company ${companyId}` : 'Keine aktive Company'} · {events.length} Ereignisse
</Typography>
</Box>
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
{events.length > 0 ? (
[...events].reverse().map((entry) => this.renderEvent(entry))
) : (
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
Noch keine SSE-Ereignisse empfangen.
</Typography>
)}
</Box>
</Paper>
)}
</>
);
}
}
export default SseMonitor;