Compare commits

...

4 Commits

Author SHA1 Message Date
deployer3000 350f375015 Merge pull request 'rc' (#43) from rc into main 2025-06-03 13:11:48 +03:00
Vladislav Drozdov fa32e75b5a Merge pull request 'redisign' (#42) from redisign into rc
test-org/trust-module-frontend/pipeline/pr-main Build succeeded
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/42
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-06-03 13:09:36 +03:00
DmitriyA 09a6082917 websocket fix
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-06-03 05:58:27 -04:00
DmitriyA cb7c22929a charts update 2025-06-03 05:29:26 -04:00
5 changed files with 377 additions and 102 deletions

View File

@ -1,68 +1,209 @@
import React from 'react'; import React from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
ReferenceArea
} from 'recharts';
const LineChartComponent = ({ // ====== Вспомогательные функции ======
data, const getStatusColor = (status) => {
title, switch(status) {
description, case 0: return '#757575'; // тёмно-серый
metaInfo, case 1: return '#00C853'; // ярко-зелёный
dataKey = 'value', case 2: return '#FF6D00'; // ярко-оранжевый
lineColor = '#8884d8', case 3: return '#D50000'; // ярко-красный
height = 400, default: return '#BDBDBD'; // fallback-серый
showLegend = true, }
showGrid = true, };
customTooltip,
customXAxisFormatter,
customYAxis,
additionalLines = []
}) => {
return (
<div style={{ width: '100%', height: `${height}px` }}>
{title && <h3>{title}</h3>}
{description && (
<p style={{ marginTop: -10, color: '#666' }}>{description}</p>
)}
{metaInfo && (
<div style={{ fontSize: 12, color: '#888', marginBottom: 10 }}>
{metaInfo}
</div>
)}
<ResponsiveContainer width="100%" height="80%"> const getStatusText = (status) => {
<LineChart return {
data={data} 0: 'Нет соединения',
margin={{ 1: 'Норма',
top: 5, 2: 'Отклонение',
right: 30, 3: 'Критично'
left: 20, }[status] || 'Неизвестно';
bottom: 5, };
}}
> const getStatusDescription = (status) => {
{showGrid && <CartesianGrid strokeDasharray="3 3" />} return {
<XAxis 0: 'Устройство не отвечает',
dataKey="timestamp" 1: 'Параметры в норме',
tickFormatter={customXAxisFormatter || ((timestamp) => new Date(timestamp).toLocaleTimeString())} 2: 'Обнаружены отклонения от нормы',
/> 3: 'Критическое состояние системы'
{customYAxis || <YAxis />} }[status] || 'Статус неизвестен';
<Tooltip };
content={customTooltip}
labelFormatter={(timestamp) => new Date(timestamp).toLocaleString()} const StatusIndicator = ({ cx, cy, payload }) => {
/> const status = payload?.status ?? 0;
{showLegend && <Legend />} return (
<Line <circle
type="monotone" cx={cx}
dataKey={dataKey} cy={cy}
stroke={lineColor} r={6}
activeDot={{ r: 8 }} fill={getStatusColor(status)}
name={title} stroke="#fff"
/> strokeWidth={2}
{additionalLines.map((lineProps, index) => ( />
<Line key={index} {...lineProps} /> );
))} };
</LineChart>
</ResponsiveContainer> const CustomTooltip = ({ active, payload, label }) => {
if (!active || !payload || !payload.length) return null;
const status = payload[0].payload.status;
const statusColor = getStatusColor(status);
return (
<div style={{
background: '#fff',
padding: '10px',
border: '1px solid #ccc',
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}>
<p><strong>{new Date(label).toLocaleString()}</strong></p>
<p style={{ color: payload[0].color }}>
Значение: <strong>{payload[0].value.toFixed(2)}</strong>
</p>
<div style={{
display: 'flex',
alignItems: 'center',
padding: '4px 8px',
background: `${statusColor}20`,
borderLeft: `4px solid ${statusColor}`,
borderRadius: '4px'
}}>
<span style={{
width: 12,
height: 12,
backgroundColor: statusColor,
borderRadius: '50%',
marginRight: 8
}} />
<div>
<strong>{getStatusText(status)}</strong>
<div style={{ fontSize: '0.8em', color: '#666' }}>
{getStatusDescription(status)}
</div>
</div> </div>
); </div>
</div>
);
};
// ====== Основной компонент ======
const LineChartComponent = ({
data,
title,
description,
metaInfo,
dataKey = 'value',
height = 400
}) => {
const getStatusAreas = () => {
if (!data || data.length === 0) return null;
const areas = [];
let currentStatus = data[0].status;
let start = data[0].timestamp;
for (let i = 1; i < data.length; i++) {
const current = data[i];
if (current.status !== currentStatus) {
areas.push({ status: currentStatus, start, end: current.timestamp });
currentStatus = current.status;
start = current.timestamp;
}
}
areas.push({ status: currentStatus, start, end: data[data.length - 1].timestamp });
return areas.map((area, i) => (
<ReferenceArea
key={`area-${i}`}
x1={area.start}
x2={area.end}
fill={getStatusColor(area.status)}
fillOpacity={0.1}
/>
));
};
return (
<div style={{ width: '100%', height: `${height}px` }}>
{title && <h3>{title}</h3>}
{description && (
<p style={{ marginTop: -10, color: '#666' }}>{description}</p>
)}
{metaInfo && (
<div style={{ fontSize: 12, color: '#888', marginBottom: 10 }}>
{metaInfo}
</div>
)}
<ResponsiveContainer width="100%" height="80%">
<LineChart
data={data}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
tickFormatter={(ts) => new Date(ts).toLocaleTimeString()}
/>
<YAxis />
{getStatusAreas()}
<Tooltip content={<CustomTooltip />} />
<Legend />
<Line
type="monotone"
dataKey={dataKey}
stroke="#8884d8"
strokeWidth={2}
dot={<StatusIndicator />}
activeDot={{ r: 8 }}
isAnimationActive={false}
name={title}
/>
</LineChart>
</ResponsiveContainer>
{/* Легенда статусов */}
<div style={{
marginTop: 20,
display: 'flex',
justifyContent: 'center',
gap: 20,
flexWrap: 'wrap'
}}>
{[
{ status: 1, label: '1 - Норма', color: '#4CAF50' },
{ status: 2, label: '2 - Отклонение', color: '#FF9800' },
{ status: 3, label: '3 - Критично', color: '#F44336' },
{ status: 0, label: '0 - Нет связи', color: '#888' }
].map(item => (
<div key={item.status} style={{ display: 'flex', alignItems: 'center' }}>
<div style={{
width: 16,
height: 16,
backgroundColor: item.color,
marginRight: 8,
borderRadius: '50%'
}}></div>
<span>{item.label}</span>
</div>
))}
</div>
</div>
);
}; };
export default LineChartComponent; export default LineChartComponent;

View File

@ -0,0 +1,86 @@
// src/Components/StatusLogTable.jsx
import React from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
Typography
} from '@mui/material';
const statusColors = {
'0': 'default',
'1': 'success',
'2': 'warning',
'3': 'error'
};
const StatusLogTable = ({ logs }) => {
return (
<TableContainer component={Paper} sx={{ mt: 2, maxHeight: 400 }}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell>Время</TableCell>
<TableCell>Устройство</TableCell>
<TableCell>Модуль</TableCell>
<TableCell>Статус</TableCell>
<TableCell>Значение</TableCell>
<TableCell>Описание</TableCell>
</TableRow>
</TableHead>
<TableBody>
{logs.map((log, index) => (
<TableRow key={index}>
<TableCell>
{new Date(log.timestamp).toLocaleString()}
</TableCell>
<TableCell>{log.device}</TableCell>
<TableCell>{log.source_id?.split('$')[1]}</TableCell>
<TableCell>
<Chip
label={getStatusText(log.status)}
color={statusColors[log.status] || 'default'}
size="small"
/>
</TableCell>
<TableCell>{parseFloat(log.value).toFixed(2)}</TableCell>
<TableCell>
<Typography variant="body2">
{log.description || getStatusDescription(log.status)}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
};
// Вспомогательные функции
const getStatusText = (status) => {
const statusMap = {
'0': 'Нет соединения',
'1': 'Норма',
'2': 'Отклонение',
'3': 'Критично'
};
return statusMap[status] || 'Неизвестно';
};
const getStatusDescription = (status) => {
const descriptions = {
'0': 'Устройство не отвечает',
'1': 'Параметры в норме',
'2': 'Обнаружены отклонения от нормы',
'3': 'Критическое состояние системы'
};
return descriptions[status] || 'Статус неизвестен';
};
export default StatusLogTable;

View File

@ -6,6 +6,8 @@ class MetricsService {
this.socket = null; this.socket = null;
this.subscriptions = new Map(); this.subscriptions = new Map();
this.pendingRequests = new Map(); this.pendingRequests = new Map();
window.addEventListener('beforeunload', this.cleanupAll.bind(this));
window.addEventListener('pagehide', this.cleanupAll.bind(this));
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
this.cleanupAll(); this.cleanupAll();
@ -40,6 +42,7 @@ class MetricsService {
}); });
this.socket.on('metrics-data', ({ metric, data, requestId }) => { this.socket.on('metrics-data', ({ metric, data, requestId }) => {
console.log('Incoming metric update:', metric);
if (requestId && this.pendingRequests.has(requestId)) { if (requestId && this.pendingRequests.has(requestId)) {
const { resolve } = this.pendingRequests.get(requestId); const { resolve } = this.pendingRequests.get(requestId);
resolve(data); resolve(data);
@ -89,13 +92,8 @@ class MetricsService {
subscribeToMetric(metricKey, callback, interval = 5000, filters = {}) { subscribeToMetric(metricKey, callback, interval = 5000, filters = {}) {
this.connectWebSocket(); this.connectWebSocket();
const alreadySubscribed = this.subscriptions.has(metricKey); if (!this.subscriptions.has(metricKey)) {
const callbacks = this.subscriptions.get(metricKey) || []; this.subscriptions.set(metricKey, []);
callbacks.push(callback);
this.subscriptions.set(metricKey, callbacks);
if (!alreadySubscribed) {
// Разделяем metricKey на метрику и фильтры
const [metric] = metricKey.split('?'); const [metric] = metricKey.split('?');
this.socket.emit('subscribe-metric', { this.socket.emit('subscribe-metric', {
metric, metric,
@ -104,7 +102,12 @@ class MetricsService {
}); });
} }
return () => this.unsubscribeFromMetric(metricKey, callback); const callbacks = this.subscriptions.get(metricKey);
callbacks.push(callback);
return () => {
this.unsubscribeFromMetric(metricKey, callback);
};
} }
unsubscribeFromMetric(metricKey, callback) { unsubscribeFromMetric(metricKey, callback) {

View File

@ -4,6 +4,9 @@ import DateRangeSelector from './Components/DateRangeSelector';
import metricsService from './Components/metricsService'; import metricsService from './Components/metricsService';
import { Button, Radio, message, Tag } from 'antd'; import { Button, Radio, message, Tag } from 'antd';
import moment from 'moment'; import moment from 'moment';
import StatusLogTable from './Components/StatusLogTable';
import { Box, IconButton, Tooltip as MuiTooltip } from '@mui/material';
import { ListAlt } from '@mui/icons-material';
const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => { const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => {
const { const {
@ -14,7 +17,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => {
context = {} context = {}
} = metricInfo || {}; } = metricInfo || {};
const { device, source_id: module } = context; const { device, source_id } = context;
const [chartData, setChartData] = useState([]); const [chartData, setChartData] = useState([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -24,27 +27,45 @@ const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => {
const [startDate, setStartDate] = useState(moment().subtract(1, 'hour').toDate()); const [startDate, setStartDate] = useState(moment().subtract(1, 'hour').toDate());
const [endDate, setEndDate] = useState(moment().toDate()); const [endDate, setEndDate] = useState(moment().toDate());
const [isLiveUpdating, setIsLiveUpdating] = useState(false); const [isLiveUpdating, setIsLiveUpdating] = useState(false);
const [showLogs, setShowLogs] = useState(false);
const [statusLogs, setStatusLogs] = useState([]);
const getSubscriptionKey = () => { const getSubscriptionKey = () => {
const filterParts = []; const filterParts = [];
if (device) filterParts.push(`device=${device}`); if (device) filterParts.push(`device=${encodeURIComponent(device)}`);
if (module) filterParts.push(`source_id=${module}`); if (source_id) filterParts.push(`source_id=${encodeURIComponent(source_id)}`);
return `${metricName}${filterParts.length ? `?${filterParts.join('&')}` : ''}`; return `${metricName}${filterParts.length ? `?${filterParts.join('&')}` : ''}`;
}; };
const formatMetricData = (dataArray) => { const formatMetricData = (dataArray) => {
return dataArray return dataArray
.map(item => ({ .map(item => ({
...item,
timestamp: item.timestamp, timestamp: item.timestamp,
value: parseFloat(item.value), value: parseFloat(item.value),
name: item.__name__ || metricName, name: item.__name__ || metricName,
status: item.status, status: item.status?.toString() || '0',
device: item.device?.trim() || null, device: item.device?.trim() || null,
source_id: item.source_id || null source_id: item.source_id || null,
description: item.description || description
})) }))
.sort((a, b) => a.timestamp - b.timestamp); .sort((a, b) => a.timestamp - b.timestamp);
}; };
// Обновляем логи при изменении данных
useEffect(() => {
if (chartData.length > 0) {
const newLogs = chartData.reduce((acc, point, index) => {
if (index === 0 || point.status !== chartData[index - 1].status) {
return [...acc, point];
}
return acc;
}, []);
setStatusLogs(newLogs);
}
}, [chartData]);
const fetchHistoricalData = async (start, end) => { const fetchHistoricalData = async (start, end) => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
@ -53,7 +74,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => {
const extendedFilters = { const extendedFilters = {
...filters, ...filters,
...(device && { device: device.toString() }), ...(device && { device: device.toString() }),
...(module && { source_id: module.toString() }) ...(source_id && { source_id: source_id.toString() })
}; };
const data = await metricsService.fetchMetricsRange( const data = await metricsService.fetchMetricsRange(
@ -107,7 +128,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => {
{ {
...filters, ...filters,
...(device && { device }), ...(device && { device }),
...(module && { source_id: module }) ...(source_id && { source_id })
} }
); );
}; };
@ -124,6 +145,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => {
}; };
useEffect(() => { useEffect(() => {
console.log('Current metric context:', { device, source_id, metricName });
let unsubscribe; let unsubscribe;
if (mode === 'realtime') { if (mode === 'realtime') {
unsubscribe = startRealtimeUpdates(); unsubscribe = startRealtimeUpdates();
@ -136,7 +158,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => {
if (unsubscribe) unsubscribe(); if (unsubscribe) unsubscribe();
stopRealtimeUpdates(); stopRealtimeUpdates();
}; };
}, [mode, metricName, device, module]); }, [mode, metricName, device, source_id]);
const metaInfo = [ const metaInfo = [
metricMeta.instance && `Instance: ${metricMeta.instance}`, metricMeta.instance && `Instance: ${metricMeta.instance}`,
@ -180,27 +202,50 @@ const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => {
</div> </div>
{device && <Tag color="geekblue">Устройство: {device}</Tag>} {device && <Tag color="geekblue">Устройство: {device}</Tag>}
{module && <Tag color="purple">Модуль: {module.split('$')[1]}</Tag>} {source_id && <Tag color="purple">Модуль: {source_id.split('$')[1]}</Tag>}
{isLoading ? ( <Box position="relative">
<div>Загрузка графика...</div> <MuiTooltip title={showLogs ? "Скрыть логи" : "Показать логи"}>
) : error ? ( <IconButton
<div>Ошибка: {error}</div> onClick={() => setShowLogs(!showLogs)}
) : chartData.length === 0 ? ( sx={{
<div>Нет данных для метрики: {metricName}</div> position: 'absolute',
) : ( right: 16,
<LineChartComponent top: 16,
data={chartData} zIndex: 1000,
title={title} bgcolor: 'background.paper',
description={description} boxShadow: 1
metaInfo={metaInfo} }}
height={chartHeight} >
additionalFilters={{ <ListAlt />
device, </IconButton>
module </MuiTooltip>
}}
/> {isLoading ? (
)} <div>Загрузка графика...</div>
) : error ? (
<div>Ошибка: {error}</div>
) : chartData.length === 0 ? (
<div>Нет данных для метрики: {metricName}</div>
) : (
<>
<LineChartComponent
data={chartData}
title={title}
description={description}
metaInfo={metaInfo}
height={chartHeight}
additionalFilters={{
device,
source_id
}}
/>
{showLogs && (
<StatusLogTable logs={statusLogs} />
)}
</>
)}
</Box>
</div> </div>
); );
}; };

View File

@ -95,7 +95,7 @@ const Dashboard = ({ isDarkMode, setIsDarkMode }) => {
context: { context: {
device: item.filters?.device, device: item.filters?.device,
source_id: item.filters?.source_id, source_id: item.filters?.source_id,
parent: item // для построения пути parent: item
} }
}} }}
/> />