u
This commit is contained in:
19
src/api/health.js
Normal file
19
src/api/health.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
@@ -33,3 +33,4 @@ export { account } from './account.js';
|
|||||||
export { mcp } from './mcp.js';
|
export { mcp } from './mcp.js';
|
||||||
export { backup } from './backup.js';
|
export { backup } from './backup.js';
|
||||||
export { carrier } from './carrier.js';
|
export { carrier } from './carrier.js';
|
||||||
|
export { health } from './health.js';
|
||||||
|
|||||||
100
src/automats/healthAutomat.js
Normal file
100
src/automats/healthAutomat.js
Normal file
@@ -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;
|
||||||
198
src/components/settings/HealthSettingsPanel.jsx
Normal file
198
src/components/settings/HealthSettingsPanel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,11 +21,14 @@ import ImportExportSettingsPanel from './ImportExportSettingsPanel';
|
|||||||
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
|
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
|
||||||
import McpAuthorizationsPanel from './McpAuthorizationsPanel';
|
import McpAuthorizationsPanel from './McpAuthorizationsPanel';
|
||||||
import BackupSettingsPanel from './BackupSettingsPanel';
|
import BackupSettingsPanel from './BackupSettingsPanel';
|
||||||
|
import HealthSettingsPanel from './HealthSettingsPanel';
|
||||||
|
import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined';
|
||||||
import SecurityIcon from '@mui/icons-material/Security';
|
import SecurityIcon from '@mui/icons-material/Security';
|
||||||
import BackupTableOutlinedIcon from '@mui/icons-material/BackupTableOutlined';
|
import BackupTableOutlinedIcon from '@mui/icons-material/BackupTableOutlined';
|
||||||
import { SURFACE_BASE } from '../../colors';
|
import { SURFACE_BASE } from '../../colors';
|
||||||
|
|
||||||
const TAB_KEYS = {
|
const TAB_KEYS = {
|
||||||
|
health: 'health',
|
||||||
company: 'company',
|
company: 'company',
|
||||||
keys: 'keys',
|
keys: 'keys',
|
||||||
numbers: 'numbers',
|
numbers: 'numbers',
|
||||||
@@ -37,6 +40,7 @@ const TAB_KEYS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TABS = [
|
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.company, label: 'Unternehmen', icon: <BusinessIcon />, Panel: CompanySettingsPanel },
|
||||||
{ id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel },
|
{ id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel },
|
||||||
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
|
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
|
||||||
@@ -50,14 +54,14 @@ const TABS = [
|
|||||||
export default class SettingsDialog extends Component {
|
export default class SettingsDialog extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { tab: TAB_KEYS.company };
|
this.state = { tab: TAB_KEYS.health };
|
||||||
this.handleTabChange = this.handleTabChange.bind(this);
|
this.handleTabChange = this.handleTabChange.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
const { open } = this.props;
|
const { open } = this.props;
|
||||||
if (!open && prevProps.open) {
|
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',
|
display: t.id === tab ? 'block' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<t.Panel />
|
<t.Panel active={t.id === tab} />
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export { default as PosTerminalSettingsPanel } from './PosTerminalSettingsPanel'
|
|||||||
export { default as ImportExportSettingsPanel } from './ImportExportSettingsPanel';
|
export { default as ImportExportSettingsPanel } from './ImportExportSettingsPanel';
|
||||||
export { default as McpAuthorizationsPanel } from './McpAuthorizationsPanel';
|
export { default as McpAuthorizationsPanel } from './McpAuthorizationsPanel';
|
||||||
export { default as BackupSettingsPanel } from './BackupSettingsPanel';
|
export { default as BackupSettingsPanel } from './BackupSettingsPanel';
|
||||||
|
export { default as HealthSettingsPanel } from './HealthSettingsPanel';
|
||||||
|
|||||||
Reference in New Issue
Block a user