This commit is contained in:
seb
2026-10-19 01:57:00 +02:00
parent 8961d662e5
commit 7499fbc36e
6 changed files with 326 additions and 3 deletions

View File

@@ -0,0 +1,198 @@
import { PureComponent } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Typography from '@mui/material/Typography';
import healthAutomat from '../../automats/healthAutomat';
import { BORDER, PRIMARY, SURFACE_RAISED } from '../../colors';
const W = 800;
const H = 240;
const PAD = { l: 48, r: 12, t: 12, b: 28 };
const fmtMs = (v) => `${v.toFixed(v < 10 ? 2 : 1)} ms`;
function fmtTime(ts, withDate) {
const d = new Date(ts);
const t = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
return withDate ? `${d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })} ${t}` : t;
}
/** Minimal dependency-free SVG line chart. series: [{ name, color, points }] */
function LineChart({ series, withDate }) {
const all = series.flatMap((s) => s.points);
if (!all.length) {
return (
<Typography variant="body2" color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
Keine Daten vorhanden.
</Typography>
);
}
const xs = all.map((p) => p.timestamp);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const maxY = Math.max(...all.map((p) => p.value)) * 1.1 || 1;
const x = (t) => PAD.l + (maxX === minX ? 0.5 : (t - minX) / (maxX - minX)) * (W - PAD.l - PAD.r);
const y = (v) => H - PAD.b - (v / maxY) * (H - PAD.t - PAD.b);
const yTicks = [0, 0.25, 0.5, 0.75, 1].map((f) => f * maxY);
const xTicks = [0, 0.25, 0.5, 0.75, 1].map((f) => minX + f * (maxX - minX));
return (
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }}>
{yTicks.map((v) => (
<g key={v}>
<line x1={PAD.l} x2={W - PAD.r} y1={y(v)} y2={y(v)} stroke={BORDER} strokeWidth="1" />
<text x={PAD.l - 6} y={y(v) + 4} fontSize="11" textAnchor="end" fill="#6b7280">
{v.toFixed(v < 10 ? 1 : 0)}
</text>
</g>
))}
{xTicks.map((t) => (
<text key={t} x={x(t)} y={H - 8} fontSize="11" textAnchor="middle" fill="#6b7280">
{fmtTime(t, withDate)}
</text>
))}
{series.map((s) => (
<polyline
key={s.name}
fill="none"
stroke={s.color}
strokeWidth="1.8"
strokeLinejoin="round"
points={s.points.map((p) => `${x(p.timestamp)},${y(p.value)}`).join(' ')}
/>
))}
</svg>
);
}
function Legend({ series }) {
return (
<Stack direction="row" spacing={2} sx={{ mb: 1, flexWrap: 'wrap' }}>
{series.map((s) => {
const last = s.points[s.points.length - 1];
return (
<Stack key={s.name} direction="row" spacing={0.75} alignItems="center">
<Box sx={{ width: 12, height: 3, bgcolor: s.color, borderRadius: 1 }} />
<Typography variant="caption" color="text.secondary">
{s.name}
{last ? `: ${fmtMs(last.value)}` : ''}
</Typography>
</Stack>
);
})}
</Stack>
);
}
function ChartCard({ title, series, withDate }) {
return (
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, bgcolor: SURFACE_RAISED, p: 2, mb: 2 }}>
<Typography variant="subtitle2" fontWeight={600} sx={{ mb: 1 }}>
{title}
</Typography>
<Legend series={series} />
<LineChart series={series} withDate={withDate} />
</Box>
);
}
export default class HealthSettingsPanel extends PureComponent {
constructor(props) {
super(props);
const { status, error, range, raw, avg, max } = healthAutomat.state;
this.state = { status, error, range, raw, avg, max };
this.polling = false;
this.handleRange = this.handleRange.bind(this);
}
componentDidMount() {
this.unsub = healthAutomat.subscribe(this, ['status', 'error', 'range', 'raw', 'avg', 'max']);
this.syncPolling();
}
componentDidUpdate(prevProps) {
if (prevProps.active !== this.props.active) this.syncPolling();
}
componentWillUnmount() {
this.unsub?.();
if (this.polling) healthAutomat.actions.stopPolling();
this.polling = false;
}
// Poll only while the tab is visible.
syncPolling() {
const active = this.props.active !== false;
if (active && !this.polling) {
this.polling = true;
healthAutomat.actions.startPolling();
} else if (!active && this.polling) {
this.polling = false;
healthAutomat.actions.stopPolling();
}
}
handleRange(_e, value) {
if (value) healthAutomat.actions.setRange(value);
}
render() {
const { status, error, range, raw, avg, max } = this.state;
const isAll = range === 'all';
return (
<Box>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<Typography variant="h6" fontWeight={600}>
Gesundheit · Latenz
</Typography>
<ToggleButtonGroup size="small" exclusive value={range} onChange={this.handleRange}>
<ToggleButton value="3h">Letzte 3 Std.</ToggleButton>
<ToggleButton value="all">Verlauf (30 Tage)</ToggleButton>
</ToggleButtonGroup>
</Stack>
{status === 'failed' && (
<Alert severity="error" sx={{ mb: 2 }}>
{error || 'Latenzdaten konnten nicht geladen werden.'}
</Alert>
)}
{status === 'loading' && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
)}
{status !== 'loading' && (
<>
{!isAll && (
<ChartCard
title="Latenz pro Minute (letzte 3 Stunden)"
series={[{ name: 'Latenz', color: PRIMARY, points: raw }]}
/>
)}
{isAll && (
<>
<ChartCard
title="Rollierender Durchschnitt und Maximum (4 Std.-Fenster)"
withDate
series={[
{ name: 'Durchschnitt', color: PRIMARY, points: avg },
{ name: 'Maximum', color: '#d97706', points: max },
]}
/>
<ChartCard
title="Letzte 3 Stunden"
series={[{ name: 'Latenz', color: PRIMARY, points: raw }]}
/>
</>
)}
</>
)}
</Box>
);
}
}

View File

@@ -21,11 +21,14 @@ import ImportExportSettingsPanel from './ImportExportSettingsPanel';
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
import McpAuthorizationsPanel from './McpAuthorizationsPanel';
import BackupSettingsPanel from './BackupSettingsPanel';
import HealthSettingsPanel from './HealthSettingsPanel';
import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined';
import SecurityIcon from '@mui/icons-material/Security';
import BackupTableOutlinedIcon from '@mui/icons-material/BackupTableOutlined';
import { SURFACE_BASE } from '../../colors';
const TAB_KEYS = {
health: 'health',
company: 'company',
keys: 'keys',
numbers: 'numbers',
@@ -37,6 +40,7 @@ const TAB_KEYS = {
};
const TABS = [
{ id: TAB_KEYS.health, label: 'Gesundheit', icon: <MonitorHeartOutlinedIcon />, Panel: HealthSettingsPanel },
{ id: TAB_KEYS.company, label: 'Unternehmen', icon: <BusinessIcon />, Panel: CompanySettingsPanel },
{ id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel },
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
@@ -50,14 +54,14 @@ const TABS = [
export default class SettingsDialog extends Component {
constructor(props) {
super(props);
this.state = { tab: TAB_KEYS.company };
this.state = { tab: TAB_KEYS.health };
this.handleTabChange = this.handleTabChange.bind(this);
}
componentDidUpdate(prevProps) {
const { open } = this.props;
if (!open && prevProps.open) {
this.setState({ tab: TAB_KEYS.company });
this.setState({ tab: TAB_KEYS.health });
}
}
@@ -181,7 +185,7 @@ export default class SettingsDialog extends Component {
display: t.id === tab ? 'block' : 'none',
}}
>
<t.Panel />
<t.Panel active={t.id === tab} />
</Box>
))}
</Box>

View File

@@ -7,3 +7,4 @@ export { default as PosTerminalSettingsPanel } from './PosTerminalSettingsPanel'
export { default as ImportExportSettingsPanel } from './ImportExportSettingsPanel';
export { default as McpAuthorizationsPanel } from './McpAuthorizationsPanel';
export { default as BackupSettingsPanel } from './BackupSettingsPanel';
export { default as HealthSettingsPanel } from './HealthSettingsPanel';