Compare commits
No commits in common. "d7c40ee04b7019162b86c72f806c70ceb02e7508" and "cb030a01d2a3c4c077482ff22a7fd4418ccc5814" have entirely different histories.
d7c40ee04b
...
cb030a01d2
|
|
@ -2,10 +2,10 @@ FROM node:22.13.0
|
|||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install --verbose
|
||||
COPY package.json package-lock.json vite.config.js eslint.config.js ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY vite.config.js eslint.config.js ./
|
||||
COPY . .
|
||||
|
||||
ENTRYPOINT ["npm", "run", "dev"]
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import React from 'react';
|
||||
import { BarChart, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Bar, ResponsiveContainer } from 'recharts';
|
||||
|
||||
const BarChartComponent = ({ chartData, metricName, metricType, colors }) => {
|
||||
// Преобразуем данные для отображения
|
||||
const data = Object.keys(chartData).map(instance => {
|
||||
const instanceData = chartData[instance].reduce((acc, point) => {
|
||||
if (point.value !== null) {
|
||||
acc[point.quantile] = point.value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
return { instance, ...instanceData };
|
||||
});
|
||||
|
||||
// Получаем все уникальные квантили
|
||||
const allQuantiles = [...new Set(
|
||||
Object.values(chartData).flat().map(point => point.quantile)
|
||||
)];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>{metricName} ({metricType})</h2>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="instance" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{allQuantiles.map((quantile, index) => (
|
||||
<Bar
|
||||
key={quantile}
|
||||
dataKey={quantile}
|
||||
fill={colors[index % colors.length]}
|
||||
name={`Quantile ${quantile}`}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BarChartComponent;
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
const ChartSkeleton = () => (
|
||||
<Box sx={{
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
marginBottom: '20px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<Box sx={{ position: 'absolute', right: '20px', top: '20px' }}>
|
||||
<Skeleton variant="circular" width={16} height={16} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Skeleton variant="text" width="40%" height={30} />
|
||||
<Skeleton variant="text" width="30%" height={30} />
|
||||
</Box>
|
||||
|
||||
<Skeleton variant="rectangular" width="100%" height={300} />
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2, gap: 2 }}>
|
||||
{[1, 2, 3, 4].map((_, i) => (
|
||||
<Skeleton key={i} variant="rounded" width={80} height={36} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
|
||||
export const ConnectionStatusIndicator = ({ connectionStatus }) => {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
padding: '5px 10px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: connectionStatus === 'connected' ? '#4CAF50' :
|
||||
connectionStatus === 'error' ? '#F44336' : '#FFC107',
|
||||
color: 'white',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
{connectionStatus === 'connected' ? 'Online' :
|
||||
connectionStatus === 'error' ? 'Connection Error' : 'Offline'}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
|
||||
const CounterComponent = ({ value, metricName }) => {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '20px', border: '1px solid #ccc', borderRadius: '8px', margin: '10px' }}>
|
||||
<h2>{metricName}</h2>
|
||||
<p style={{ fontSize: '48px', fontWeight: 'bold', color: '#3e95cd' }}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CounterComponent;
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
|
||||
export const CurrentRangeDisplay = ({ useCustomRange, startDate, endDate, selectedRange }) => {
|
||||
return (
|
||||
<div style={{
|
||||
margin: '10px 0',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#f0f7ff',
|
||||
borderRadius: '4px',
|
||||
borderLeft: '3px solid #4a6baf'
|
||||
}}>
|
||||
Текущий диапазон: {useCustomRange
|
||||
? `${startDate.toLocaleString()} - ${endDate.toLocaleString()}`
|
||||
: selectedRange.label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Line, ResponsiveContainer, ReferenceArea } from 'recharts';
|
||||
import { Skeleton } from '@mui/material';
|
||||
import { HOUR, DAY } from './constants';
|
||||
|
||||
const TIME_FORMATS = {
|
||||
LONG: 'dd.MM HH:mm', // Для диапазона > 24 часов
|
||||
MEDIUM: 'HH:mm', // Для диапазона > 1 часа
|
||||
SHORT: 'HH:mm:ss' // Для коротких диапазонов
|
||||
};
|
||||
|
||||
const LineChartComponent = ({
|
||||
chartData,
|
||||
metricName,
|
||||
colors,
|
||||
onRangeSelect,
|
||||
filteredData
|
||||
}) => {
|
||||
const [selectionArea, setSelectionArea] = useState(null);
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
const chartRef = useRef(null);
|
||||
|
||||
|
||||
const allTimestamps = Object.values(chartData)
|
||||
.flat()
|
||||
.map(point => point.timestamp)
|
||||
.filter((timestamp, index, self) => self.indexOf(timestamp) === index)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const data = allTimestamps.map(timestamp => {
|
||||
const point = { timestamp };
|
||||
|
||||
const firstPoint = Object.values(chartData)
|
||||
.flat()
|
||||
.find(p => p.timestamp === timestamp);
|
||||
|
||||
if (firstPoint) {
|
||||
point.time = firstPoint.time;
|
||||
point.fullTime = firstPoint.fullTime;
|
||||
}
|
||||
|
||||
Object.keys(chartData).forEach(key => {
|
||||
const instanceData = chartData[key].find(p => p.timestamp === timestamp);
|
||||
point[key] = instanceData ? instanceData.value : null;
|
||||
});
|
||||
|
||||
return point;
|
||||
});
|
||||
|
||||
const displayData = filteredData || data;
|
||||
|
||||
const instanceKeys = displayData.length
|
||||
? Object.keys(displayData[0]).filter(k => !['timestamp', 'time', 'fullTime'].includes(k))
|
||||
: [];
|
||||
|
||||
const getTimeFormat = () => {
|
||||
if (!data.length) return TIME_FORMATS.SHORT;
|
||||
|
||||
const range = data[data.length - 1].timestamp - data[0].timestamp;
|
||||
|
||||
if (range > DAY) return TIME_FORMATS.LONG;
|
||||
if (range > HOUR) return TIME_FORMATS.MEDIUM;
|
||||
return TIME_FORMATS.SHORT;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleSelectStart = (e) => {
|
||||
if (isSelecting) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
document.addEventListener('selectstart', handleSelectStart);
|
||||
return () => document.removeEventListener('selectstart', handleSelectStart);
|
||||
}, [isSelecting]);
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
if (!e) return;
|
||||
|
||||
const activeIndex = e.activeTooltipIndex;
|
||||
if (activeIndex === undefined || activeIndex < 0 || activeIndex >= data.length) return;
|
||||
|
||||
setIsSelecting(true);
|
||||
setSelectionArea({
|
||||
start: data[activeIndex].timestamp,
|
||||
end: null,
|
||||
startIndex: activeIndex,
|
||||
endIndex: null
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isSelecting || !selectionArea?.start || !e) return;
|
||||
|
||||
const activeIndex = e.activeTooltipIndex;
|
||||
if (activeIndex === undefined || activeIndex < 0 || activeIndex >= data.length) return;
|
||||
|
||||
setSelectionArea(prev => ({
|
||||
...prev,
|
||||
end: data[activeIndex].timestamp,
|
||||
endIndex: activeIndex
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isSelecting || !selectionArea?.start || !selectionArea?.end) {
|
||||
setIsSelecting(false);
|
||||
setSelectionArea(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const startIndex = Math.min(selectionArea.startIndex, selectionArea.endIndex);
|
||||
const endIndex = Math.max(selectionArea.startIndex, selectionArea.endIndex);
|
||||
|
||||
const normalizedStart = startIndex / (data.length - 1);
|
||||
const normalizedEnd = endIndex / (data.length - 1);
|
||||
|
||||
onRangeSelect({
|
||||
startIndex: normalizedStart,
|
||||
endIndex: normalizedEnd
|
||||
});
|
||||
|
||||
setIsSelecting(false);
|
||||
setSelectionArea(null);
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const currentPoint = data.find(point => point.timestamp === label);
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: '#fff',
|
||||
padding: '10px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 2px 5px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<p style={{ fontWeight: 'bold', marginBottom: '5px' }}>
|
||||
{currentPoint?.fullTime || new Date(label).toLocaleString('ru-RU')}
|
||||
</p>
|
||||
{payload.map((item, index) => (
|
||||
<p key={index} style={{ color: item.color }}>
|
||||
{`Значение: ${item.value}`}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!data.length) {
|
||||
return (
|
||||
<Box sx={{
|
||||
position: 'relative',
|
||||
height: '400px',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '8px',
|
||||
padding: '20px'
|
||||
}}>
|
||||
<Skeleton variant="text" width="60%" height={30} sx={{ mb: 2 }} />
|
||||
<Skeleton variant="rectangular" width="100%" height="calc(100% - 50px)" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', height: '400px' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={displayData}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
ref={chartRef}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
height={75}
|
||||
tick={{ fontSize: 12, angle: -45, textAnchor: 'end' }}
|
||||
interval={Math.max(1, Math.floor(data.length / 10))}
|
||||
tickFormatter={(timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
const format = getTimeFormat();
|
||||
|
||||
if (format === 'dd.MM HH:mm') {
|
||||
return date.toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} else if (format === 'HH:mm') {
|
||||
return date.toLocaleString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} else {
|
||||
return date.toLocaleString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{instanceKeys.map((instance, index) => (
|
||||
<Line
|
||||
key={instance}
|
||||
type=""
|
||||
dataKey={instance}
|
||||
name={instance}
|
||||
stroke={colors[index % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
))}
|
||||
{selectionArea?.start && selectionArea?.end && (
|
||||
<ReferenceArea
|
||||
x1={selectionArea.start}
|
||||
x2={selectionArea.end}
|
||||
strokeOpacity={0.3}
|
||||
fill="#4a6baf"
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(LineChartComponent);
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import { ScatterChart, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Scatter, ResponsiveContainer } from 'recharts';
|
||||
|
||||
const ScatterChartComponent = ({ chartData, metricName, metricType, colors }) => {
|
||||
return (
|
||||
<div>
|
||||
<h2>{metricName} ({metricType})</h2>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<ScatterChart>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" />
|
||||
<YAxis dataKey="value" />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{Object.keys(chartData).map((instance, index) => (
|
||||
<Scatter
|
||||
key={instance}
|
||||
data={chartData[instance]}
|
||||
name={instance}
|
||||
fill={colors[index % colors.length]}
|
||||
/>
|
||||
))}
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScatterChartComponent;
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import React from 'react';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { TIME_RANGES } from './constants';
|
||||
|
||||
export const TimeRangeSelector = ({
|
||||
selectedRange,
|
||||
handleRangeChange,
|
||||
startDate,
|
||||
setStartDate,
|
||||
endDate,
|
||||
setEndDate,
|
||||
useCustomRange,
|
||||
handleCustomRangeChange,
|
||||
handleResetZoom
|
||||
}) => {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '15px',
|
||||
alignItems: 'center',
|
||||
marginBottom: '15px'
|
||||
}}>
|
||||
{/* Стандартные диапазоны */}
|
||||
<div style={{ flex: '1 1 200px', display: 'flex', gap: '10px', alignItems: 'flex-end' }}>
|
||||
<div style={{ flex: '1' }}>
|
||||
<label htmlFor="time-range" style={{
|
||||
display: 'block',
|
||||
marginBottom: '5px',
|
||||
fontWeight: '500',
|
||||
color: '#555'
|
||||
}}>Стандартные диапазоны:</label>
|
||||
<select
|
||||
id="time-range"
|
||||
value={selectedRange.value}
|
||||
onChange={handleRangeChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #ddd',
|
||||
color: "#333",
|
||||
backgroundColor: '#f9f9f9'
|
||||
}}
|
||||
>
|
||||
{TIME_RANGES.map(range => (
|
||||
<option key={range.value} value={range.value}>{range.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleResetZoom}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
color: '#333',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
height: '36px',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
onMouseOver={(e) => e.target.style.backgroundColor = '#e0e0e0'}
|
||||
onMouseOut={(e) => e.target.style.backgroundColor = '#f0f0f0'}
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Кастомный диапазон */}
|
||||
<div style={{ flex: '1 1 300px' }}>
|
||||
<div style={{
|
||||
marginBottom: '10px',
|
||||
fontWeight: '500',
|
||||
color: '#555'
|
||||
}}>
|
||||
Или укажите свой диапазон:
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ flex: '1 1 200px' }}>
|
||||
<DatePicker
|
||||
selected={startDate}
|
||||
onChange={(date) => setStartDate(date)}
|
||||
showTimeSelect
|
||||
timeFormat="HH:mm"
|
||||
timeIntervals={15}
|
||||
dateFormat="yyyy-MM-dd HH:mm"
|
||||
placeholderText="Начальная дата"
|
||||
customInput={
|
||||
<input style={{
|
||||
backgroundColor: '#f9f9f9',
|
||||
color: "#555",
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #ddd'
|
||||
}} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: '1 1 200px' }}>
|
||||
<DatePicker
|
||||
selected={endDate}
|
||||
onChange={(date) => setEndDate(date)}
|
||||
showTimeSelect
|
||||
timeFormat="HH:mm"
|
||||
timeIntervals={15}
|
||||
dateFormat="yyyy-MM-dd HH:mm"
|
||||
placeholderText="Конечная дата"
|
||||
customInput={
|
||||
<input style={{
|
||||
backgroundColor: '#f9f9f9',
|
||||
color: "#555",
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #ddd'
|
||||
}} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCustomRangeChange}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#4a6baf',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s',
|
||||
flex: '0 0 auto',
|
||||
alignSelf: 'flex-end'
|
||||
}}
|
||||
onMouseOver={(e) => e.target.style.backgroundColor = '#3a5a9f'}
|
||||
onMouseOut={(e) => e.target.style.backgroundColor = '#4a6baf'}
|
||||
>
|
||||
Применить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
export const TIME_RANGES = [
|
||||
{ label: '1 минута', value: 60, interval: 3000 },
|
||||
{ label: '5 минут', value: 300, interval: 15000 },
|
||||
{ label: '30 минут', value: 1800, interval: 90000 },
|
||||
{ label: '1 час', value: 3600, interval: 180000 },
|
||||
{ label: '3 часа', value: 10800, interval: 540000 },
|
||||
{ label: '6 часов', value: 21600, interval: 1080000 },
|
||||
{ label: '12 часов', value: 43200, interval: 2160000 },
|
||||
{ label: '24 часа', value: 86400, interval: 4320000 },
|
||||
{ label: '2 дня', value: 172800, interval: 8640000 },
|
||||
{ label: '7 дней', value: 604800, interval: 30240000 },
|
||||
{ label: '30 дней', value: 2592000, interval: 129600000 },
|
||||
{ label: '90 дней', value: 7776000, interval: 388800000 },
|
||||
{ label: '6 месяцев', value: 15552000, interval: 777600000 },
|
||||
{ label: '9 месяцев', value: 23328000, interval: 1166400000 },
|
||||
{ label: '1 год', value: 31536000, interval: 1576800000 },
|
||||
];
|
||||
|
||||
export const COLORS = ['#3e95cd', '#8e5ea2', '#3cba9f', '#e8c3b9', '#c45850'];
|
||||
export const MAX_POINTS = 20;
|
||||
|
||||
|
||||
// Для работы с временными интервалами (setTimeout и т.д.)
|
||||
export const MS = 1;
|
||||
export const SECOND_MS = 1000 * MS;
|
||||
export const MINUTE_MS = 60 * SECOND_MS;
|
||||
export const HOUR_MS = 60 * MINUTE_MS;
|
||||
export const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
// Для работы с Unix-временем и API (Prometheus и т.д.)
|
||||
export const SECOND = 1;
|
||||
export const MINUTE = 60 * SECOND;
|
||||
export const HOUR = 60 * MINUTE;
|
||||
export const DAY = 24 * HOUR;
|
||||
export const WEEK = 7 * DAY;
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||
ResponsiveContainer, ReferenceLine
|
||||
} from 'recharts';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const lineColors = {
|
||||
'18': '#8884d8',
|
||||
'19': '#82ca9d',
|
||||
'default': '#ff8042'
|
||||
};
|
||||
|
||||
const formatXAxis = (tickItem) => {
|
||||
return format(new Date(tickItem), 'HH:mm:ss');
|
||||
};
|
||||
|
||||
const formatTooltip = (value, name, props) => {
|
||||
return [`${value.toFixed(2)}`, ` ${name}`];
|
||||
};
|
||||
|
||||
const LineChartComponent = ({
|
||||
data = [],
|
||||
multipleLines = false,
|
||||
lineKey = 'device',
|
||||
title,
|
||||
description,
|
||||
height = 400,
|
||||
ranges = []
|
||||
}) => {
|
||||
if (!data || data.length === 0) return <div>Нет данных для отображения</div>;
|
||||
|
||||
// Создаем массив уникальных линий
|
||||
const lineKeys = [...new Set(data.map(item => item[lineKey] || 'default'))];
|
||||
|
||||
// Преобразуем данные в формат, удобный для Recharts
|
||||
const chartData = data.reduce((acc, item) => {
|
||||
const timestamp = item.timestamp;
|
||||
const existingPoint = acc.find(p => p.timestamp === timestamp);
|
||||
|
||||
if (existingPoint) {
|
||||
return acc.map(p =>
|
||||
p.timestamp === timestamp
|
||||
? { ...p, [item[lineKey] || 'default']: item.value }
|
||||
: p
|
||||
);
|
||||
}
|
||||
|
||||
return [...acc, {
|
||||
timestamp,
|
||||
[item[lineKey] || 'default']: item.value
|
||||
}];
|
||||
}, []).sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: `${height}px` }}>
|
||||
<h3>{title}</h3>
|
||||
{description && <p>{description}</p>}
|
||||
<ResponsiveContainer width="100%" height="90%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickFormatter={formatXAxis}
|
||||
/>
|
||||
<YAxis domain={[0, 25]} />
|
||||
<Tooltip
|
||||
formatter={formatTooltip}
|
||||
labelFormatter={(label) => format(new Date(label), 'yyyy-MM-dd HH:mm:ss')}
|
||||
/>
|
||||
<Legend />
|
||||
|
||||
{multipleLines ? (
|
||||
lineKeys.map(key => (
|
||||
<Line
|
||||
key={`line-${key}`}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
name={` ${key}`}
|
||||
stroke={lineColors[key] || lineColors.default}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 6 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={lineKeys[0] || 'value'}
|
||||
name={title}
|
||||
stroke={lineColors.default}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 6 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Добавляем диапазоны если они есть */}
|
||||
{ranges.map((range, idx) => (
|
||||
<ReferenceLine
|
||||
key={`range-${idx}`}
|
||||
y={range.value}
|
||||
stroke={range.color}
|
||||
label={range.label}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LineChartComponent;
|
||||
|
|
@ -0,0 +1,499 @@
|
|||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { webSocketManager } from './WebSocketManager';
|
||||
import LineChartComponent from './Components/LineChartComponent';
|
||||
import { TimeRangeSelector } from './Components/TimeRangeSelector';
|
||||
import { ConnectionStatusIndicator } from './Components/ConnectionStatusIndicator';
|
||||
import { CurrentRangeDisplay } from './Components/CurrentRangeDisplay';
|
||||
import { TIME_RANGES, COLORS, SECOND, MINUTE, HOUR, DAY } from './Components/constants';
|
||||
import axios from 'axios';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
|
||||
// Компонент Skeleton для графика
|
||||
const ChartSkeleton = () => (
|
||||
<Box sx={{
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
marginBottom: '20px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<Box sx={{ position: 'absolute', right: '20px', top: '20px' }}>
|
||||
<Skeleton variant="circular" width={16} height={16} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Skeleton variant="text" width="40%" height={30} />
|
||||
<Skeleton variant="text" width="30%" height={30} />
|
||||
</Box>
|
||||
|
||||
<Skeleton variant="rectangular" width="100%" height={300} />
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2, gap: 2 }}>
|
||||
{[1, 2, 3, 4].map((_, i) => (
|
||||
<Skeleton key={i} variant="rounded" width={80} height={36} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const PrometheusChart = ({ metricName }) => {
|
||||
const [chartData, setChartData] = useState(null);
|
||||
const [selectedRange, setSelectedRange] = useState(TIME_RANGES[0]);
|
||||
const [startDate, setStartDate] = useState(new Date());
|
||||
const [endDate, setEndDate] = useState(new Date());
|
||||
const [useCustomRange, setUseCustomRange] = useState(false);
|
||||
const [connectionStatus, setConnectionStatus] = useState('disconnected');
|
||||
const [selectedGraphRange, setSelectedGraphRange] = useState(null);
|
||||
const [filteredData, setFilteredData] = useState(null);
|
||||
const [isSelectingRange, setIsSelectingRange] = useState(false);
|
||||
const [lastCustomRange, setLastCustomRange] = useState(null);
|
||||
const intervalRef = useRef(null);
|
||||
const debounceRef = useRef(null);
|
||||
|
||||
const formatTime = useCallback((timestamp, rangeSeconds) => {
|
||||
const ts = typeof timestamp === 'number' ? timestamp : Date.now();
|
||||
const date = new Date(ts);
|
||||
|
||||
// Определяем формат в зависимости от диапазона
|
||||
const showFullDate = rangeSeconds > 86400; // больше суток
|
||||
|
||||
const timeOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
};
|
||||
|
||||
const dateOptions = showFullDate ? {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
...timeOptions
|
||||
} : timeOptions;
|
||||
|
||||
return {
|
||||
display: date.toLocaleString('ru-RU', dateOptions),
|
||||
fullDisplay: date.toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
}),
|
||||
timestamp: ts
|
||||
};
|
||||
}, []);
|
||||
|
||||
const calculateStep = useCallback((start, end) => {
|
||||
const range = end - start;
|
||||
if (range <= MINUTE) return 1; // 1 мин
|
||||
if (range <= MINUTE * 5) return 5; // 5 мин
|
||||
if (range <= HOUR / 2) return 15; // 30 мин
|
||||
if (range <= HOUR) return 30; // 1 час
|
||||
if (range <= HOUR * 3) return 60; // 3 часа
|
||||
if (range <= HOUR * 6) return 120; // 6 часов
|
||||
if (range <= DAY / 2) return 300; // 12 часов
|
||||
if (range <= DAY) return 600; // 24 часа
|
||||
return 1800; // > 24 часов
|
||||
}, []);
|
||||
|
||||
|
||||
const processMetricsData = useCallback((response, replace = false) => {
|
||||
console.log('Processing metrics data:', response);
|
||||
if (response.metric !== metricName) return;
|
||||
|
||||
const dataArray = Array.isArray(response.data) ? response.data : [response.data];
|
||||
if (!dataArray.length) return;
|
||||
|
||||
const newData = {};
|
||||
const rangeSeconds = useCustomRange
|
||||
? (endDate.getTime() - startDate.getTime()) / 1000
|
||||
: selectedRange.value;
|
||||
|
||||
dataArray.forEach(item => {
|
||||
const instance = item.instance || 'default';
|
||||
if (!newData[instance]) newData[instance] = [];
|
||||
|
||||
let timestamp;
|
||||
if (typeof item.timestamp === 'number') {
|
||||
timestamp = item.timestamp > 1e12 ? item.timestamp : item.timestamp * 1000;
|
||||
} else {
|
||||
timestamp = Date.now();
|
||||
}
|
||||
|
||||
const value = parseFloat(item.value);
|
||||
const formattedTime = formatTime(timestamp, rangeSeconds);
|
||||
|
||||
newData[instance].push({
|
||||
time: formattedTime.display,
|
||||
fullTime: formattedTime.fullDisplay,
|
||||
value,
|
||||
timestamp
|
||||
});
|
||||
});
|
||||
|
||||
Object.keys(newData).forEach(instance => {
|
||||
newData[instance] = newData[instance]
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
.slice(-1000);
|
||||
});
|
||||
|
||||
if (replace) {
|
||||
setChartData(newData); // Заменяем полностью
|
||||
} else {
|
||||
setChartData(prev => {
|
||||
const merged = { ...(prev || {}) };
|
||||
Object.keys(newData).forEach(instance => {
|
||||
if (!merged[instance]) merged[instance] = [];
|
||||
merged[instance] = [...merged[instance], ...newData[instance]]
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
.slice(-1000);
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
}, [metricName, selectedRange.value, formatTime, useCustomRange, startDate, endDate]);
|
||||
|
||||
|
||||
const fetchData = useCallback(() => {
|
||||
if (isSelectingRange) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const start = now - selectedRange.value;
|
||||
const end = now;
|
||||
const step = calculateStep(start, end);
|
||||
|
||||
webSocketManager.getMetricsRange(metricName, start, end, step)
|
||||
.then(data => {
|
||||
processMetricsData({ metric: metricName, data });
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching metrics:', error);
|
||||
});
|
||||
}, [metricName, selectedRange.value, isSelectingRange, calculateStep, processMetricsData]);
|
||||
|
||||
const fetchCustomRangeData = useCallback(async () => {
|
||||
// Добавляем проверку на валидность дат
|
||||
if (!startDate || !endDate || startDate >= endDate) {
|
||||
console.error('Invalid date range');
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Math.floor(startDate.getTime() / 1000);
|
||||
const end = Math.ceil(endDate.getTime() / 1000); // Используем Math.ceil для конечной даты
|
||||
const rangeSeconds = end - start;
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/metrics`, {
|
||||
params: {
|
||||
metric: metricName,
|
||||
start,
|
||||
end,
|
||||
step: calculateStep(start, end)
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data?.length) {
|
||||
// Добавляем нормализацию timestamp
|
||||
const processedData = response.data.map(item => ({
|
||||
...item,
|
||||
timestamp: item.timestamp > 1e12 ? item.timestamp : item.timestamp * 1000,
|
||||
value: parseFloat(item.value)
|
||||
}));
|
||||
|
||||
processMetricsData({
|
||||
metric: metricName,
|
||||
data: processedData
|
||||
}, true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении кастомных данных:', error);
|
||||
}
|
||||
}, [metricName, startDate, endDate, calculateStep, processMetricsData]);
|
||||
|
||||
|
||||
const handleRangeChange = useCallback(async (event) => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
const selectedValue = event.target.value;
|
||||
const range = TIME_RANGES.find(r => r.value === parseInt(selectedValue, 10));
|
||||
|
||||
// Полный сброс состояния перед загрузкой новых данных
|
||||
setChartData(null);
|
||||
setSelectedRange(range);
|
||||
setUseCustomRange(false);
|
||||
setSelectedGraphRange(null);
|
||||
setFilteredData(null);
|
||||
|
||||
const now = new Date();
|
||||
setEndDate(now);
|
||||
setStartDate(new Date(now.getTime() - range.value * 1000));
|
||||
|
||||
// Ждем завершения обновления состояния перед загрузкой
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleCustomRangeChange = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
setUseCustomRange(true);
|
||||
setChartData(null);
|
||||
setSelectedGraphRange(null);
|
||||
setFilteredData(null);
|
||||
fetchCustomRangeData();
|
||||
}, [fetchCustomRangeData]);
|
||||
|
||||
|
||||
|
||||
const interpolateData = useCallback((data, targetPointCount) => {
|
||||
if (!data || data.length < 2) return data;
|
||||
if (data.length >= targetPointCount) return data;
|
||||
|
||||
const interpolated = [];
|
||||
const step = (data.length - 1) / (targetPointCount - 1);
|
||||
|
||||
for (let i = 0; i < targetPointCount; i++) {
|
||||
const index = i * step;
|
||||
const lowerIndex = Math.floor(index);
|
||||
const upperIndex = Math.ceil(index);
|
||||
|
||||
if (lowerIndex === upperIndex) {
|
||||
interpolated.push(data[lowerIndex]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fraction = index - lowerIndex;
|
||||
const interpolatedPoint = {};
|
||||
|
||||
Object.keys(data[lowerIndex]).forEach(key => {
|
||||
if (key === 'timestamp') {
|
||||
interpolatedPoint[key] = data[lowerIndex][key] +
|
||||
fraction * (data[upperIndex][key] - data[lowerIndex][key]);
|
||||
|
||||
// Добавляем отображаемое время
|
||||
const { display, fullDisplay } = formatTime(interpolatedPoint[key],
|
||||
(endDate - startDate) / 1000);
|
||||
interpolatedPoint.time = display;
|
||||
interpolatedPoint.fullTime = fullDisplay;
|
||||
} else if (typeof data[lowerIndex][key] === 'number') {
|
||||
interpolatedPoint[key] = data[lowerIndex][key] +
|
||||
fraction * (data[upperIndex][key] - data[lowerIndex][key]);
|
||||
} else {
|
||||
interpolatedPoint[key] = data[lowerIndex][key];
|
||||
}
|
||||
});
|
||||
|
||||
interpolated.push(interpolatedPoint);
|
||||
}
|
||||
|
||||
return interpolated;
|
||||
}, []);
|
||||
|
||||
const handleRangeSelect = useCallback((range) => {
|
||||
setLastCustomRange(range);
|
||||
if (!range || !chartData) return;
|
||||
|
||||
setIsSelectingRange(true);
|
||||
setSelectedGraphRange(range);
|
||||
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
// Получаем все точки и сортируем по времени
|
||||
const allPoints = Object.values(chartData).flat();
|
||||
const sortedPoints = allPoints.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
// Вычисляем абсолютные индексы
|
||||
const startIndex = Math.floor(range.startIndex * (sortedPoints.length - 1));
|
||||
const endIndex = Math.floor(range.endIndex * (sortedPoints.length - 1));
|
||||
|
||||
// Фильтруем точки по выбранному диапазону
|
||||
const filtered = sortedPoints.slice(startIndex, endIndex + 1);
|
||||
|
||||
// Применяем интерполяцию только если точек меньше 100
|
||||
const interpolated = filtered.length < 100 ?
|
||||
interpolateData(filtered, Math.min(100, filtered.length * 3)) :
|
||||
filtered;
|
||||
|
||||
setFilteredData(interpolated);
|
||||
setIsSelectingRange(false);
|
||||
}, [chartData, interpolateData, formatTime]);
|
||||
|
||||
const handleResetZoom = useCallback(() => {
|
||||
setSelectedGraphRange(null);
|
||||
setFilteredData(null);
|
||||
setIsSelectingRange(false);
|
||||
|
||||
if (useCustomRange) {
|
||||
fetchCustomRangeData();
|
||||
} else {
|
||||
fetchData();
|
||||
}
|
||||
|
||||
if (lastCustomRange) {
|
||||
handleRangeSelect(lastCustomRange);
|
||||
}
|
||||
}, [fetchData, fetchCustomRangeData, useCustomRange, lastCustomRange, handleRangeSelect]);
|
||||
|
||||
useEffect(() => {
|
||||
// Обработчик данных с сервера
|
||||
const handleMetricsData = (data) => {
|
||||
if (!useCustomRange) {
|
||||
processMetricsData({ metric: metricName, data });
|
||||
}
|
||||
};
|
||||
|
||||
// Подписываемся на обновления метрики
|
||||
const unsubscribe = webSocketManager.subscribe(metricName, handleMetricsData);
|
||||
|
||||
// Подписываемся на изменения статуса соединения
|
||||
const unsubscribeStatus = webSocketManager.onConnectionStatusChange(setConnectionStatus);
|
||||
|
||||
return () => {
|
||||
// Отписываемся при размонтировании компонента
|
||||
unsubscribe();
|
||||
unsubscribeStatus();
|
||||
|
||||
// Очищаем интервал обновления
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [metricName, useCustomRange, processMetricsData]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (useCustomRange && !isSelectingRange) {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
fetchCustomRangeData();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [useCustomRange, isSelectingRange, startDate, endDate, fetchCustomRangeData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (useCustomRange || isSelectingRange) return;
|
||||
|
||||
const fetchDataWrapper = () => {
|
||||
try {
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error in interval fetch:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
|
||||
fetchDataWrapper();
|
||||
intervalRef.current = setInterval(fetchDataWrapper, selectedRange.interval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [fetchData, selectedRange.interval, useCustomRange, isSelectingRange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGraphRange || !chartData) {
|
||||
setFilteredData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const allPoints = Object.values(chartData).flat();
|
||||
const sortedPoints = allPoints.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
const startIndex = Math.floor(selectedGraphRange.startIndex * (sortedPoints.length - 1));
|
||||
const endIndex = Math.floor(selectedGraphRange.endIndex * (sortedPoints.length - 1));
|
||||
|
||||
const filtered = sortedPoints.slice(startIndex, endIndex + 1);
|
||||
const interpolated = filtered.length > 100 ?
|
||||
interpolateData(filtered, 100) :
|
||||
filtered;
|
||||
|
||||
setFilteredData(interpolated);
|
||||
}, [selectedGraphRange, chartData, interpolateData]);
|
||||
|
||||
if (chartData === null) {
|
||||
return <ChartSkeleton />;
|
||||
}
|
||||
|
||||
if (Object.keys(chartData).length === 0) {
|
||||
return (
|
||||
<Box sx={{
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
marginBottom: '20px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
No data available
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
marginBottom: '20px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<ConnectionStatusIndicator connectionStatus={connectionStatus} />
|
||||
|
||||
<TimeRangeSelector
|
||||
selectedRange={selectedRange}
|
||||
handleRangeChange={handleRangeChange}
|
||||
startDate={startDate}
|
||||
setStartDate={setStartDate}
|
||||
endDate={endDate}
|
||||
setEndDate={setEndDate}
|
||||
useCustomRange={useCustomRange}
|
||||
handleCustomRangeChange={handleCustomRangeChange}
|
||||
handleResetZoom={handleResetZoom}
|
||||
/>
|
||||
|
||||
<CurrentRangeDisplay
|
||||
useCustomRange={useCustomRange}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
selectedRange={selectedRange}
|
||||
/>
|
||||
|
||||
<LineChartComponent
|
||||
chartData={chartData}
|
||||
metricName={metricName}
|
||||
colors={COLORS}
|
||||
onRangeSelect={handleRangeSelect}
|
||||
filteredData={filteredData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(PrometheusChart);
|
||||
|
|
@ -1,283 +0,0 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import LineChartComponent from './LineChartComponent';
|
||||
import DateRangeSelector from '../Charts2/Components/DateRangeSelector';
|
||||
import metricsService from '../Charts2/Components/metricsService';
|
||||
import { Button, Radio, message, Tag, Spin } from 'antd';
|
||||
import moment from 'moment';
|
||||
import StatusLogTable from '../Charts2/Components/StatusLogTable';
|
||||
import { Box, IconButton, Tooltip as MuiTooltip } from '@mui/material';
|
||||
import { ListAlt } from '@mui/icons-material';
|
||||
|
||||
const SystemChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||
const {
|
||||
name: metricName,
|
||||
filters = {},
|
||||
title = metricName,
|
||||
description,
|
||||
context = {},
|
||||
ranges = [],
|
||||
multipleLines = false,
|
||||
lineKey = 'device'
|
||||
} = metricInfo || {};
|
||||
|
||||
const { device, source_id } = context;
|
||||
|
||||
const [rawData, setRawData] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [metricMeta, setMetricMeta] = useState({});
|
||||
const [mode, setMode] = useState('realtime');
|
||||
const [startDate, setStartDate] = useState(moment().subtract(1, 'hour').toDate());
|
||||
const [endDate, setEndDate] = useState(moment().toDate());
|
||||
const [isLiveUpdating, setIsLiveUpdating] = useState(false);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [statusLogs, setStatusLogs] = useState([]);
|
||||
|
||||
const MAX_POINTS = 1000;
|
||||
const TIME_WINDOW_MS = 3600 * 1000;
|
||||
|
||||
const subscriptionKey = useMemo(() => {
|
||||
const filterParts = [];
|
||||
if (device) filterParts.push(`device=${encodeURIComponent(device)}`);
|
||||
if (source_id) filterParts.push(`source_id=${encodeURIComponent(source_id)}`);
|
||||
return `${metricName}${filterParts.length ? `?${filterParts.join('&')}` : ''}`;
|
||||
}, [metricName, device, source_id]);
|
||||
|
||||
const formatMetricData = (dataArray) => {
|
||||
return dataArray.map(item => ({
|
||||
...item,
|
||||
timestamp: item.timestamp,
|
||||
value: parseFloat(item.value),
|
||||
name: item.__name__ || metricName,
|
||||
status: parseInt(item.status) || 0,
|
||||
device: item.device?.trim() || null,
|
||||
source_id: item.source_id || null,
|
||||
description: item.description || description,
|
||||
lineId: item[lineKey] || 'default'
|
||||
}));
|
||||
};
|
||||
|
||||
const calculateStep = (start, end) => {
|
||||
const duration = end.getTime() - start.getTime();
|
||||
return Math.max(Math.floor(duration / (MAX_POINTS * 1000)), 1);
|
||||
};
|
||||
|
||||
const fetchHistoricalData = async (start, end) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const extendedFilters = {
|
||||
...filters,
|
||||
...(device && { device: device.toString() }),
|
||||
...(source_id && { source_id: source_id.toString() })
|
||||
};
|
||||
|
||||
const step = calculateStep(start, end);
|
||||
const data = await metricsService.fetchMetricsRange(
|
||||
metricName,
|
||||
Math.floor(start.getTime() / 1000),
|
||||
Math.floor(end.getTime() / 1000),
|
||||
step,
|
||||
extendedFilters
|
||||
);
|
||||
|
||||
const formattedData = formatMetricData(data);
|
||||
setRawData(formattedData);
|
||||
|
||||
if (formattedData.length > 0) {
|
||||
setMetricMeta({
|
||||
type: data[0]?.type,
|
||||
description: data[0]?.description || description,
|
||||
instance: data[0]?.instance,
|
||||
job: data[0]?.job
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error loading historical data for ${metricName}:`, err);
|
||||
setError(err.message);
|
||||
message.error(`Failed to load historical data: ${err.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startRealtimeUpdates = () => {
|
||||
setIsLiveUpdating(true);
|
||||
setIsLoading(true);
|
||||
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - TIME_WINDOW_MS);
|
||||
fetchHistoricalData(start, end).finally(() => setIsLoading(false));
|
||||
|
||||
return metricsService.subscribeToMetric(
|
||||
subscriptionKey,
|
||||
(newData) => {
|
||||
setRawData(prev => {
|
||||
const now = Date.now();
|
||||
const cutoffTime = now - TIME_WINDOW_MS;
|
||||
|
||||
const formattedNewData = formatMetricData(newData)
|
||||
.filter(point => point.timestamp >= cutoffTime);
|
||||
|
||||
const filteredPrev = prev.filter(point => point.timestamp >= cutoffTime);
|
||||
|
||||
// Объединяем данные, удаляем дубликаты
|
||||
const merged = [...filteredPrev, ...formattedNewData]
|
||||
.filter((v, i, a) =>
|
||||
a.findIndex(t =>
|
||||
t.timestamp === v.timestamp &&
|
||||
t[lineKey] === v[lineKey]
|
||||
) === i
|
||||
);
|
||||
|
||||
return merged;
|
||||
});
|
||||
},
|
||||
5000, // Интервал обновления 5 секунд
|
||||
{
|
||||
...filters,
|
||||
...(device && { device }),
|
||||
...(source_id && { source_id })
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const stopRealtimeUpdates = () => {
|
||||
setIsLiveUpdating(false);
|
||||
metricsService.unsubscribeFromMetric(subscriptionKey);
|
||||
};
|
||||
|
||||
const handleCustomRangeApply = () => {
|
||||
if (startDate && endDate) {
|
||||
fetchHistoricalData(startDate, endDate);
|
||||
}
|
||||
};
|
||||
|
||||
// Обновляем логи статусов
|
||||
useEffect(() => {
|
||||
if (rawData.length > 0) {
|
||||
const logs = [];
|
||||
const devices = [...new Set(rawData.map(item => item[lineKey]))];
|
||||
|
||||
devices.forEach(dev => {
|
||||
const deviceData = rawData
|
||||
.filter(item => item[lineKey] === dev)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
if (deviceData.length > 0) {
|
||||
logs.push(deviceData[0]); // Первая точка
|
||||
|
||||
for (let i = 1; i < deviceData.length; i++) {
|
||||
if (deviceData[i].status !== deviceData[i - 1].status) {
|
||||
logs.push(deviceData[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setStatusLogs(logs.sort((a, b) => b.timestamp - a.timestamp));
|
||||
}
|
||||
}, [rawData, lineKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe;
|
||||
|
||||
if (mode === 'realtime') {
|
||||
unsubscribe = startRealtimeUpdates();
|
||||
} else {
|
||||
stopRealtimeUpdates();
|
||||
fetchHistoricalData(startDate, endDate);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (unsubscribe) unsubscribe();
|
||||
stopRealtimeUpdates();
|
||||
};
|
||||
}, [mode, metricName, device, source_id]);
|
||||
|
||||
const metaInfo = [
|
||||
metricMeta.instance && `Instance: ${metricMeta.instance}`,
|
||||
metricMeta.job && `Job: ${metricMeta.job}`,
|
||||
metricMeta.type && `Type: ${metricMeta.type}`
|
||||
].filter(Boolean).join(' | ');
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value)}
|
||||
buttonStyle="solid"
|
||||
style={{ marginBottom: 10 }}
|
||||
>
|
||||
<Radio.Button value="realtime">Режим реального времени</Radio.Button>
|
||||
<Radio.Button value="historical">Исторические данные</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
{mode === 'historical' && (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onApply={handleCustomRangeApply}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'realtime' && (
|
||||
<Tag color={isLiveUpdating ? 'green' : 'red'}>
|
||||
{isLiveUpdating ? 'Обновление в реальном времени' : 'Режим реального времени остановлен'}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{device && <Tag color="geekblue">Устройство: {device}</Tag>}
|
||||
{source_id && <Tag color="purple">Модуль: {source_id.split('$')[1]}</Tag>}
|
||||
|
||||
<Box position="relative">
|
||||
<MuiTooltip title={showLogs ? "Скрыть логи" : "Показать логи"}>
|
||||
<IconButton
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: 16,
|
||||
zIndex: 1000,
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 1
|
||||
}}
|
||||
>
|
||||
<ListAlt />
|
||||
</IconButton>
|
||||
</MuiTooltip>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ height: chartHeight, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Spin size="large" tip="Загрузка данных..." />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={{ color: 'red', padding: 20 }}>Ошибка: {error}</div>
|
||||
) : rawData.length === 0 ? (
|
||||
<div style={{ padding: 20 }}>Нет данных для метрики: {metricName}</div>
|
||||
) : (
|
||||
<>
|
||||
<LineChartComponent
|
||||
data={rawData}
|
||||
title={title}
|
||||
description={description}
|
||||
multipleLines={multipleLines}
|
||||
lineKey={lineKey}
|
||||
metaInfo={metaInfo}
|
||||
height={chartHeight}
|
||||
ranges={ranges}
|
||||
/>
|
||||
{showLogs && <StatusLogTable logs={statusLogs} />}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemChart;
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
|
||||
const SystemStatusChart = ({ data }) => {
|
||||
// Обрезаем массив, оставляя только последние 20 точек
|
||||
const trimmedData = data.slice(-20);
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="custom-tooltip" style={{
|
||||
backgroundColor: '#fff',
|
||||
padding: '10px',
|
||||
border: '1px solid #ccc'
|
||||
}}>
|
||||
<p>{`Время: ${label}`}</p>
|
||||
<p>{`Значение: ${payload[0].value}`}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart
|
||||
data={trimmedData} // Используем обрезанный массив
|
||||
margin={{
|
||||
top: 5, right: 30, left: 20, bottom: 5,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" />
|
||||
<YAxis domain={[0, 100]} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line type="monotone" dataKey="status" stroke="#8884d8" activeDot={{ r: 8 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemStatusChart;
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { io } from 'socket.io-client';
|
||||
|
||||
class WebSocketManager {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.subscribers = new Map();
|
||||
this.connectionStatus = 'disconnected';
|
||||
this.connectionCallbacks = new Set();
|
||||
this.connecting = false;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.socket?.connected || this.connecting) {
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
this.connecting = true;
|
||||
|
||||
this.socket = io(`${import.meta.env.VITE_BACK_WS_URL}/api/metrics-ws`, {
|
||||
transports: ['websocket'],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
});
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
this.connectionStatus = 'connected';
|
||||
this.connecting = false;
|
||||
this.notifyConnectionStatus();
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
this.connectionStatus = 'disconnected';
|
||||
this.connecting = false;
|
||||
this.notifyConnectionStatus();
|
||||
if (reason === 'io server disconnect') this.socket.connect();
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (error) => {
|
||||
this.connectionStatus = 'error';
|
||||
this.notifyConnectionStatus();
|
||||
setTimeout(() => this.socket.connect(), 1000);
|
||||
});
|
||||
|
||||
this.socket.on('metrics-data', (response) => {
|
||||
const callbacks = this.subscribers.get(response.metric);
|
||||
if (callbacks) {
|
||||
callbacks.forEach(callback => callback(response.data));
|
||||
}
|
||||
});
|
||||
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
subscribe(metricName, callback) {
|
||||
if (!this.socket?.connected) {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
if (!this.subscribers.has(metricName)) {
|
||||
this.subscribers.set(metricName, new Set());
|
||||
this.socket.emit('subscribe-metric', {
|
||||
metric: metricName,
|
||||
isSubscription: true // Флаг для подписки
|
||||
});
|
||||
}
|
||||
|
||||
this.subscribers.get(metricName).add(callback);
|
||||
|
||||
return () => this.unsubscribe(metricName, callback);
|
||||
}
|
||||
|
||||
unsubscribe(metricName, callback) {
|
||||
const callbacks = this.subscribers.get(metricName);
|
||||
if (callbacks) {
|
||||
callbacks.delete(callback);
|
||||
if (callbacks.size === 0) {
|
||||
this.subscribers.delete(metricName);
|
||||
this.socket.emit('unsubscribe-metric', { metric: metricName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getMetricsRange(metricName, start, end, step) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error('Timeout while waiting for metrics data'));
|
||||
}, 10000);
|
||||
|
||||
// Временный обработчик для разового запроса
|
||||
const tempHandler = (data) => {
|
||||
clearTimeout(timer);
|
||||
this.socket.off(`metrics-range-${metricName}`, tempHandler);
|
||||
resolve(data);
|
||||
};
|
||||
|
||||
this.socket.on(`metrics-range-${metricName}`, tempHandler);
|
||||
this.socket.emit('get-metrics', {
|
||||
metric: metricName,
|
||||
start,
|
||||
end,
|
||||
step,
|
||||
isRangeQuery: true // Флаг для разового запроса
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onConnectionStatusChange(callback) {
|
||||
this.connectionCallbacks.add(callback);
|
||||
callback(this.connectionStatus);
|
||||
return () => this.connectionCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
notifyConnectionStatus() {
|
||||
this.connectionCallbacks.forEach(callback => callback(this.connectionStatus));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const webSocketManager = new WebSocketManager();
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
|
|
@ -66,39 +66,12 @@ const StatusIndicator = ({ cx, cy, payload }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const StatusBadge = ({ status }) => {
|
||||
const statusColor = getStatusColor(status);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '4px 8px',
|
||||
background: `${statusColor}20`,
|
||||
borderLeft: `4px solid ${statusColor}`,
|
||||
borderRadius: '4px',
|
||||
marginTop: '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>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label, multipleLines }) => {
|
||||
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',
|
||||
|
|
@ -108,24 +81,31 @@ const CustomTooltip = ({ active, payload, label, multipleLines }) => {
|
|||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<p><strong>{new Date(label).toLocaleString()}</strong></p>
|
||||
|
||||
{multipleLines ? (
|
||||
payload.map((item, index) => (
|
||||
<div key={index} style={{ marginBottom: '8px' }}>
|
||||
<p style={{ color: item.color }}>
|
||||
{item.name}: <strong>{item.value.toFixed(2)}</strong>
|
||||
</p>
|
||||
<StatusBadge status={item.payload.status} />
|
||||
<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>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<p style={{ color: payload[0].color }}>
|
||||
Значение: <strong>{payload[0].value.toFixed(2)}</strong>
|
||||
</p>
|
||||
<StatusBadge status={payload[0].payload.status} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -137,43 +117,9 @@ const LineChartComponent = ({
|
|||
metaInfo,
|
||||
dataKey = 'value',
|
||||
height = 400,
|
||||
ranges = [],
|
||||
statusBoundaries = [],
|
||||
multipleLines = false,
|
||||
lineKey = 'device'
|
||||
ranges = [],
|
||||
statusBoundaries = []
|
||||
}) => {
|
||||
// Группировка данных для нескольких линий
|
||||
const groupedData = useMemo(() => {
|
||||
if (!multipleLines || !data || data.length === 0) return null;
|
||||
|
||||
return data.reduce((groups, item) => {
|
||||
const key = item[lineKey] || 'default';
|
||||
if (!groups[key]) {
|
||||
groups[key] = {
|
||||
data: [],
|
||||
color: getLineColor(key),
|
||||
name: `${title} (${key})`
|
||||
};
|
||||
}
|
||||
groups[key].data.push(item);
|
||||
return groups;
|
||||
}, {});
|
||||
}, [data, multipleLines, lineKey, title]);
|
||||
|
||||
// Функции для цветов линий
|
||||
const getLineColor = (key) => {
|
||||
const colors = ['#8884d8', '#82ca9d', '#ffc658', '#ff8042', '#0088FE'];
|
||||
const index = Math.abs(hashCode(key)) % colors.length;
|
||||
return colors[index];
|
||||
};
|
||||
|
||||
const hashCode = (str) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
const getStatusAreas = () => {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
|
@ -195,21 +141,22 @@ const LineChartComponent = ({
|
|||
|
||||
return areas.map((area, i) => (
|
||||
<ReferenceArea
|
||||
key={`area-${i}`}
|
||||
x1={area.start}
|
||||
x2={area.end}
|
||||
fill={getStatusColor(area.status)}
|
||||
fillOpacity={0.12}
|
||||
stroke={getStatusColor(area.status)}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
key={`area-${i}`}
|
||||
x1={area.start}
|
||||
x2={area.end}
|
||||
fill={getStatusColor(area.status)}
|
||||
fillOpacity={0.12}
|
||||
stroke={getStatusColor(area.status)}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
|
||||
));
|
||||
};
|
||||
|
||||
const renderRangeLines = () => {
|
||||
if (!ranges || ranges.length === 0) return null;
|
||||
|
||||
|
||||
// Собираем только уникальные граничные значения, исключая дубликаты на стыках диапазонов
|
||||
const boundaryValues = [];
|
||||
ranges.forEach((range, index) => {
|
||||
|
|
@ -217,28 +164,28 @@ const LineChartComponent = ({
|
|||
if (index === 0) {
|
||||
boundaryValues.push(range.min);
|
||||
boundaryValues.push(range.max);
|
||||
}
|
||||
}
|
||||
// Для остальных добавляем только max (min будет совпадать с max предыдущего)
|
||||
else {
|
||||
boundaryValues.push(range.max);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return boundaryValues.map((value, index) => {
|
||||
// Находим диапазон, к которому принадлежит эта граница
|
||||
const range = ranges.find(r => r.min === value || r.max === value);
|
||||
const status = range ? range.status : 1;
|
||||
|
||||
|
||||
const lineStyle = {
|
||||
1: { strokeWidth: 1, strokeDasharray: "none", opacity: 0.7 },
|
||||
2: { strokeWidth: 2, strokeDasharray: "none", opacity: 0.9 },
|
||||
3: { strokeWidth: 2, strokeDasharray: "none", opacity: 1 },
|
||||
4: { strokeWidth: 2, strokeDasharray: "none", opacity: 1 }
|
||||
}[status] || { strokeWidth: 1, strokeDasharray: "3 3", opacity: 0.7 };
|
||||
|
||||
|
||||
return (
|
||||
<ReferenceLine
|
||||
key={`line-${value}`}
|
||||
key={`line-${value}`} // Используем значение как ключ для стабильности
|
||||
y={value}
|
||||
stroke={rangeColors[status] || '#888'}
|
||||
strokeWidth={lineStyle.strokeWidth}
|
||||
|
|
@ -285,9 +232,10 @@ const LineChartComponent = ({
|
|||
|
||||
return (
|
||||
<div style={{ width: '100%', height: `${height}px` }}>
|
||||
{/* Заголовок и описание */}
|
||||
{title && <h3>{title}</h3>}
|
||||
{description && <p style={{ marginTop: -10, color: '#666' }}>{description}</p>}
|
||||
{description && (
|
||||
<p style={{ marginTop: -10, color: '#666' }}>{description}</p>
|
||||
)}
|
||||
{metaInfo && (
|
||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 10 }}>
|
||||
{metaInfo}
|
||||
|
|
@ -339,56 +287,35 @@ const LineChartComponent = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* График */}
|
||||
<ResponsiveContainer width="100%" height="75%">
|
||||
<LineChart
|
||||
data={multipleLines ? null : data}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickFormatter={(ts) => new Date(ts).toLocaleTimeString()}
|
||||
/>
|
||||
<YAxis />
|
||||
<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 />
|
||||
{renderRangeLines()}
|
||||
{renderStatusBoundaries()}
|
||||
{getStatusAreas()}
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke="#8884d8"
|
||||
strokeWidth={2}
|
||||
dot={<StatusIndicator />}
|
||||
activeDot={{ r: 8 }}
|
||||
isAnimationActive={false}
|
||||
name={title}
|
||||
/>
|
||||
</LineChart>
|
||||
|
||||
{renderRangeLines()}
|
||||
{renderStatusBoundaries()}
|
||||
{getStatusAreas()}
|
||||
|
||||
<Tooltip content={<CustomTooltip multipleLines={multipleLines} />} />
|
||||
<Legend />
|
||||
|
||||
{multipleLines && groupedData ? (
|
||||
Object.entries(groupedData).map(([key, group]) => (
|
||||
<Line
|
||||
key={key}
|
||||
data={group.data}
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke={group.color}
|
||||
strokeWidth={2}
|
||||
dot={<StatusIndicator />}
|
||||
activeDot={{ r: 8 }}
|
||||
isAnimationActive={false}
|
||||
animationDuration={300}
|
||||
name={group.name}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke="#8884d8"
|
||||
strokeWidth={2}
|
||||
dot={<StatusIndicator />}
|
||||
activeDot={{ r: 8 }}
|
||||
isAnimationActive={false}
|
||||
animationDuration={300}
|
||||
name={title}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Легенда статусов */}
|
||||
|
|
|
|||
|
|
@ -30,9 +30,6 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
|||
const [isLiveUpdating, setIsLiveUpdating] = useState(false);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [statusLogs, setStatusLogs] = useState([]);
|
||||
const MAX_POINTS = 50;
|
||||
const TIME_WINDOW_MS = 3600 * 1000; // 1 час в миллисекундах
|
||||
|
||||
|
||||
const getSubscriptionKey = () => {
|
||||
const filterParts = [];
|
||||
|
|
@ -56,32 +53,16 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
|||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
};
|
||||
|
||||
const calculateStep = (startTime, endTime, maxPoints = 10000) => {
|
||||
const durationSeconds = (endTime.getTime() - startTime.getTime()) / 1000;
|
||||
return Math.max(Math.ceil(durationSeconds / maxPoints), 1);
|
||||
const downsampleData = (data, maxPoints = 500) => {
|
||||
if (data.length <= maxPoints) return data;
|
||||
|
||||
const ratio = Math.ceil(data.length / maxPoints);
|
||||
return data.filter((_, index) => index % ratio === 0);
|
||||
};
|
||||
|
||||
const downsampleData = (data, maxPoints = MAX_POINTS) => {
|
||||
if (data.length <= maxPoints) return [...data];
|
||||
|
||||
const sortedData = [...data].sort((a, b) => a.timestamp - b.timestamp);
|
||||
const step = Math.max(1, Math.floor(sortedData.length / maxPoints));
|
||||
|
||||
const result = [];
|
||||
for (let i = 0; i < sortedData.length; i += step) {
|
||||
if (result.length >= maxPoints) break;
|
||||
result.push(sortedData[i]);
|
||||
}
|
||||
|
||||
// Всегда включаем последнюю точку
|
||||
if (result.length > 0) {
|
||||
const lastOriginalPoint = sortedData[sortedData.length - 1];
|
||||
if (result[result.length - 1].timestamp !== lastOriginalPoint.timestamp) {
|
||||
result[result.length - 1] = lastOriginalPoint;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
const calculateStep = (startTime, endTime, maxPoints = 10000) => {
|
||||
const seconds = (endTime.getTime() - startTime.getTime()) / 1000;
|
||||
return Math.max(Math.ceil(seconds / maxPoints), 1); // в секундах
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -119,15 +100,9 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
|||
extendedFilters
|
||||
);
|
||||
|
||||
const formattedData = formatMetricData(data)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
// Применяем ограничение по количеству точек только для исторических данных
|
||||
const limitedData = formattedData.length > MAX_POINTS
|
||||
? formattedData.slice(-MAX_POINTS)
|
||||
: formattedData;
|
||||
|
||||
if (limitedData.length > 0) {
|
||||
const formattedData = downsampleData(formatMetricData(data), 100); //КОЛИЧЕСТВО ТОЧЕК НА ГРАФИКЕ
|
||||
if (formattedData.length > 0) {
|
||||
setMetricMeta({
|
||||
type: data[0]?.type,
|
||||
description: data[0]?.description || description,
|
||||
|
|
@ -136,7 +111,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
|||
});
|
||||
}
|
||||
|
||||
setChartData(limitedData);
|
||||
setChartData(formattedData);
|
||||
} catch (err) {
|
||||
console.error(`Error loading historical data for ${metricName}:`, err);
|
||||
setError(err.message);
|
||||
|
|
@ -151,38 +126,21 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
|||
setIsLoading(true);
|
||||
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - TIME_WINDOW_MS);
|
||||
const start = new Date(end.getTime() - 3600 * 1000);
|
||||
fetchHistoricalData(start, end).finally(() => setIsLoading(false));
|
||||
|
||||
return metricsService.subscribeToMetric(
|
||||
getSubscriptionKey(),
|
||||
(newData) => {
|
||||
const formattedData = formatMetricData(newData);
|
||||
setChartData(prev => {
|
||||
const now = Date.now();
|
||||
const cutoffTime = now - TIME_WINDOW_MS;
|
||||
|
||||
// Фильтруем старые точки (старше TIME_WINDOW_MS)
|
||||
const filteredPrev = prev.filter(point => point.timestamp >= cutoffTime);
|
||||
|
||||
// Добавляем новые точки
|
||||
const newPoints = formatMetricData(newData)
|
||||
.filter(point => point.timestamp >= cutoffTime);
|
||||
|
||||
// Объединяем и удаляем дубликаты
|
||||
const mergedData = [...filteredPrev, ...newPoints]
|
||||
const newChartData = [...prev, ...formattedData]
|
||||
.filter((v, i, a) => a.findIndex(t => t.timestamp === v.timestamp) === i)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
// Если точек слишком много, равномерно прореживаем
|
||||
if (mergedData.length > MAX_POINTS) {
|
||||
const step = Math.ceil(mergedData.length / MAX_POINTS);
|
||||
return mergedData.filter((_, index) => index % step === 0);
|
||||
}
|
||||
|
||||
return mergedData;
|
||||
.slice(-200);
|
||||
return newChartData;
|
||||
});
|
||||
},
|
||||
1000, // Уменьшаем интервал обновления до 1 секунды
|
||||
5000,
|
||||
{
|
||||
...filters,
|
||||
...(device && { device }),
|
||||
|
|
@ -191,7 +149,6 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
|||
);
|
||||
};
|
||||
|
||||
|
||||
const stopRealtimeUpdates = () => {
|
||||
setIsLiveUpdating(false);
|
||||
metricsService.unsubscribeFromMetric(getSubscriptionKey());
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import menuData from "../TreeChart/menuData.json";
|
|||
import SidebarMenuWrapper from "./SidebarMenuWrapper";
|
||||
import MetricTabContent from "./MetricTabContent";
|
||||
import ProfileMenu from "../UI/ProfileMenu";
|
||||
import AIAnalysisButton from "../UI/AIAnalysisButton";
|
||||
|
||||
const DashboardContainer = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
|
|
@ -141,13 +140,9 @@ const Dashboard = ({ isDarkMode, setIsDarkMode, user, onLogout }) => {
|
|||
top: 12,
|
||||
right: 20,
|
||||
zIndex: (theme) => theme.zIndex.tooltip + 10,
|
||||
pointerEvents: 'auto', //ВРЕМЕННОЕ РАСПОЛОЖЕНИЕ КНОПКИ
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
alignItems: 'center'
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
<AIAnalysisButton />
|
||||
<ProfileMenu user={user} onLogout={onLogout} />
|
||||
</Box>
|
||||
|
||||
|
|
|
|||
|
|
@ -46,11 +46,11 @@ const SidebarFooter = ({
|
|||
const handleSettingsClose = () => {
|
||||
setSettingsOpen(false);
|
||||
};
|
||||
/*console.log('SidebarFooter user with role:', {
|
||||
console.log('SidebarFooter user with role:', {
|
||||
...user,
|
||||
hasRole: 'role' in user,
|
||||
roleValue: user?.role
|
||||
}); */
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<FooterList>
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
/*import React, { useState } from 'react';
|
||||
import { Button, CircularProgress, Alert, Box } from '@mui/material';
|
||||
import axios from 'axios';
|
||||
|
||||
const AIAnalysisButton = ({ onAnalysisComplete }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [result, setResult] = useState(null);
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/clickhouse/send-to-ai');
|
||||
setResult(response.data);
|
||||
if (onAnalysisComplete) {
|
||||
onAnalysisComplete(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleAnalyze}
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
sx={{
|
||||
minWidth: '180px',
|
||||
backgroundColor: '#4caf50',
|
||||
'&:hover': {
|
||||
backgroundColor: '#388e3c',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? 'Analyzing...' : 'AI Analysis'}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>{error}</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIAnalysisButton; */
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button, CircularProgress, Alert, Box } from '@mui/material';
|
||||
import axios from 'axios';
|
||||
|
||||
const AIAnalysisButton = ({ onAnalysisComplete }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [result, setResult] = useState(null);
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
// 1. Получаем данные из ClickHouse
|
||||
const metricsResponse = await axios.get('/api/clickhouse');
|
||||
|
||||
// 2. Отправляем в AI API
|
||||
const aiResponse = await axios.post(
|
||||
'/ai-api/api/metrics/rest',
|
||||
metricsResponse.data,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setResult(aiResponse.data);
|
||||
if (onAnalysisComplete) {
|
||||
onAnalysisComplete(aiResponse.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Детали ошибки 422:", err.response?.data);
|
||||
setError(err.response?.data?.message || JSON.stringify(err.response?.data) || "Ошибка валидации данных");
|
||||
|
||||
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleAnalyze}
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
sx={{
|
||||
minWidth: '180px',
|
||||
backgroundColor: '#4caf50',
|
||||
'&:hover': {
|
||||
backgroundColor: '#388e3c',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? 'Отправка в AI...' : 'Проанализировать AI'}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result && !loading && (
|
||||
<Alert severity="success" sx={{ mt: 1 }}>
|
||||
Анализ завершен! Результат в консоли.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIAnalysisButton;
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import React from 'react';
|
||||
|
||||
export const RoleBasedRender = ({ user, allowedRoles, children }) => {
|
||||
// console.log('RoleBasedRender check:', {
|
||||
// user,
|
||||
// hasRole: user?.role,
|
||||
// allowedRoles,
|
||||
// hasAccess: user && allowedRoles.includes(user.role)
|
||||
// });
|
||||
console.log('RoleBasedRender check:', {
|
||||
user,
|
||||
hasRole: user?.role,
|
||||
allowedRoles,
|
||||
hasAccess: user && allowedRoles.includes(user.role)
|
||||
});
|
||||
|
||||
if (!user || !allowedRoles.includes(user.role)) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import axios from "axios"
|
||||
|
||||
export const checkAuth = async () => {
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import SystemStatusChart from "../../Charts/SystemStatusChart";
|
||||
import TreeTable from "../UI/TreeTable";
|
||||
import FlowChart from "../TreeChart/FlowChart";
|
||||
import { getStatusColor } from "../TreeChart/dataUtils";
|
||||
import SystemChart from "../../Charts/SystemChart";
|
||||
import MetricsAnalyzer from "./MetricsAnalyzer"; // Импортируем новый компонент
|
||||
|
||||
const TabContent = ({ activeTab, tabs, statusHistories, treeData1, tabContent, handleOpenTab }) => {
|
||||
const countStatuses = (data) => {
|
||||
|
|
@ -16,52 +17,24 @@ const TabContent = ({ activeTab, tabs, statusHistories, treeData1, tabContent, h
|
|||
}
|
||||
};
|
||||
|
||||
if (data) countRecursive(data);
|
||||
countRecursive(data);
|
||||
return counts;
|
||||
};
|
||||
|
||||
if (activeTab === "Главная") {
|
||||
const statusCounts = countStatuses(treeData1);
|
||||
|
||||
// Конфигурация для метрики серверов (с несколькими линиями)
|
||||
const serverMetric = {
|
||||
name: "zvks_server_li",
|
||||
title: "Надежность системы",
|
||||
description: "Уровень надежности системы",
|
||||
multipleLines: true,
|
||||
lineKey: "device",
|
||||
ranges: [
|
||||
{ value: 15, color: 'green', label: 'Оптимально' },
|
||||
{ value: 10, color: 'orange', label: 'Предупреждение' },
|
||||
{ value: 5, color: 'red', label: 'Критично' }
|
||||
]
|
||||
};
|
||||
|
||||
// Конфигурация для метрики приложений (одна линия)
|
||||
const appMetric = {
|
||||
name: "zvks_application_li",
|
||||
title: "Функциональность системы",
|
||||
description: "Уровень функциональности системы",
|
||||
multipleLines: false
|
||||
};
|
||||
const statusCounts = treeData1 ? countStatuses(treeData1) : { green: 0, yellow: 0, orange: 0, red: 0 };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ textAlign: 'center' }}>Общий мониторинг состояния системы</h2>
|
||||
<div>
|
||||
<div style={{ display: 'inline-block', width: '48%', marginRight: '2%' }}>
|
||||
<label>Надежность серверов</label>
|
||||
<SystemChart
|
||||
metricInfo={serverMetric}
|
||||
chartHeight={580}
|
||||
/>
|
||||
<label>Надежность системы</label>
|
||||
<SystemStatusChart data={statusHistories.history1} />
|
||||
</div>
|
||||
<div style={{ display: 'inline-block', width: '48%' }}>
|
||||
<label>Функциональность приложений</label>
|
||||
<SystemChart
|
||||
metricInfo={appMetric}
|
||||
chartHeight={580}
|
||||
/>
|
||||
<label>Функциональность системы</label>
|
||||
<SystemStatusChart data={statusHistories.history2} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -92,6 +65,9 @@ const TabContent = ({ activeTab, tabs, statusHistories, treeData1, tabContent, h
|
|||
|
||||
<label>Статус компонентов системы</label>
|
||||
<TreeTable data={treeData1} />
|
||||
|
||||
{/* Добавляем кнопку анализа
|
||||
<MetricsAnalyzer />*/}
|
||||
</div>
|
||||
);
|
||||
} else if (activeTab === "Визуализация") {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@ import { defineConfig } from 'vite'
|
|||
import react from '@vitejs/plugin-react'
|
||||
import svgr from 'vite-plugin-svgr'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), svgr()],
|
||||
plugins: [
|
||||
react(),
|
||||
svgr()
|
||||
],
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: ['dev.msf.enode', 'demo-msf.kis-npo.ru'],
|
||||
allowedHosts: ['dev.msf.enode', 'demo-msf.kis-npo.ru']
|
||||
}
|
||||
});
|
||||
})
|
||||
Loading…
Reference in New Issue