Compare commits

...

2 Commits

Author SHA1 Message Date
seb
7499fbc36e u 2026-10-19 01:57:00 +02:00
seb
8961d662e5 u 2026-10-19 01:54:07 +02:00
7 changed files with 330 additions and 4 deletions

View File

@@ -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
View 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
};

View File

@@ -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';

View 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;

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';