diff --git a/src/api/health.js b/src/api/health.js
new file mode 100644
index 0000000..564ef26
--- /dev/null
+++ b/src/api/health.js
@@ -0,0 +1,19 @@
+import { http } from './http.js';
+
+/**
+ * Health API for backend latency samples.
+ */
+export const health = {
+ /**
+ * GET /api/health/latency?range=3h|all
+ * Returns: 200 { raw: Point[] } for '3h', { raw, avg, max } for 'all'.
+ * Point = { timestamp: epoch ms, value: latency ms }, sorted oldest first.
+ * @param {'3h'|'all'} [range='3h']
+ */
+ // #region GET /api/health/latency — getLatency
+ // MARK: calls GET /api/health/latency from ./api/index.js
+ getLatency(range = '3h') {
+ return http(`/api/health/latency?range=${encodeURIComponent(range)}`);
+ },
+ // #endregion
+};
diff --git a/src/api/index.js b/src/api/index.js
index e7a194c..02d843e 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -33,3 +33,4 @@ export { account } from './account.js';
export { mcp } from './mcp.js';
export { backup } from './backup.js';
export { carrier } from './carrier.js';
+export { health } from './health.js';
diff --git a/src/automats/healthAutomat.js b/src/automats/healthAutomat.js
new file mode 100644
index 0000000..8392134
--- /dev/null
+++ b/src/automats/healthAutomat.js
@@ -0,0 +1,100 @@
+/**
+ * @file healthAutomat.js
+ * @description Backend latency samples for the settings "Gesundheit" tab
+ * (`GET /api/health/latency`). Not company-scoped; only polls while a view
+ * has called `actions.startPolling()`.
+ */
+
+import { Automat } from 'react-automat';
+import { health as healthApi } from '../api';
+
+const POLL_MS = 60_000;
+
+/**
+ * @typedef {{ timestamp: number, value: number }} LatencyPoint
+ * @typedef {Object} HealthState
+ * @property {'idle'|'loading'|'ready'|'failed'} status
+ * @property {string|null} error
+ * @property {'3h'|'all'} range
+ * @property {LatencyPoint[]} raw Per-minute samples (last 3h)
+ * @property {LatencyPoint[]} avg 30-min rolling 4h average (range=all only)
+ * @property {LatencyPoint[]} max 30-min rolling 4h max (range=all only)
+ */
+
+/** @type {HealthState} */
+const initialState = {
+ status: 'idle',
+ error: null,
+ range: '3h',
+ raw: [],
+ avg: [],
+ max: [],
+};
+
+let healthAutomat;
+let inflight = null;
+let timer = null;
+let pollers = 0;
+
+const actions = {
+ // #region load
+ // MARK: calls GET /api/health/latency from ./api/index.js
+ /**
+ * Loads latency data for the current range. Deduplicates concurrent calls.
+ * @param {boolean} [silent=false] Keep status 'ready' while refreshing
+ */
+ load(silent = false) {
+ if (inflight) return inflight;
+ const { range, status } = healthAutomat.state;
+ if (!silent && status !== 'ready') healthAutomat.setState({ status: 'loading', error: null });
+ inflight = (async () => {
+ try {
+ const data = await healthApi.getLatency(range);
+ // Range may have changed while the request was in flight.
+ if (healthAutomat.state.range !== range) return;
+ healthAutomat.setState({
+ status: 'ready',
+ error: null,
+ raw: data?.raw ?? [],
+ avg: data?.avg ?? [],
+ max: data?.max ?? [],
+ });
+ } catch (err) {
+ healthAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
+ } finally {
+ inflight = null;
+ }
+ })();
+ return inflight;
+ },
+ // #endregion
+
+ /** Switches between the live 3h view and the long-term view. */
+ setRange(range) {
+ if (range === healthAutomat.state.range) return;
+ healthAutomat.setState({ range, status: 'loading', error: null });
+ void healthAutomat.actions.load();
+ },
+
+ // #region polling
+ /** Starts the once-a-minute refresh (ref-counted). */
+ startPolling() {
+ pollers += 1;
+ if (pollers > 1) return;
+ void actions.load(healthAutomat.state.status === 'ready');
+ timer = setInterval(() => void actions.load(true), POLL_MS);
+ },
+
+ stopPolling() {
+ pollers = Math.max(0, pollers - 1);
+ if (pollers === 0 && timer) {
+ clearInterval(timer);
+ timer = null;
+ }
+ },
+ // #endregion
+};
+
+healthAutomat = new Automat(initialState, actions, { name: 'health' });
+
+export default healthAutomat;
diff --git a/src/components/settings/HealthSettingsPanel.jsx b/src/components/settings/HealthSettingsPanel.jsx
new file mode 100644
index 0000000..8c04549
--- /dev/null
+++ b/src/components/settings/HealthSettingsPanel.jsx
@@ -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 (
+
+ Keine Daten vorhanden.
+
+ );
+ }
+ 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 (
+
+ );
+}
+
+function Legend({ series }) {
+ return (
+
+ {series.map((s) => {
+ const last = s.points[s.points.length - 1];
+ return (
+
+
+
+ {s.name}
+ {last ? `: ${fmtMs(last.value)}` : ''}
+
+
+ );
+ })}
+
+ );
+}
+
+function ChartCard({ title, series, withDate }) {
+ return (
+
+
+ {title}
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+ Gesundheit · Latenz
+
+
+ Letzte 3 Std.
+ Verlauf (30 Tage)
+
+
+
+ {status === 'failed' && (
+
+ {error || 'Latenzdaten konnten nicht geladen werden.'}
+
+ )}
+ {status === 'loading' && (
+
+
+
+ )}
+
+ {status !== 'loading' && (
+ <>
+ {!isAll && (
+
+ )}
+ {isAll && (
+ <>
+
+
+ >
+ )}
+ >
+ )}
+
+ );
+ }
+}
diff --git a/src/components/settings/SettingsDialog.jsx b/src/components/settings/SettingsDialog.jsx
index dc0bc92..71c852f 100644
--- a/src/components/settings/SettingsDialog.jsx
+++ b/src/components/settings/SettingsDialog.jsx
@@ -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: , Panel: HealthSettingsPanel },
{ id: TAB_KEYS.company, label: 'Unternehmen', icon: , Panel: CompanySettingsPanel },
{ id: TAB_KEYS.keys, label: 'Einstellungen', icon: , Panel: SettingsKeysPanel },
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: , 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',
}}
>
-
+
))}
diff --git a/src/components/settings/index.js b/src/components/settings/index.js
index 5656049..94cfe1f 100644
--- a/src/components/settings/index.js
+++ b/src/components/settings/index.js
@@ -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';