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 ( + + {yTicks.map((v) => ( + + + + {v.toFixed(v < 10 ? 1 : 0)} + + + ))} + {xTicks.map((t) => ( + + {fmtTime(t, withDate)} + + ))} + {series.map((s) => ( + `${x(p.timestamp)},${y(p.value)}`).join(' ')} + /> + ))} + + ); +} + +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';