de379e28bf
Общий AreaTrendChart для мониторинга и daily-графиков, StatisticSparklineCard на дашборде. Co-authored-by: Cursor <cursoragent@cursor.com>
222 lines
9.4 KiB
TypeScript
222 lines
9.4 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import { Card, Col, Row, Select, Table, Tabs } from 'antd';
|
||
import dayjs from 'dayjs';
|
||
import { useMetricsStore, NodeMetric } from '../../store/metricsStore';
|
||
import { monitoringApi } from '../../api/monitoringApi';
|
||
import AreaTrendChart from '../../components/AreaTrendChart';
|
||
|
||
const NODE_COLORS = ['#1677ff', '#52c41a', '#fa8c16', '#eb2f96', '#722ed1', '#13c2c2'];
|
||
const TIME_RANGES = [
|
||
{ label: '5 мин', value: 5 },
|
||
{ label: '15 мин', value: 15 },
|
||
{ label: '30 мин', value: 30 },
|
||
{ label: '1 час', value: 60 },
|
||
];
|
||
|
||
const bytesToMb = (value?: number) => (value ?? 0) / 1048576;
|
||
|
||
const formatTime = (ts: string) => dayjs(ts).format('HH:mm:ss');
|
||
|
||
const buildChartData = (metrics: NodeMetric[]) =>
|
||
metrics.map((m) => ({
|
||
time: formatTime(m.timestamp),
|
||
cpu: m.cpu_utilization ?? 0,
|
||
memoryMb: bytesToMb(m.memory_total),
|
||
memoryProcessesMb: bytesToMb(m.memory_processes),
|
||
memoryAtomMb: bytesToMb(m.memory_atom),
|
||
memoryBinaryMb: bytesToMb(m.memory_binary),
|
||
memoryEtsMb: bytesToMb(m.memory_ets),
|
||
memoryAvailableMb: bytesToMb(m.memory_available),
|
||
ws: m.ws_connections ?? 0,
|
||
runQueue: m.run_queue ?? 0,
|
||
processCount: m.process_count ?? 0,
|
||
activeSessions: m.active_sessions ?? 0,
|
||
ioIn: m.io_in ?? 0,
|
||
ioOut: m.io_out ?? 0,
|
||
mnesiaCommits: m.mnesia_commits ?? 0,
|
||
mnesiaFailures: m.mnesia_failures ?? 0,
|
||
tableSizes: m.table_sizes ?? {},
|
||
}));
|
||
|
||
const MetricChart: React.FC<{
|
||
data: ReturnType<typeof buildChartData>;
|
||
lines: Array<{ key: string; color: string; name: string }>;
|
||
}> = ({ data, lines }) => (
|
||
<AreaTrendChart data={data} xKey="time" series={lines} />
|
||
);
|
||
|
||
const MonitoringPage: React.FC = () => {
|
||
const allHistory = useMetricsStore((s) => s.allHistory);
|
||
const current = useMetricsStore((s) => s.current);
|
||
const setAllHistory = useMetricsStore((s) => s.setAllHistory);
|
||
const filterVisibleHistory = useMetricsStore((s) => s.filterVisibleHistory);
|
||
const visibleHistory = useMetricsStore((s) => s.visibleHistory);
|
||
const [minutes, setMinutes] = useState(15);
|
||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
const loadHistory = async () => {
|
||
setLoadingHistory(true);
|
||
try {
|
||
const to = dayjs().toISOString();
|
||
const from = dayjs().subtract(minutes, 'minute').toISOString();
|
||
const metrics = await monitoringApi.getNodeMetrics(from, to);
|
||
if (!cancelled && metrics.length > 0) {
|
||
setAllHistory(metrics);
|
||
}
|
||
} catch {
|
||
// live WS дополняет историю; REST недоступен — не блокируем страницу
|
||
} finally {
|
||
if (!cancelled) {
|
||
setLoadingHistory(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
loadHistory();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [minutes, setAllHistory]);
|
||
|
||
useEffect(() => {
|
||
filterVisibleHistory(minutes);
|
||
}, [minutes, allHistory.length, filterVisibleHistory]);
|
||
|
||
const allNodes = useMemo(() => {
|
||
const nodes = new Set<string>();
|
||
[...allHistory, ...(current ? [current] : [])].forEach((m) => nodes.add(m.node));
|
||
return Array.from(nodes).sort();
|
||
}, [allHistory, current]);
|
||
|
||
const metricsByNode = useMemo(() => {
|
||
const source = visibleHistory.length > 0 ? visibleHistory : allHistory;
|
||
const map = new Map<string, NodeMetric[]>();
|
||
source.forEach((m) => {
|
||
const list = map.get(m.node) || [];
|
||
list.push(m);
|
||
map.set(m.node, list);
|
||
});
|
||
return map;
|
||
}, [visibleHistory, allHistory]);
|
||
|
||
const tabItems = allNodes.map((node, idx) => {
|
||
const nodeMetrics = metricsByNode.get(node) || [];
|
||
const chartData = buildChartData(nodeMetrics);
|
||
const latestTableSizes = chartData.at(-1)?.tableSizes ?? {};
|
||
const color = NODE_COLORS[idx % NODE_COLORS.length];
|
||
|
||
return {
|
||
key: node,
|
||
label: <span style={{ color }}>{node}</span>,
|
||
children: (
|
||
<Row gutter={[16, 16]}>
|
||
<Col span={24}>
|
||
<Card title="CPU и память (МБ)">
|
||
<MetricChart
|
||
data={chartData}
|
||
lines={[
|
||
{ key: 'cpu', color, name: 'CPU %' },
|
||
{ key: 'memoryMb', color: '#1890ff', name: 'Память всего' },
|
||
{ key: 'memoryAvailableMb', color: '#13c2c2', name: 'Память доступно' },
|
||
]}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col span={24}>
|
||
<Card title="Память по типам (МБ)">
|
||
<MetricChart
|
||
data={chartData}
|
||
lines={[
|
||
{ key: 'memoryProcessesMb', color: '#1677ff', name: 'Processes' },
|
||
{ key: 'memoryAtomMb', color: '#52c41a', name: 'Atom' },
|
||
{ key: 'memoryBinaryMb', color: '#fa8c16', name: 'Binary' },
|
||
{ key: 'memoryEtsMb', color: '#eb2f96', name: 'ETS' },
|
||
]}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col xs={24} lg={12}>
|
||
<Card title="WebSocket и очередь">
|
||
<MetricChart
|
||
data={chartData}
|
||
lines={[
|
||
{ key: 'ws', color: '#52c41a', name: 'WS' },
|
||
{ key: 'runQueue', color: '#fa8c16', name: 'Run queue' },
|
||
{ key: 'processCount', color: '#722ed1', name: 'Processes' },
|
||
{ key: 'activeSessions', color: '#1677ff', name: 'Sessions' },
|
||
]}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col xs={24} lg={12}>
|
||
<Card title="Mnesia">
|
||
<MetricChart
|
||
data={chartData}
|
||
lines={[
|
||
{ key: 'mnesiaCommits', color: '#722ed1', name: 'Commits' },
|
||
{ key: 'mnesiaFailures', color: '#ff4d4f', name: 'Failures' },
|
||
]}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col span={24}>
|
||
<Card title="I/O">
|
||
<MetricChart
|
||
data={chartData}
|
||
lines={[
|
||
{ key: 'ioIn', color: '#1677ff', name: 'IO in' },
|
||
{ key: 'ioOut', color: '#52c41a', name: 'IO out' },
|
||
]}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col span={24}>
|
||
<Card title="Размеры таблиц Mnesia (последняя точка)">
|
||
<Table
|
||
size="small"
|
||
pagination={false}
|
||
rowKey="table"
|
||
dataSource={Object.entries(latestTableSizes).map(([table, size]) => ({
|
||
table,
|
||
size,
|
||
}))}
|
||
columns={[
|
||
{ title: 'Таблица', dataIndex: 'table', key: 'table' },
|
||
{ title: 'Записей', dataIndex: 'size', key: 'size' },
|
||
]}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
),
|
||
};
|
||
});
|
||
|
||
return (
|
||
<div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||
<h2 style={{ margin: 0 }}>Мониторинг нод</h2>
|
||
<Select
|
||
value={minutes}
|
||
onChange={setMinutes}
|
||
options={TIME_RANGES}
|
||
style={{ width: 120 }}
|
||
/>
|
||
</div>
|
||
|
||
{allNodes.length === 0 ? (
|
||
<Card loading={loadingHistory}>
|
||
{loadingHistory ? 'Загрузка метрик…' : 'Ожидание метрик по WebSocket…'}
|
||
</Card>
|
||
) : (
|
||
<Tabs items={tabItems} />
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default MonitoringPage;
|