Compare commits
2 Commits
c647068067
...
7499fbc36e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7499fbc36e | ||
|
|
8961d662e5 |
@@ -131,7 +131,7 @@ Strip supplierName?: str from T11 (resolve via entityId in cached supplier list)
|
||||
|
||||
# Dash API Routes Overview (All)
|
||||
|
||||
Overview of all 263 API endpoints across all modules.
|
||||
Overview of all 264 API endpoints across all modules.
|
||||
|
||||
### Notation
|
||||
|
||||
@@ -629,6 +629,9 @@ _none_
|
||||
#### Database health check — `GET /health`
|
||||
_none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### Health latency history — `GET /health/latency`
|
||||
- Params: `query: range?: str`
|
||||
- Returns: `200 { raw: { timestamp: int, value: num }[], avg: { timestamp: int, value: num }[], max: { timestamp: int, value: num }[] }`
|
||||
|
||||
### Section: /image
|
||||
|
||||
|
||||
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 { backup } from './backup.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 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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user