Compare commits

...

14 Commits

Author SHA1 Message Date
Vladislav Drozdov d70c7673b4 Merge pull request 'redisign' (#40) from redisign into rc
test-org/trust-module-frontend/pipeline/pr-main Build succeeded
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/40
2025-05-28 14:31:44 +03:00
DmitriyA 8223cc4a27 Added environment variables
test-org/trust-module-frontend/pipeline/pr-main This commit looks good Details
test-org/trust-module-frontend/pipeline/pr-rc This commit looks good Details
2025-05-28 01:47:18 -04:00
DmitriyA 4088dacba4 added logo 2025-05-28 01:36:11 -04:00
DmitriyA 08e2c24a63 tabs and charts update 2025-05-27 22:14:48 -04:00
DmitriyA efd8532ac3 automatic generation of tabs for the sidebar menu 2025-05-27 21:40:59 -04:00
DmitriyA 069cea21b0 automatic formation of the side menu 2025-05-27 20:45:30 -04:00
DmitriyA 2b79159d35 Created a new chart 2025-05-22 06:29:58 -04:00
DmitriyA 4dfd972615 graph refactor 2025-05-05 09:11:48 -04:00
DmitriyA b9a2be4860 prepared the graph for refactoring and added an indicator 2025-04-28 09:27:07 -04:00
DmitriyA d5aa312104 Added the logo to the side menu 2025-04-23 10:19:29 -04:00
DmitriyA bbbcd932ad improved the design 2025-04-23 09:54:17 -04:00
DmitriyA 6fd5d1aed2 fixed a bug with tabs 2025-04-23 08:48:09 -04:00
DmitriyA 40d8046617 modified the skeleton MUI 2025-04-23 08:25:15 -04:00
DmitriyA e47161acd1 fixed a bug with collapsing the menu, improved the MUI skeleton, fixed several visual bugs 2025-04-22 08:59:35 -04:00
36 changed files with 1823 additions and 698 deletions

View File

@ -1,11 +1,10 @@
import React, { useState, useMemo, useEffect } from "react";
import { ThemeProvider, CssBaseline, Switch, Box, CircularProgress } from "@mui/material";
import { ThemeProvider, CssBaseline, Switch, Box, CircularProgress, Typography } from "@mui/material";
import Dashboard from "./Components/Layout/Dashboard";
import LoginModal from "./Components/UI/LoginModal";
import { lightTheme, darkTheme } from "./Style/theme";
import Logo from './assets/images/logo.svg?react';
import { checkAuth } from "./Components/UI/auth";
import axios from 'axios';
function App() {
const [authState, setAuthState] = useState({
@ -24,13 +23,11 @@ function App() {
const verifyAuth = async () => {
try {
const authStatus = await checkAuth();
setAuthState({
isAuthenticated: authStatus.isAuthenticated,
isLoading: false,
user: authStatus.user || null
});
setShowLoginModal(!authStatus.isAuthenticated);
} catch (error) {
console.error('Auth verification error:', error);
@ -42,7 +39,6 @@ function App() {
setShowLoginModal(true);
}
};
verifyAuth();
}, []);
@ -57,15 +53,15 @@ function App() {
const handleLogout = async () => {
try {
await axios.get(`${import.meta.env.VITE_BACK_URL}/api/metrics`, {
method: 'POST',
credentials: 'include'
await axios.post(`${import.meta.env.VITE_BACK_URL}/api/auth/logout`, null, {
withCredentials: true, // чтобы отправлялись куки
});
localStorage.removeItem('access_token');
setAuthState({
isAuthenticated: false,
isLoading: false,
user: null
user: null,
});
setShowLoginModal(true);
} catch (error) {
@ -73,20 +69,25 @@ function App() {
}
};
// Полноэкранный лоадер во время проверки авторизации
if (authState.isLoading) {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{
position: 'fixed',
top: 0, left: 0, right: 0, bottom: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
flexDirection: 'column',
gap: 2
zIndex: 9999,
bgcolor: 'background.default'
}}>
<CircularProgress />
<p>Проверка авторизации...</p>
<Typography sx={{ mt: 2 }}>
Проверка авторизации...
</Typography>
</Box>
</ThemeProvider>
);
@ -95,26 +96,20 @@ function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{!authState.isAuthenticated && showLoginModal ? (
{!authState.isAuthenticated ? (
<>
<Box
component="div"
sx={{
<Box sx={{
position: "fixed",
top: 24,
left: "50%",
transform: "translateX(-50%)",
zIndex: 1200,
'& svg': {
width: 400,
height: 'auto'
}
}}
>
'& svg': { width: 400, height: 'auto' }
}}>
<Logo />
</Box>
<LoginModal
open={showLoginModal}
onLogin={handleLogin}
onClose={() => setShowLoginModal(false)}
/>
@ -124,19 +119,14 @@ function App() {
display: "flex",
height: "100vh",
overflow: "hidden",
bgcolor: "background.default",
color: "text.primary"
bgcolor: "background.default"
}}>
<Dashboard
user={authState.user}
onLogout={handleLogout}
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
/>
<Box sx={{ position: "absolute", top: 10, right: 10 }}>
<Switch
checked={isDarkMode}
onChange={() => setIsDarkMode((prev) => !prev)}
/>
</Box>
</Box>
)}
</ThemeProvider>

View File

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

View File

@ -1,6 +1,8 @@
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 часа
@ -51,7 +53,6 @@ const LineChartComponent = ({
? Object.keys(displayData[0]).filter(k => !['timestamp', 'time', 'fullTime'].includes(k))
: [];
// Функция для определения оптимального формата времени в зависимости от диапазона
const getTimeFormat = () => {
if (!data.length) return TIME_FORMATS.SHORT;
@ -77,7 +78,6 @@ const LineChartComponent = ({
const handleMouseDown = (e) => {
if (!e) return;
// Получаем индекс точки по координатам
const activeIndex = e.activeTooltipIndex;
if (activeIndex === undefined || activeIndex < 0 || activeIndex >= data.length) return;
@ -113,7 +113,6 @@ const LineChartComponent = ({
const startIndex = Math.min(selectionArea.startIndex, selectionArea.endIndex);
const endIndex = Math.max(selectionArea.startIndex, selectionArea.endIndex);
// Нормализуем индексы к диапазону [0, 1] для родительского компонента
const normalizedStart = startIndex / (data.length - 1);
const normalizedEnd = endIndex / (data.length - 1);
@ -152,7 +151,18 @@ const LineChartComponent = ({
};
if (!data.length) {
return <div style={{ padding: '20px', textAlign: 'center' }}>Нет данных для отображения</div>;
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 (

View File

@ -6,6 +6,37 @@ import { ConnectionStatusIndicator } from './Components/ConnectionStatusIndicato
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);
@ -70,15 +101,14 @@ const PrometheusChart = ({ metricName }) => {
}, []);
const processMetricsData = useCallback((response) => {
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;
setChartData(prev => {
const newData = { ...(prev || {}) };
const newData = {};
const rangeSeconds = useCustomRange
? (endDate.getTime() - startDate.getTime()) / 1000
: selectedRange.value;
@ -87,10 +117,8 @@ const PrometheusChart = ({ metricName }) => {
const instance = item.instance || 'default';
if (!newData[instance]) newData[instance] = [];
// Унифицированная конвертация timestamp
let timestamp;
if (typeof item.timestamp === 'number') {
// Определяем, в секундах или миллисекундах пришел timestamp
timestamp = item.timestamp > 1e12 ? item.timestamp : item.timestamp * 1000;
} else {
timestamp = Date.now();
@ -102,21 +130,34 @@ const PrometheusChart = ({ metricName }) => {
newData[instance].push({
time: formattedTime.display,
fullTime: formattedTime.fullDisplay,
value: value,
timestamp: timestamp
value,
timestamp
});
});
// Сортируем и ограничиваем данные
Object.keys(newData).forEach(instance => {
newData[instance] = newData[instance]
.sort((a, b) => a.timestamp - b.timestamp)
.slice(-1000);
});
return newData;
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;
@ -166,7 +207,7 @@ const PrometheusChart = ({ metricName }) => {
processMetricsData({
metric: metricName,
data: processedData
});
}, true);
}
} catch (error) {
console.error('Ошибка при получении кастомных данных:', error);
@ -309,7 +350,9 @@ const PrometheusChart = ({ metricName }) => {
useEffect(() => {
// Обработчик данных с сервера
const handleMetricsData = (data) => {
if (!useCustomRange) {
processMetricsData({ metric: metricName, data });
}
};
// Подписываемся на обновления метрики
@ -328,7 +371,8 @@ const PrometheusChart = ({ metricName }) => {
clearInterval(intervalRef.current);
}
};
}, [metricName, processMetricsData]);
}, [metricName, useCustomRange, processMetricsData]);
useEffect(() => {
if (useCustomRange && !isSelectingRange) {
@ -395,11 +439,21 @@ const PrometheusChart = ({ metricName }) => {
}, [selectedGraphRange, chartData, interpolateData]);
if (chartData === null) {
return <div style={{ padding: '20px', textAlign: 'center' }}>Loading data...</div>;
return <ChartSkeleton />;
}
if (Object.keys(chartData).length === 0) {
return <div style={{ padding: '20px', textAlign: 'center' }}>No data available</div>;
return (
<Box sx={{
backgroundColor: '#fff',
borderRadius: '8px',
padding: '20px',
marginBottom: '20px',
textAlign: 'center'
}}>
No data available
</Box>
);
}
return (

View File

@ -1,4 +1,3 @@
// src/services/WebSocketManager.js
import { io } from 'socket.io-client';
class WebSocketManager {
@ -7,13 +6,16 @@ class WebSocketManager {
this.subscribers = new Map();
this.connectionStatus = 'disconnected';
this.connectionCallbacks = new Set();
this.connecting = false;
}
connect() {
if (this.socket && (this.socket.connected || this.socket.reconnecting)) {
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,
@ -24,11 +26,13 @@ class WebSocketManager {
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();
});
@ -50,7 +54,9 @@ class WebSocketManager {
}
subscribe(metricName, callback) {
if (!this.socket?.connected) {
this.connect();
}
if (!this.subscribers.has(metricName)) {
this.subscribers.set(metricName, new Set());

View File

@ -0,0 +1,97 @@
import React from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
const DateRangeSelector = ({
startDate,
endDate,
onStartDateChange,
onEndDateChange,
onApply
}) => {
return (
<div style={{
marginTop: 10,
backgroundColor: '#f5f5f5',
padding: '15px',
borderRadius: '4px'
}}>
<div style={{
marginBottom: '10px',
fontWeight: '500',
color: '#555'
}}>
Укажите диапазон дат:
</div>
<div style={{
display: 'flex',
gap: '10px',
flexWrap: 'wrap',
alignItems: 'flex-end'
}}>
<div style={{ flex: '1 1 200px' }}>
<DatePicker
selected={startDate}
onChange={onStartDateChange}
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={onEndDateChange}
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={onApply}
style={{
padding: '8px 16px',
backgroundColor: '#4a6baf',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
transition: 'background-color 0.2s',
flex: '0 0 auto',
height: '36px'
}}
onMouseOver={(e) => e.target.style.backgroundColor = '#3a5a9f'}
onMouseOut={(e) => e.target.style.backgroundColor = '#4a6baf'}
>
Применить
</button>
</div>
</div>
);
};
export default DateRangeSelector;

View File

@ -0,0 +1,68 @@
import React from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
const LineChartComponent = ({
data,
title,
description,
metaInfo,
dataKey = 'value',
lineColor = '#8884d8',
height = 400,
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%">
<LineChart
data={data}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
>
{showGrid && <CartesianGrid strokeDasharray="3 3" />}
<XAxis
dataKey="timestamp"
tickFormatter={customXAxisFormatter || ((timestamp) => new Date(timestamp).toLocaleTimeString())}
/>
{customYAxis || <YAxis />}
<Tooltip
content={customTooltip}
labelFormatter={(timestamp) => new Date(timestamp).toLocaleString()}
/>
{showLegend && <Legend />}
<Line
type="monotone"
dataKey={dataKey}
stroke={lineColor}
activeDot={{ r: 8 }}
name={title}
/>
{additionalLines.map((lineProps, index) => (
<Line key={index} {...lineProps} />
))}
</LineChart>
</ResponsiveContainer>
</div>
);
};
export default LineChartComponent;

View File

@ -0,0 +1,155 @@
import { io } from 'socket.io-client';
class MetricsService {
constructor(baseUrl) {
this.baseUrl = baseUrl || window.location.origin;
this.socket = null;
this.subscriptions = new Map();
this.pendingRequests = new Map();
window.addEventListener('beforeunload', () => {
this.cleanupAll();
});
}
connectWebSocket() {
if (this.socket) {
console.log('WebSocket already exists');
return;
}
console.log('Connecting WebSocket...');
this.socket = io(`${this.baseUrl.replace('http', 'ws')}/api/metrics-ws`, {
transports: ['websocket'],
withCredentials: true,
});
this.socket.on('connect', () => {
console.log('WebSocket connected');
// Восстанавливаем подписки при переподключении
this.subscriptions.forEach((_, metricKey) => {
const [metric, query] = metricKey.split('?');
const filters = this.parseFiltersFromKey(metricKey);
this.socket.emit('subscribe-metric', { metric, filters });
});
});
this.socket.on('disconnect', () => {
console.log('WebSocket disconnected');
this.socket = null;
});
this.socket.on('metrics-data', ({ metric, data, requestId }) => {
if (requestId && this.pendingRequests.has(requestId)) {
const { resolve } = this.pendingRequests.get(requestId);
resolve(data);
this.pendingRequests.delete(requestId);
return;
}
const callbacks = this.subscriptions.get(metric) || [];
callbacks.forEach(cb => cb(data));
});
this.socket.on('metrics-error', ({ error, requestId }) => {
if (requestId && this.pendingRequests.has(requestId)) {
const { reject } = this.pendingRequests.get(requestId);
reject(new Error(error));
this.pendingRequests.delete(requestId);
}
});
}
async fetchMetricsRange(metric, start, end, step = 15, filters = {}) {
return new Promise((resolve, reject) => {
this.connectWebSocket();
const requestId = `range-${Date.now()}`;
this.pendingRequests.set(requestId, { resolve, reject });
this.socket.emit('get-metrics', {
metric,
start,
end,
step,
filters,
isRangeQuery: true,
requestId
});
setTimeout(() => {
if (this.pendingRequests.has(requestId)) {
reject(new Error('Request timeout'));
this.pendingRequests.delete(requestId);
}
}, 30000);
});
}
subscribeToMetric(metricKey, callback, interval = 5000, filters = {}) {
this.connectWebSocket();
const alreadySubscribed = this.subscriptions.has(metricKey);
const callbacks = this.subscriptions.get(metricKey) || [];
callbacks.push(callback);
this.subscriptions.set(metricKey, callbacks);
if (!alreadySubscribed) {
// Разделяем metricKey на метрику и фильтры
const [metric] = metricKey.split('?');
this.socket.emit('subscribe-metric', {
metric,
interval,
filters
});
}
return () => this.unsubscribeFromMetric(metricKey, callback);
}
unsubscribeFromMetric(metricKey, callback) {
const callbacks = this.subscriptions.get(metricKey) || [];
const filtered = callbacks.filter(cb => cb !== callback);
if (filtered.length === 0) {
this.subscriptions.delete(metricKey);
if (this.socket && this.socket.connected) {
const [metric] = metricKey.split('?');
this.socket.emit('unsubscribe-metric', { metric });
}
} else {
this.subscriptions.set(metricKey, filtered);
}
}
parseFiltersFromKey(metricKey) {
const parts = metricKey.split('?');
if (parts.length < 2) return {};
return parts[1].split('&').reduce((acc, pair) => {
const [key, value] = pair.split('=');
if (key && value) acc[key] = value;
return acc;
}, {});
}
cleanupAll() {
if (this.socket && this.socket.connected) {
this.socket.emit('unsubscribe-all');
}
this.subscriptions.clear();
this.disconnectWebSocket();
}
disconnectWebSocket() {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
}
}
// Создаем экземпляр сервиса
const metricsService = new MetricsService(import.meta.env.VITE_BACK_URL);
export default metricsService;

View File

@ -0,0 +1,208 @@
import React, { useState, useEffect } from 'react';
import LineChartComponent from './Components/LineChartComponent';
import DateRangeSelector from './Components/DateRangeSelector';
import metricsService from './Components/metricsService';
import { Button, Radio, message, Tag } from 'antd';
import moment from 'moment';
const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => {
const {
name: metricName,
filters = {},
title = metricName,
description,
context = {}
} = metricInfo || {};
const { device, source_id: module } = context;
const [chartData, setChartData] = 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 getSubscriptionKey = () => {
const filterParts = [];
if (device) filterParts.push(`device=${device}`);
if (module) filterParts.push(`source_id=${module}`);
return `${metricName}${filterParts.length ? `?${filterParts.join('&')}` : ''}`;
};
const formatMetricData = (dataArray) => {
return dataArray
.map(item => ({
timestamp: item.timestamp,
value: parseFloat(item.value),
name: item.__name__ || metricName,
status: item.status,
device: item.device?.trim() || null,
source_id: item.source_id || null
}))
.sort((a, b) => a.timestamp - b.timestamp);
};
const fetchHistoricalData = async (start, end) => {
setIsLoading(true);
setError(null);
try {
const extendedFilters = {
...filters,
...(device && { device: device.toString() }),
...(module && { source_id: module.toString() })
};
const data = await metricsService.fetchMetricsRange(
metricName,
Math.floor(start.getTime() / 1000),
Math.floor(end.getTime() / 1000),
15,
extendedFilters
);
const formattedData = formatMetricData(data);
if (formattedData.length > 0) {
setMetricMeta({
type: data[0]?.type,
description: data[0]?.description || description,
instance: data[0]?.instance,
job: data[0]?.job
});
}
setChartData(formattedData);
} 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() - 3600 * 1000);
fetchHistoricalData(start, end).finally(() => setIsLoading(false));
return metricsService.subscribeToMetric(
getSubscriptionKey(),
(newData) => {
const formattedData = formatMetricData(newData);
setChartData(prev => {
const newChartData = [...prev, ...formattedData]
.filter((v, i, a) => a.findIndex(t => t.timestamp === v.timestamp) === i)
.slice(-200);
return newChartData;
});
},
5000,
{
...filters,
...(device && { device }),
...(module && { source_id: module })
}
);
};
const stopRealtimeUpdates = () => {
setIsLiveUpdating(false);
metricsService.unsubscribeFromMetric(getSubscriptionKey());
};
const handleCustomRangeApply = () => {
if (startDate && endDate) {
fetchHistoricalData(startDate, endDate);
}
};
useEffect(() => {
let unsubscribe;
if (mode === 'realtime') {
unsubscribe = startRealtimeUpdates();
} else {
stopRealtimeUpdates();
fetchHistoricalData(startDate, endDate);
}
return () => {
if (unsubscribe) unsubscribe();
stopRealtimeUpdates();
};
}, [mode, metricName, device, module]);
const metaInfo = [
metricMeta.instance && `Instance: ${metricMeta.instance}`,
metricMeta.job && `Job: ${metricMeta.job}`,
metricMeta.type && `Type: ${metricMeta.type}`
].filter(Boolean).join(' | ');
return (
<div>
<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' && isLiveUpdating && (
<Button
type="primary"
danger
onClick={() => setMode('historical')}
style={{ marginTop: 10 }}
>
Остановить обновление
</Button>
)}
</div>
{device && <Tag color="geekblue">Устройство: {device}</Tag>}
{module && <Tag color="purple">Модуль: {module.split('$')[1]}</Tag>}
{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,
module
}}
/>
)}
</div>
);
};
export default PrometheusChart;

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect } from "react";
import { Box, styled } from "@mui/material";
import SidebarMenu from "./SidebarMenu";
import { statusManager1, statusManager2 } from "../TreeChart/dataUtils";
import generateTabContent from "../TreeChart/tabContent";
import CustomTabs from "../UI/MUItabs";
@ -8,8 +7,10 @@ import useTabs from "../hooks/useTabs";
import useSidebarResize from "../hooks/useSidebarResize";
import TabContent from "../hooks/TabContent";
import menuData from "../TreeChart/menuData.json";
import SidebarMenuWrapper from "./SidebarMenuWrapper";
import MetricTabContent from "./MetricTabContent"
// Создаем стилизованные компоненты
// Стилизованные компоненты
const DashboardContainer = styled(Box)(({ theme }) => ({
display: 'flex',
height: '100vh',
@ -19,46 +20,25 @@ const DashboardContainer = styled(Box)(({ theme }) => ({
color: theme.palette.text.primary,
}));
const SidebarWrapper = styled(Box)(({ theme }) => ({
position: 'relative',
backgroundColor: theme.palette.custom.sidebar,
color: theme.palette.custom.sidebarText,
}));
const SidebarResizer = styled(Box)(({ theme }) => ({
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '4px',
cursor: 'col-resize',
'&:hover': {
backgroundColor: theme.palette.primary.main,
},
}));
const MainContent = styled(Box)(({ theme }) => ({
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(2.5), // 20px
padding: theme.spacing(2.5),
overflow: 'auto',
backgroundColor: theme.palette.background.default,
}));
const Content = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.custom.modalBackground,
//padding: theme.spacing(2.5),
borderRadius: '10px',
//boxShadow: theme.shadows[2],
maxWidth: '100%',
overflow: 'auto',
color: theme.palette.custom.modalText,
}));
const Dashboard = () => {
const Dashboard = ({ isDarkMode, setIsDarkMode }) => {
const { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab } = useTabs("Главная");
const { sidebarWidth, startResizing } = useSidebarResize(250);
const [tabContent, setTabContent] = useState({});
const [treeData1, setTreeData1] = useState(menuData);
const [treeData2, setTreeData2] = useState(menuData);
@ -100,29 +80,90 @@ const Dashboard = () => {
return () => clearInterval(interval);
}, [treeData1, treeData2]);
const handleMenuSelect = (item) => {
const tabId = `tab_${item.id}`;
const tabTitle = item.title || 'Новая вкладка';
// Если это метрика, создаём специальный контент с графиком
const tabContent = item.metric
? <MetricTabContent
metricInfo={{
name: item.metric,
filters: item.filters,
title: item.title,
description: item.description,
context: {
device: item.filters?.device,
source_id: item.filters?.source_id,
parent: item // для построения пути
}
}}
/>
: <div style={{ padding: 20 }}>Контент для <strong>{item.title}</strong></div>;
const existingTab = tabs.find(tab => tab.id === tabId);
if (!existingTab) {
const newTab = {
id: tabId,
title: tabTitle,
content: tabContent,
type: item.metric ? 'metric' : 'menuItem',
metric: item.metric,
filters: item.filters
};
handleOpenTab(newTab);
} else {
setActiveTab(tabId);
}
};
// Вспомогательная функция для получения всех дочерних элементов
const getAllChildren = (node) => {
let children = [];
if (node.items && node.items.length > 0) {
node.items.forEach((child) => {
children.push(child);
children = children.concat(getAllChildren(child));
});
}
return children;
};
return (
<DashboardContainer>
{/* Сайдбар */}
<SidebarWrapper sx={{ width: sidebarWidth }}>
<SidebarMenu
data={treeData1}
onOpenTab={handleOpenTab}
sidebarWidth={sidebarWidth}
startResizing={startResizing}
<SidebarMenuWrapper
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
onMenuSelect={handleMenuSelect}
/>
<SidebarResizer onMouseDown={startResizing} />
</SidebarWrapper>
{/* Основной контент */}
<MainContent>
<Box sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
overflow: 'hidden'
}}>
{/* Вкладки */}
<Box sx={{
borderBottom: 1,
borderColor: 'divider',
backgroundColor: 'background.default',
zIndex: 1,
transform: 'translateY(31px)'
}}>
<CustomTabs
tabs={tabs}
activeTab={activeTab}
onTabClick={setActiveTab}
onCloseTab={handleCloseTab}
/>
</Box>
{/* Остальной контент */}
<MainContent>
{/* Контент вкладки */}
<Content>
<TabContent
@ -131,9 +172,11 @@ const Dashboard = () => {
treeData1={treeData1}
tabContent={tabContent}
handleOpenTab={handleOpenTab}
tabs={tabs}
/>
</Content>
</MainContent>
</Box>
</DashboardContainer>
);
};

View File

@ -0,0 +1,25 @@
import React, { useEffect } from 'react';
import PrometheusChart from '../../Charts2/PrometheusChart';
import metricsService from '../../Charts2/Components/metricsService';
const MetricTabContent = ({ metricInfo }) => {
// Очистка подписок при закрытии вкладки
useEffect(() => {
return () => {
if (metricInfo?.name) {
metricsService.unsubscribeFromMetric(metricInfo.name);
}
};
}, [metricInfo?.name]);
return (
<div style={{ padding: 16 }}>
<PrometheusChart
metricInfo={metricInfo}
chartHeight={600}
/>
</div>
);
};
export default MetricTabContent;

View File

@ -1,3 +1,4 @@
// SidebarMenu.jsx
import React, { useState, useEffect } from "react";
import {
Drawer,
@ -6,56 +7,54 @@ import {
styled,
IconButton,
Tooltip,
Box
Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField
} from "@mui/material";
import {
ChevronLeft,
ChevronRight,
Menu as MenuIcon
} from "@mui/icons-material";
import MenuItem from "./SidebarMenuComponents/MenuItem";
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
import { statusManager1 } from "../TreeChart/dataUtils";
import useSidebarResize from "../hooks/useSidebarResize";
import ChevronLeft from '@mui/icons-material/ChevronLeft';
import ChevronRight from '@mui/icons-material/ChevronRight';
import LogoFull from '../../assets/images/logo.svg?react';
import LogoSmall from '../../assets/images/system_monitor_icon.svg?react';
const SidebarResizer = styled('div')(({ theme }) => ({
width: "5px",
cursor: "ew-resize",
backgroundColor: 'transparent',
height: "100%",
position: "absolute",
right: 0,
top: 0,
transition: 'background-color 0.2s',
'&:hover': {
backgroundColor: theme.palette.primary.main,
},
zIndex: 2
}));
const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => {
const SidebarMenu = ({
data,
isDarkMode,
setIsDarkMode,
onEditItem,
onSelectItem,
editModalOpen,
editingItem,
onCloseEditModal,
onSaveChanges
}) => {
const [collapsed, setCollapsed] = useState(false);
const { sidebarWidth, startResizing } = useSidebarResize(290);
const [hovered, setHovered] = useState(false);
const [menuData, setMenuData] = useState(data);
// Обновляем статусы при изменении данных
useEffect(() => {
if (data) {
// Создаем глубокую копию данных, чтобы не мутировать исходные
const dataCopy = JSON.parse(JSON.stringify(data));
statusManager1.updateStatuses(dataCopy);
setMenuData(dataCopy);
}
}, [data]);
const handleToggleCollapse = () => {
setCollapsed(!collapsed);
};
const handleSelectItem = (id, title, children) => {
onOpenTab(id, title, children);
};
const drawerWidth = collapsed ? 64 : sidebarWidth;
const SidebarResizer = styled('div')(({ theme }) => ({
width: '4px',
cursor: 'ew-resize',
backgroundColor: 'transparent',
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
height: '100%',
position: 'absolute',
top: 0,
right: 0,
zIndex: 1000,
}));
return (
<Box
@ -63,17 +62,17 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => {
onMouseLeave={() => setHovered(false)}
sx={{
position: 'relative',
width: drawerWidth,
width: collapsed ? 64 : sidebarWidth,
transition: 'width 0.3s ease',
}}
>
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
width: collapsed ? 64 : sidebarWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: drawerWidth,
width: collapsed ? 64 : sidebarWidth,
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
@ -85,81 +84,150 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => {
},
}}
>
{/* Кнопка сворачивания/разворачивания */}
{/* Заголовок с логотипом */}
<Box sx={{
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
justifyContent: 'center', // Центрируем содержимое
p: 1,
borderBottom: '1px solid',
borderColor: 'divider',
backgroundColor: 'custom.sidebar'
backgroundColor: 'custom.sidebar',
height: 80, // Фиксированная высота
position: 'relative' // Для позиционирования кнопки
}}>
{/* Логотип (занимает все пространство) */}
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
'& svg': {
width: '100%',
height: '100%',
padding: collapsed ? '8px' : '12px',
objectFit: 'contain'
}
}}>
{collapsed ? (
<LogoSmall style={{
color: 'inherit' // Наследует цвет темы
}} />
) : (
<LogoFull style={{
color: 'inherit' // Наследует цвет темы
}} />
)}
</Box>
{/* Кнопка сворачивания (абсолютное позиционирование) */}
<Tooltip title={collapsed ? "Развернуть меню" : "Свернуть меню"}>
<IconButton
onClick={handleToggleCollapse}
size="small"
sx={{
color: 'custom.sidebarText',
'&:hover': {
backgroundColor: 'custom.sidebarHover',
}
'&:hover': { backgroundColor: 'custom.sidebarHover' },
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)'
}}
>
{collapsed ? (
hovered ? <ChevronRight /> : <MenuIcon />
) : (
<ChevronLeft />
)}
{collapsed ? <ChevronRight /> : <ChevronLeft />}
</IconButton>
</Tooltip>
</Box>
{/* Содержимое меню */}
<Box sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
<List sx={{
overflowY: 'auto',
overflowX: 'hidden',
flex: '1 1 auto'
}}>
{!collapsed && (
<Typography
variant="h6"
sx={{
p: 2,
fontWeight: 'bold',
textAlign: 'center'
}}
>
Меню
</Typography>
)}
{menuData && (
{/* Основное содержимое меню */}
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<List sx={{ overflowY: 'auto', overflowX: 'hidden', flex: '1 1 auto' }}>
{data && (
<MenuItem
item={menuData}
onSelectItem={handleSelectItem}
item={data}
collapsed={collapsed}
level={0}
onEdit={onEditItem}
onSelectItem={onSelectItem}
/>
)}
</List>
{/* Футер */}
{!collapsed && (
<SidebarFooter />
)}
<SidebarFooter
collapsed={collapsed}
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
/>
</Box>
{/* Ресайзер */}
{!collapsed && (
<SidebarResizer onMouseDown={startResizing} />
)}
</Drawer>
{/* Модальное окно редактирования */}
<EditMenuItemDialog
open={editModalOpen}
item={editingItem}
onClose={onCloseEditModal}
onSave={onSaveChanges}
/>
</Box>
);
};
const EditMenuItemDialog = ({ open, item, onClose, onSave }) => {
const [formData, setFormData] = useState(item || {});
useEffect(() => {
setFormData(item || {});
}, [item]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = () => {
onSave(formData);
};
if (!item) return null;
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Редактирование элемента меню</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
<TextField
fullWidth
label="Название"
name="title"
value={formData.title || ''}
onChange={handleChange}
sx={{ mb: 2 }}
/>
<TextField
fullWidth
label="ID"
name="id"
value={formData.id || ''}
onChange={handleChange}
disabled
sx={{ mb: 2 }}
/>
{/* Дополнительные поля для редактирования */}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Отмена</Button>
<Button onClick={handleSubmit} variant="contained" color="primary">
Сохранить
</Button>
</DialogActions>
</Dialog>
);
};
export default SidebarMenu;

View File

@ -1,21 +1,23 @@
import React from "react";
// MenuItem.jsx
import React, { useState } from "react";
import {
ListItem,
ListItemIcon,
ListItemText,
Collapse,
List,
styled
styled,
IconButton,
Menu,
MenuItem as MuiMenuItem
} from "@mui/material";
import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
import { getStatusColor } from "../../TreeChart/dataUtils";
import { ExpandLess, ExpandMore, Folder, FolderOpen, Edit } from "@mui/icons-material";
import StatusIndicator from "./StatusIndicator";
const StyledListItem = styled(ListItem)(({ theme, level }) => ({
cursor: "pointer",
paddingLeft: theme.spacing(2 + level * 2),
position: 'relative', // Добавляем для позиционирования индикатора
position: 'relative',
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
@ -24,59 +26,56 @@ const StyledListItem = styled(ListItem)(({ theme, level }) => ({
},
}));
const StatusIndicator = styled('div')(({ theme, status }) => ({
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '4px',
backgroundColor: status ? getStatusColor(status) : 'transparent',
borderTopRightRadius: '4px',
borderBottomRightRadius: '4px',
}));
const IconWrapper = styled('div')(({ theme }) => ({
cursor: "pointer",
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(0.5),
'&:hover': {
backgroundColor: theme.palette.action.selected,
},
}));
const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
const [isOpen, setIsOpen] = React.useState(false);
const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
const [isOpen, setIsOpen] = useState(false);
const [contextMenu, setContextMenu] = useState(null);
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
const handleContextMenu = (e) => {
e.preventDefault();
setContextMenu(
contextMenu === null
? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 }
: null
);
};
const handleCloseContextMenu = () => {
setContextMenu(null);
};
const handleEditClick = () => {
onEdit(item);
handleCloseContextMenu();
};
const handleToggle = (e) => {
e.stopPropagation();
setIsOpen(!isOpen);
};
const handleOpenTab = (e) => {
e.stopPropagation();
const allChildren = getAllChildren(item);
onSelectItem(item.id, item.title, allChildren);
const handleClick = () => {
if (onSelectItem) {
onSelectItem(item);
}
};
return (
<>
<StyledListItem
component="div"
onClick={hasChildren ? handleToggle : handleOpenTab}
onClick={hasChildren ? handleToggle : handleClick}
onContextMenu={handleContextMenu}
level={level}
sx={{
pl: collapsed ? 2 : 2 + level * 2,
justifyContent: collapsed ? 'center' : 'flex-start',
}}
>
{/* Индикатор статуса */}
{!collapsed && <StatusIndicator status={item.status} />}
<ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
<IconWrapper onClick={handleOpenTab}>
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
</IconWrapper>
</ListItemIcon>
{!collapsed && (
@ -88,18 +87,47 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
}}
/>
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
{level > 0 && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleEditClick();
}}
sx={{ ml: 1 }}
>
<Edit fontSize="small" />
</IconButton>
)}
</>
)}
</StyledListItem>
<Menu
open={contextMenu !== null}
onClose={handleCloseContextMenu}
anchorReference="anchorPosition"
anchorPosition={
contextMenu !== null
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
: undefined
}
>
<MuiMenuItem onClick={handleEditClick}>
<Edit fontSize="small" sx={{ mr: 1 }} /> Редактировать
</MuiMenuItem>
</Menu>
{hasChildren && !collapsed && (
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{item.items.map((child, index) => (
<MenuItem
key={index}
key={child.id ?? index}
item={child}
onSelectItem={onSelectItem}
onEdit={onEdit}
level={level + 1}
collapsed={collapsed}
/>
@ -111,15 +139,4 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
);
};
const getAllChildren = (node) => {
let children = [];
if (node.items && node.items.length > 0) {
node.items.forEach((child) => {
children.push(child);
children = children.concat(getAllChildren(child));
});
}
return children;
};
export default MenuItem;

View File

@ -1,9 +1,13 @@
import React from "react";
import { Brightness4, Brightness7 } from "@mui/icons-material";
import { IconButton, Tooltip } from "@mui/material";
import {
List,
ListItem,
ListItemText,
styled
styled,
Switch,
Box
} from "@mui/material";
const FooterList = styled(List)(({ theme }) => ({
@ -18,11 +22,15 @@ const FooterListItem = styled(ListItem)(({ theme }) => ({
backgroundColor: theme.palette.custom.sidebarHover,
},
padding: theme.spacing(1, 2),
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}));
const SidebarFooter = () => {
const SidebarFooter = ({ collapsed, isDarkMode, setIsDarkMode }) => {
return (
<FooterList>
{!collapsed && (
<FooterListItem button>
<ListItemText
primary="Помощь"
@ -32,7 +40,9 @@ const SidebarFooter = () => {
}}
/>
</FooterListItem>
<FooterListItem button>
)}
<FooterListItem>
{!collapsed && (
<ListItemText
primary="Настройка"
primaryTypographyProps={{
@ -40,6 +50,26 @@ const SidebarFooter = () => {
variant: 'body2'
}}
/>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title="Переключить тему">
<IconButton
size="small"
onClick={() => setIsDarkMode(!isDarkMode)}
sx={{ color: 'custom.sidebarText' }}
>
{isDarkMode ? <Brightness4 /> : <Brightness7 />}
</IconButton>
</Tooltip>
{!collapsed && (
<Switch
checked={isDarkMode}
onChange={() => setIsDarkMode(!isDarkMode)}
size="small"
/>
)}
</Box>
</FooterListItem>
</FooterList>
);

View File

@ -0,0 +1,104 @@
import React, { useState, useEffect } from 'react';
import SidebarMenu from './SidebarMenu';
import { Box, CircularProgress, Typography } from '@mui/material';
import axios from 'axios';
const SidebarMenuWrapper = ({ isDarkMode, setIsDarkMode, onMenuSelect }) => {
const [menuData, setMenuData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editingItem, setEditingItem] = useState(null);
const [editModalOpen, setEditModalOpen] = useState(false);
useEffect(() => {
const fetchMenuData = async () => {
try {
const response = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/menu/full`);
setMenuData(response.data); // axios хранит данные в response.data
} catch (err) {
console.error('Error fetching menu data:', err);
setError(err.message || 'Failed to fetch menu data');
} finally {
setLoading(false);
}
};
fetchMenuData();
}, []);
const handleSaveChanges = async (updatedItem) => {
try {
const response = await axios.put(
`${import.meta.env.VITE_BACK_URL}/api/menu/${updatedItem.id}`,
updatedItem,
{
headers: {
'Content-Type': 'application/json',
}
}
);
// Обновляем локальное состояние
const updateItemInTree = (items) => {
return items.map(item => {
if (item.id === updatedItem.id) {
return { ...item, ...updatedItem };
}
if (item.items) {
return { ...item, items: updateItemInTree(item.items) };
}
return item;
});
};
setMenuData(prev => ({
...prev,
items: updateItemInTree(prev.items),
}));
setEditModalOpen(false);
} catch (err) {
console.error('Error updating menu item:', err);
setError(err.response?.data?.message || err.message || 'Failed to update menu item');
}
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" height="100vh">
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box p={2}>
<Typography color="error">Error loading menu: {error}</Typography>
</Box>
);
}
if (!menuData) {
return null;
}
return (
<SidebarMenu
data={menuData}
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
onEditItem={(item) => {
setEditingItem(item);
setEditModalOpen(true);
}}
onSelectItem={onMenuSelect}
editModalOpen={editModalOpen}
editingItem={editingItem}
onCloseEditModal={() => setEditModalOpen(false)}
onSaveChanges={handleSaveChanges}
/>
);
};
export default SidebarMenuWrapper;

View File

@ -45,12 +45,10 @@ const FlowChart = ({ data }) => {
const findAndCollapseLastLevelParents = (items) => {
items.forEach(item => {
if (item.items && item.items.length > 0) {
// Проверяем, есть ли у детей свои дети
const hasGrandchildren = item.items.some(child =>
child.items && child.items.length > 0
);
// Если у детей нет своих детей - это родители последнего уровня
if (!hasGrandchildren) {
toggleNodeCollapse(item.id);
} else {

View File

@ -39,7 +39,7 @@ export const useDataParser = (nodePositions, collapsedNodes) => {
const baseLevelRadius = 150;
const traverse = (item, parentId = null, level = 0, angleStart = 0, angleEnd = 2 * Math.PI, parentRadius = 0) => {
if (!item || collapsedNodes[parentId]) return; // Пропускаем свёрнутые узлы
if (!item || collapsedNodes[parentId]) return;
const nodeId = item.id;
const items = item.items || [];
@ -58,7 +58,7 @@ export const useDataParser = (nodePositions, collapsedNodes) => {
data: {
...item,
label: item.title,
style: getNodeStyle(item, isLeaf), // Переносим стили в data
style: getNodeStyle(item, isLeaf),
hasChildren: items.length > 0,
collapsed: collapsedNodes[nodeId]
}
@ -88,7 +88,7 @@ export const useDataParser = (nodePositions, collapsedNodes) => {
const centerNode = {
id: data.id,
type: 'customNode', // Добавляем тип узла
type: 'customNode',
position: nodePositions[data.id] || { x: centerX, y: centerY },
style: getCenterNodeStyle(data),
data: { label: data.title, hasChildren: data.items.length > 0, collapsed: collapsedNodes[data.id] }

View File

@ -10,13 +10,13 @@ const NodeWrapper = memo(({ id, data, selected }) => {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden', // Чтобы текст не выходил за границы
textOverflow: 'ellipsis', // Добавляем многоточие если текст не помещается
whiteSpace: 'nowrap', // Запрещаем перенос строк
padding: '0 8px', // Горизонтальный padding для текста
boxSizing: 'border-box' // Учитываем padding в общей ширине
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
padding: '0 8px',
boxSizing: 'border-box'
}}
title={data.label} // Простой tooltip при наведении
title={data.label}
>
{/* Хендл для входящих соединений */}
<Handle

View File

@ -6,7 +6,7 @@ export const useFlowChart = (initialData) => {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [nodePositions, setNodePositions] = useState({});
const [collapsedNodes, setCollapsedNodes] = useState({}); // Добавили
const [collapsedNodes, setCollapsedNodes] = useState({});
const toggleNodeCollapse = useCallback((nodeId) => {
setCollapsedNodes((prev) => ({

View File

@ -2,7 +2,6 @@ import { useCallback } from 'react';
export const useNodeHandlers = (debouncedSetNodePositions) => {
const onNodeDrag = useCallback((event, node) => {
// Фиксируем позицию сразу при перемещении
node.position = {
x: Math.round(node.position.x),
y: Math.round(node.position.y)

View File

@ -1,48 +1,43 @@
const StatusManager = () => {
const getRandomStatus = () => {
const statuses = [
...Array(90).fill("green"), // 90% шанс
...Array(6).fill("yellow"), // 6% шанс
...Array(3).fill("orange"), // 3% шанс
...Array(1).fill("red"), // 1% шанс
...Array(90).fill("green"),
...Array(6).fill("yellow"),
...Array(3).fill("orange"),
...Array(1).fill("red"),
];
return statuses[Math.floor(Math.random() * statuses.length)];
};
const getStatusWeight = (status) => {
switch (status) {
case "green": return 1; // 100% здоровья
case "green": return 1;
case "yellow": return 0.75;
case "orange": return 0.5;
case "red": return 0.25; // 25% здоровья
default: return 1; // По умолчанию "green"
case "red": return 0.25;
default: return 1;
}
};
const updateStatuses = (data) => {
if (!data.items || data.items.length === 0) {
// Если это элемент нижнего уровня, генерируем случайный статус
data.status = getRandomStatus();
return getStatusWeight(data.status);
}
// Рекурсивно обновляем статусы для всех дочерних элементов
let childStatusWeights = data.items.map((child) => updateStatuses(child));
// Проверяем, есть ли дочерние элементы (избегаем деления на 0)
if (childStatusWeights.length === 0) {
data.status = "green";
return 1;
}
// Вычисляем среднее арифметическое значение весов статусов
const averageStatusWeight =
childStatusWeights.reduce((sum, weight) => sum + weight, 0) / childStatusWeights.length;
// Определяем статус текущего элемента
data.status = getStatusFromWeight(averageStatusWeight);
return Math.max(0, averageStatusWeight); // Гарантия, что не будет отрицательных значений
return Math.max(0, averageStatusWeight);
};
const getStatusFromWeight = (weight) => {
@ -69,16 +64,13 @@ const StatusManager = () => {
};
};
// Создаем два независимых менеджера статусов
export const statusManager1 = StatusManager();
export const statusManager2 = StatusManager();
// Функция для расчета процентов здоровья системы
export const calculateStatusPercentage = (averageStatusValue) => {
return Math.max(0, Math.min(100, averageStatusValue * 100));
};
// Экспортируем getStatusColor отдельно
export const getStatusColor = (status) => {
switch (status) {
case "green":

View File

@ -11,16 +11,18 @@
"id": "media_server_1",
"items": [
{
"id": "18",
"id": "device$18",
"title": "Graviton S2082I (device$18)",
"items": [
{
"id": "4",
"title": "OS Linux (module$4) АО",
"id": "module$11",
"title": "OS Linux (module$11) АО",
"items": [
{
"id": "190",
"title": "Загрузка процессора за 1 минуту"
"id": "zvks_cpu1min",
"title": "Загрузка процессора за 1 минуту",
"metric": "zvks_cpu1min",
"description": "Загрузка процессора за 1 минуту"
},
{
"id": "191",
@ -399,118 +401,74 @@
"id": "media_server_2",
"items": [
{
"id": "182",
"title": "Graviton S2082I (device$18)",
"id": "device$19",
"title": "Graviton S2082I (device$19)",
"items": [
{
"id": "42",
"title": "OS Linux (module$4) АО",
"id": "module$13",
"title": "OS Linux (module$13) АО",
"items": [
{
"id": "1902",
"title": "Загрузка процессора за 1 минуту"
},
{
"id": "1912",
"title": "Загрузка процессора за 5 минут"
},
{
"id": "1922",
"title": "Загрузка процессора за 15 минут"
},
{
"id": "1972",
"title": "Общий объем SWAP-файла"
},
{
"id": "1982",
"title": "Используемый объем SWAP-файла"
},
{
"id": "1992",
"title": "Общий объем физической оперативной памяти"
},
{
"id": "2002",
"title": "Доступный объем физической оперативной памяти"
},
{
"id": "2012",
"title": "Свободный объем физической и виртуальной оперативной памяти"
},
{
"id": "2022",
"title": "Буферизованный объем оперативной памяти"
},
{
"id": "2032",
"title": "Кэшированый объем оперативной памяти"
},
{
"id": "2742",
"title": "Используемый объем SWAP-файла"
},
{
"id": "2752",
"title": "Время затраченное процессором на процессы с пониженным приоритетом"
},
{
"id": "2762",
"title": "Время затраченное процессором на процессы ядра ОС"
},
{
"id": "2772",
"title": "Время простоя процессора"
},
{
"id": "2782",
"title": "Общая емкость жестких дисков"
},
{
"id": "2792",
"title": "Доступная емкость жестких дисков"
}
]
},
{
"id": "52",
"title": "Vinteo (module$5) ПО",
"items": [
{
"id": "312",
"title": "Общее количество участников"
},
{
"id": "322",
"title": "Ожидание соединения"
},
{
"id": "332",
"title": "Зарегистрированные абоненты"
},
{
"id": "342",
"title": "Количество пользоватей HLS"
},
{
"id": "352",
"title": "Общее количество P2P комнат"
},
{
"id": "362",
"title": "Общее количество конференций"
"id": "zvks_cpu1min",
"title": "Загрузка процессора за 1 минуту",
"metric": "zvks_cpu1min",
"description": "Загрузка процессора за 1 минуту"
},
{
"id": "372",
"title": "Общее количество активных конференций"
"title": "Загрузка процессора за 5 минут"
},
{
"id": "373",
"title": "Загрузка процессора за 15 минут"
},
{
"id": "378",
"title": "Общий объем SWAP-файла"
},
{
"id": "379",
"title": "Используемый объем SWAP-файла"
},
{
"id": "380",
"title": "Общий объем физической оперативной памяти"
},
{
"id": "381",
"title": "Доступный объем физической оперативной памяти"
},
{
"id": "382",
"title": "Статус записи"
"title": "Свободный объем физической и виртуальной оперативной памяти"
},
{
"id": "392",
"title": "Общее количество сохранённых записей"
"id": "383",
"title": "Буферизованный объем оперативной памяти"
},
{
"id": "384",
"title": "Кэшированый объем оперативной памяти"
},
{
"id": "375",
"title": "Время затраченное процессором на процессы с пониженным приоритетом"
},
{
"id": "376",
"title": "Время затраченное процессором на процессы ядра ОС"
},
{
"id": "377",
"title": "Время простоя процессора"
},
{
"id": "385",
"title": "Общая емкость жестких дисков"
},
{
"id": "386",
"title": "Доступная емкость жестких дисков"
}
]
},
@ -519,63 +477,63 @@
"title": "Сетевой адаптер №1 (port$261) Eth_1",
"items": [
{
"id": "2072",
"id": "388",
"title": "Скорость порта Eth_1"
},
{
"id": "2092",
"id": "390",
"title": "Административное состояние порта Eth_1"
},
{
"id": "2102",
"id": "391",
"title": "Оперативное состояние порта Eth_1"
},
{
"id": "2112",
"id": "392",
"title": "Общее количество отправленных октетов Eth_1"
},
{
"id": "2122",
"id": "393",
"title": "Количество входящих Multicast пакетов Eth_1"
},
{
"id": "2132",
"id": "394",
"title": "Количество иcходящих Multiicast пакетов Eth_1"
},
{
"id": "2142",
"id": "395",
"title": "Количество входящих Broadcast пакетов Eth_1"
},
{
"id": "2152",
"id": "396",
"title": "Количество иcходящих Broadcast пакетов Eth_1"
},
{
"id": "2162",
"id": "397",
"title": "Количество входящих Unicast пакетов Eth_1"
},
{
"id": "2172",
"id": "398",
"title": "Количество иcходящих Unicast пакетов Eth_1"
},
{
"id": "2182",
"id": "399",
"title": "Количество входящих пакетов помеченные как отброшенные Eth_1"
},
{
"id": "2192",
"id": "400",
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_1"
},
{
"id": "2202",
"id": "401",
"title": "Количество входящих пакетов с ошибкой Eth_1"
},
{
"id": "2212",
"id": "402",
"title": "Количество исходящих пакетов с ошибкой Eth_1"
},
{
"id": "2222",
"id": "403",
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_1"
}
]
@ -585,63 +543,63 @@
"title": "Сетевой адаптер №2 (port$262) Eth_2",
"items": [
{
"id": "2242",
"id": "405",
"title": "Скорость порта Eth_2"
},
{
"id": "2262",
"id": "407",
"title": "Административное состояние порта Eth_2"
},
{
"id": "2272",
"id": "408",
"title": "Оперативное состояние порта Eth_2"
},
{
"id": "2282",
"id": "409",
"title": "Общее количество отправленных октетов Eth_2"
},
{
"id": "2292",
"id": "410",
"title": "Количество входящих Multicast пакетов Eth_2"
},
{
"id": "2302",
"id": "411",
"title": "Количество иcходящих Multiicast пакетов Eth_2"
},
{
"id": "2312",
"id": "412",
"title": "Количество входящих Broadcast пакетов Eth_2"
},
{
"id": "2322",
"id": "413",
"title": "Количество иcходящих Broadcast пакетов Eth_2"
},
{
"id": "2332",
"id": "414",
"title": "Количество входящих Unicast пакетов Eth_2"
},
{
"id": "2342",
"id": "415",
"title": "Количество иcходящих Unicast пакетов Eth_2"
},
{
"id": "2352",
"id": "416",
"title": "Количество входящих пакетов помеченные как отброшенные Eth_2"
},
{
"id": "2362",
"id": "417",
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_2"
},
{
"id": "2372",
"id": "418",
"title": "Количество входящих пакетов с ошибкой Eth_2"
},
{
"id": "2382",
"id": "419",
"title": "Количество исходящих пакетов с ошибкой Eth_2"
},
{
"id": "2392",
"id": "420",
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_2"
}
]
@ -651,63 +609,63 @@
"title": "Сетевой адаптер №3 (port$263) Eth_3",
"items": [
{
"id": "2412",
"id": "422",
"title": "Скорость порта Eth_3"
},
{
"id": "2432",
"id": "424",
"title": "Административное состояние порта Eth_3"
},
{
"id": "2442",
"id": "425",
"title": "Оперативное состояние порта Eth_3"
},
{
"id": "2452",
"id": "426",
"title": "Общее количество отправленных октетов Eth_3"
},
{
"id": "2462",
"id": "427",
"title": "Количество входящих Multicast пакетов Eth_3"
},
{
"id": "2472",
"id": "428",
"title": "Количество иcходящих Multiicast пакетов Eth_3"
},
{
"id": "2482",
"id": "429",
"title": "Количество входящих Broadcast пакетов Eth_3"
},
{
"id": "2492",
"id": "430",
"title": "Количество иcходящих Broadcast пакетов Eth_3"
},
{
"id": "2502",
"id": "431",
"title": "Количество входящих Unicast пакетов Eth_3"
},
{
"id": "2512",
"id": "432",
"title": "Количество иcходящих Unicast пакетов Eth_3"
},
{
"id": "2522",
"id": "433",
"title": "Количество входящих пакетов помеченные как отброшенные Eth_3"
},
{
"id": "2532",
"id": "434",
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_3"
},
{
"id": "2542",
"id": "435",
"title": "Количество входящих пакетов с ошибкой Eth_3"
},
{
"id": "2552",
"id": "436",
"title": "Количество исходящих пакетов с ошибкой Eth_3"
},
{
"id": "2562",
"id": "437",
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_3"
}
]
@ -717,63 +675,63 @@
"title": "Сетевой адаптер №4 (port$264) Eth_4",
"items": [
{
"id": "2582",
"id": "439",
"title": "Скорость порта Eth_4"
},
{
"id": "2602",
"id": "441",
"title": "Административное состояние порта Eth_4"
},
{
"id": "2612",
"id": "442",
"title": "Оперативное состояние порта Eth_4"
},
{
"id": "2622",
"id": "443",
"title": "Общее количество отправленных октетов Eth_4"
},
{
"id": "2632",
"id": "444",
"title": "Количество входящих Multicast пакетов Eth_4"
},
{
"id": "2642",
"id": "445",
"title": "Количество иcходящих Multiicast пакетов Eth_4"
},
{
"id": "2652",
"id": "446",
"title": "Количество входящих Broadcast пакетов Eth_4"
},
{
"id": "2662",
"id": "447",
"title": "Количество иcходящих Broadcast пакетов Eth_4"
},
{
"id": "2672",
"id": "448",
"title": "Количество входящих Unicast пакетов Eth_4"
},
{
"id": "2682",
"id": "449",
"title": "Количество иcходящих Unicast пакетов Eth_4"
},
{
"id": "2692",
"id": "450",
"title": "Количество входящих пакетов помеченные как отброшенные Eth_4"
},
{
"id": "2702",
"id": "451",
"title": "Количество иcходящих пакетов помеченные как отброшенные Eth_4"
},
{
"id": "2712",
"id": "452",
"title": "Количество входящих пакетов с ошибкой Eth_4"
},
{
"id": "2722",
"id": "453",
"title": "Количество исходящих пакетов с ошибкой Eth_4"
},
{
"id": "2732",
"id": "454",
"title": "Количество входящих пакетов с неизвестным или неподдерживаемым протоколом Eth_4"
}
]
@ -889,15 +847,15 @@
"title": "Общее количество конференций"
},
{
"id": "373",
"id": "373000",
"title": "Общее количество активных конференций"
},
{
"id": "383",
"id": "38300",
"title": "Статус записи"
},
{
"id": "393",
"id": "39300",
"title": "Общее количество сохранённых записей"
}
]
@ -1281,11 +1239,11 @@
"title": "Общее количество активных конференций"
},
{
"id": "384",
"id": "38400",
"title": "Статус записи"
},
{
"id": "394",
"id": "39400",
"title": "Общее количество сохранённых записей"
}
]
@ -1671,7 +1629,7 @@
"title": "Общее количество конференций"
},
{
"id": "379",
"id": "37900",
"title": "Общее количество активных конференций"
},
{
@ -1679,7 +1637,7 @@
"title": "Статус записи"
},
{
"id": "399",
"id": "39900",
"title": "Общее количество сохранённых записей"
}
]
@ -2447,15 +2405,15 @@
"title": "Общее количество конференций"
},
{
"id": "378",
"id": "37800",
"title": "Общее количество активных конференций"
},
{
"id": "388",
"id": "38800",
"title": "Статус записи"
},
{
"id": "398",
"id": "39800",
"title": "Общее количество сохранённых записей"
}
]
@ -2841,15 +2799,15 @@
"title": "Общее количество конференций"
},
{
"id": "375",
"id": "37500",
"title": "Общее количество активных конференций"
},
{
"id": "385",
"id": "38500",
"title": "Статус записи"
},
{
"id": "395",
"id": "39500",
"title": "Общее количество сохранённых записей"
}
]
@ -3229,15 +3187,15 @@
"title": "Общее количество конференций"
},
{
"id": "376",
"id": "37600",
"title": "Общее количество активных конференций"
},
{
"id": "386",
"id": "38600",
"title": "Статус записи"
},
{
"id": "396",
"id": "39600",
"title": "Общее количество сохранённых записей"
}
]
@ -3617,7 +3575,7 @@
"title": "Общее количество конференций"
},
{
"id": "377",
"id": "37700",
"title": "Общее количество активных конференций"
},
{
@ -3625,7 +3583,7 @@
"title": "Статус записи"
},
{
"id": "397",
"id": "39700",
"title": "Общее количество сохранённых записей"
}
]

View File

@ -1,43 +1,23 @@
import React, { lazy, Suspense } from "react";
import Skeleton from '@mui/material/Skeleton';
import Box from '@mui/material/Box';
import Skeleton from "@mui/material/Skeleton";
import Box from "@mui/material/Box";
const PrometheusChart = lazy(() => import('../../Charts/PrometheusChart'));
const PrometheusChart = lazy(() => import("../../Charts2/PrometheusChart"));
import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender";
// Функция для генерации названия метрики на основе id
const getMetricName = (id) => {
return `zvks_apiforsnmp_measure_${id}`;
};
// Функция для рекурсивного сбора всех id потомков
const getAllChildIds = (node) => {
let ids = [];
if (node.id) {
ids.push(node.id); // Добавляем id текущего узла
}
if (node.items && node.items.length > 0) {
node.items.forEach((child) => {
ids = ids.concat(getAllChildIds(child)); // Рекурсивно собираем id потомков
});
}
return ids;
};
// Компонент Skeleton для графика
const ChartSkeleton = () => (
<Box sx={{ width: '100%' }}>
<Skeleton variant="text" width="60%" height={30} /> {/* Заголовок */}
<Skeleton variant="rectangular" width="100%" height={300} sx={{ mt: 2 }} /> {/* График */}
<Box sx={{ width: "100%" }}>
<Skeleton variant="text" width="60%" height={30} />
<Skeleton variant="rectangular" width="100%" height={300} sx={{ mt: 2 }} />
</Box>
);
// Компонент Skeleton для родительского контейнера
// Компонент Skeleton для контейнера
const ContainerSkeleton = () => (
<Box sx={{ width: '100%' }}>
<Skeleton variant="text" width="40%" height={40} /> {/* Заголовок */}
<Skeleton variant="text" width="80%" height={20} sx={{ mt: 1 }} /> {/* Описание */}
{/* Место для дочерних элементов */}
<Box sx={{ width: "100%" }}>
<Skeleton variant="text" width="40%" height={40} />
<Skeleton variant="text" width="80%" height={20} sx={{ mt: 1 }} />
<Box sx={{ mt: 2 }}>
{[...Array(3)].map((_, i) => (
<ChartSkeleton key={i} />
@ -46,72 +26,130 @@ const ContainerSkeleton = () => (
</Box>
);
const tabContent = (data) => {
const tabContent = {};
// Утилита для извлечения контекста из пути
const parseContextFromPath = (node) => {
const context = {};
let current = node;
// Функция для рекурсивного обхода и сбора данных
// Функция для рекурсивного обхода и сбора данных
const generateContent = (nodes) => {
nodes.forEach((node) => {
// Если у узла есть вложенные элементы, рекурсивно обрабатываем их
if (node.items && node.items.length > 0) {
// Создаем контент для родителя
const childrenContent = generateContent(node.items);
while (current) {
if (current.id.startsWith("device$")) {
context.device = current.id.split("$")[1];
context.deviceId = current.id;
}
if (current.id.startsWith("module$")) {
context.module = current.id;
context.source_id = current.id;
}
current = current.parent;
}
return context;
};
// Основная функция построения контента вкладок
const tabContent = (data, cache = {}) => {
const tabContentMap = { ...cache };
if (!data || !data.items || data.items.length === 0) {
console.warn("Данные отсутствуют или массив items пуст", data);
return tabContentMap;
}
const processNode = (node, parentContext = {}) => {
// Получаем полный контекст из всей цепочки родителей
const pathContext = parseContextFromPath(node);
const currentContext = { ...parentContext, ...pathContext };
// Генерируем уникальный ключ на основе пути
const path = [];
let current = node;
while (current) {
path.unshift(current.id);
current = current.parent;
}
const pathId = path.join('_');
if (Array.isArray(node.items) && node.items.length > 0) {
const children = node.items
.map((child) => processNode(child, currentContext))
.filter(Boolean);
const content = (
<div>
<div key={`${pathId}-container`}>
<h2>{node.title}</h2>
<Suspense fallback={<ContainerSkeleton />}>
<LazyChartBatchRenderer charts={node.items.map((child) => tabContent[child.id].content)} />
<LazyChartBatchRenderer charts={children.map((c) => c.content)} />
</Suspense>
<p>Контент для {node.title}.</p>
{/*childrenContent*/}
</div>
);
// Сохраняем контент для текущего id
tabContent[node.id] = {
tabContentMap[pathId] = {
title: node.title,
content: content,
content,
context: currentContext,
};
} else {
// Если у узла нет вложенных элементов, это самый нижний уровень
const metricName = getMetricName(node.id);
return { content, context: currentContext };
}
if (node.metric) {
const chartKey = `${node.metric}-${currentContext.device || "all"}-${currentContext.module || "all"}-${pathId}`;
const content = (
<div key={node.id}>
<h3>{node.title}</h3> {/* Используем title узла */}
<div key={chartKey}>
<h3>{node.title}</h3>
{currentContext.device && <p>Устройство: {currentContext.device}</p>}
{currentContext.module && <p>Модуль: {currentContext.module}</p>}
<Suspense fallback={<ChartSkeleton />}>
<PrometheusChart metricName={metricName} />
<PrometheusChart
metricInfo={{
name: node.metric,
filters: {
...(currentContext.device && { device: currentContext.device }),
...(currentContext.source_id && { source_id: currentContext.source_id }),
},
title: node.title,
description: node.description,
context: currentContext,
}}
key={chartKey}
/>
</Suspense>
</div>
);
// Сохраняем контент для текущего id
tabContent[node.id] = {
tabContentMap[pathId] = {
title: node.title,
content: content,
content,
context: currentContext,
};
}
});
// Возвращаем контент для всех потомков
return (
<div>
{nodes.map((node) => (
<div key={node.id}>{tabContent[node.id].content}</div>
))}
return { content, context: currentContext };
}
// Узел без метрики и без вложенных просто заголовок
const content = (
<div key={pathId}>
<h3>{node.title}</h3>
</div>
);
tabContentMap[pathId] = {
title: node.title,
content,
context: currentContext,
};
// Начинаем обработку с корневого уровня
if (data.items && data.items.length > 0) {
generateContent(data.items);
} else {
console.warn("Данные отсутствуют или массив items пуст");
return { content, context: currentContext };
};
try {
processNode(data);
} catch (error) {
console.error("Ошибка обработки данных:", error);
}
return tabContent;
return tabContentMap;
};
export default tabContent;

View File

@ -17,28 +17,27 @@ const LoginModal = ({ onLogin, onClose }) => {
e.preventDefault();
try {
const response = await axios.post(
`${import.meta.env.VITE_BACK_URL}/api/auth/login`, {
method: 'POST',
credentials: 'include',
const { data } = await axios.post(
`${import.meta.env.VITE_BACK_URL}/api/auth/login`,
{ login: username, password },
{
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ login: username, password }),
});
const data = await response.json();
}
);
if (data.success) {
localStorage.setItem('access_token', data.access_token);
onLogin(data.user); // Передаем данные пользователя
onLogin(data.user);
onClose();
} else {
setError(data.message || "Неверный логин или пароль");
}
} catch (err) {
console.error('Ошибка при отправке запроса:', err);
setError("Ошибка при подключении к серверу");
setError(err.response?.data?.message || "Ошибка при подключении к серверу");
}
};

View File

@ -1,9 +1,11 @@
import React from "react";
import { Tabs, Tab, Box, styled } from "@mui/material";
import { Tabs, Tab, Box, styled, Typography } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
const StyledTab = styled(Tab)(({ theme }) => ({
minHeight: 48,
padding: theme.spacing(1, 2),
textTransform: 'none',
'&.Mui-selected': {
color: theme.palette.primary.main,
fontWeight: theme.typography.fontWeightMedium,
@ -14,9 +16,43 @@ const StyledTab = styled(Tab)(({ theme }) => ({
},
}));
const TabLabel = ({ title, onClose }) => {
return (
<Box sx={{
display: "flex",
alignItems: "center",
minWidth: 0 // Для корректного обрезания длинного текста
}}>
<Typography
variant="body2"
noWrap
sx={{
maxWidth: 120,
textOverflow: 'ellipsis',
overflow: 'hidden'
}}
>
{title}
</Typography>
<CloseIcon
fontSize="small"
sx={{
ml: 1,
cursor: "pointer",
flexShrink: 0,
'&:hover': {
color: 'error.main'
}
}}
onClick={onClose}
/>
</Box>
);
};
const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
const handleMouseDown = (e, id) => {
if (e.button === 1) { // Middle mouse button
if (e.button === 1) { // Средняя кнопка мыши
e.preventDefault();
onCloseTab(id);
}
@ -26,6 +62,12 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
onTabClick(newValue);
};
// Статические вкладки (сохраняем оригинальные id)
const staticTabs = [
{ id: "Главная", title: "Главная" },
{ id: "Визуализация", title: "Визуализация" }
];
return (
<Box sx={{
borderBottom: 1,
@ -39,42 +81,31 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
onChange={handleChange}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
aria-label="tabs"
>
{/* Статические вкладки */}
{staticTabs.map(tab => (
<StyledTab
label="Главная"
value="Главная"
onMouseDown={(e) => handleMouseDown(e, "Главная")}
/>
<StyledTab
label="Визуализация"
value="Визуализация"
onMouseDown={(e) => handleMouseDown(e, "Визуализация")}
key={`static_${tab.id}`} // Добавляем префикс для уникальности
label={tab.title}
value={tab.id} // Используем id как value
onMouseDown={(e) => handleMouseDown(e, tab.id)}
/>
))}
{/* Динамические вкладки */}
{tabs.map((tab) => (
<StyledTab
key={tab.id}
key={`dynamic_${tab.id}`} // Добавляем префикс для уникальности
label={
<Box sx={{ display: "flex", alignItems: "center" }}>
<span>{tab.title}</span>
<CloseIcon
fontSize="small"
sx={{
ml: 1,
cursor: "pointer",
'&:hover': {
color: 'error.main'
}
}}
onClick={(e) => {
<TabLabel
title={tab.title}
onClose={(e) => {
e.stopPropagation();
onCloseTab(tab.id);
}}
/>
</Box>
}
value={tab.id}
onMouseDown={(e) => handleMouseDown(e, tab.id)}

View File

@ -324,7 +324,7 @@ const TreeTable = ({ data }) => {
</Typography>
<Box component="ul" sx={{
pl: 2,
maxHeight: 200,
maxHeight: 400,
overflow: 'auto',
listStyle: 'none'
}}>

View File

@ -2,22 +2,17 @@ import axios from 'axios';
export const checkAuth = async () => {
try {
const response = await axios.get(
const { data } = await axios.get(
`${import.meta.env.VITE_BACK_URL}/api/auth/check`,
{
withCredentials: true, // аналог `credentials: 'include'` в fetch
withCredentials: true,
headers: {
Authorization: `Bearer ${localStorage.getItem('access_token') || ''}`,
'Authorization': `Bearer ${localStorage.getItem('access_token') || ''}`,
},
}
);
// У axios нет свойства .ok, проверяем статус 200-299
if (response.status >= 200 && response.status < 300) {
return response.data; // Данные уже в JSON, не нужно .json()
} else {
throw new Error('Not authenticated');
}
return data;
} catch (err) {
console.error('Auth check failed:', err);
return { isAuthenticated: false };

View File

@ -1,29 +1,167 @@
import { useEffect, useState } from "react";
import React, { useEffect, useRef, useState, useCallback } from 'react';
import Box from '@mui/material/Box';
import Skeleton from '@mui/material/Skeleton';
const LazyChartBatchRenderer = ({ charts, batchSize = 3, delay = 150 }) => {
const [visibleCharts, setVisibleCharts] = useState([]);
const LazyChartBatchRenderer = ({ charts }) => {
const [visibleIndices, setVisibleIndices] = useState(new Set());
const placeholderRefs = useRef([]);
const observerRef = useRef(null);
const cleanupTimeoutRef = useRef(null);
const ChartSkeleton = () => (
<Box sx={{
backgroundColor: '#fff',
borderRadius: '8px',
padding: '20px',
marginBottom: '20px',
position: 'relative',
height: '400px',
overflow: 'hidden'
}}>
<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="300px"
sx={{
transform: 'none',
animation: 'none'
}}
/>
<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}
sx={{
transform: 'none',
animation: 'none'
}}
/>
))}
</Box>
</Box>
);
const isElementFarFromViewport = useCallback((element) => {
if (!element) return true;
const rect = element.getBoundingClientRect();
const buffer = window.innerHeight * 1.5;
return rect.bottom < -buffer || rect.top > window.innerHeight + buffer;
}, []);
const updateVisibleIndices = useCallback(() => {
const newVisibleIndices = new Set();
placeholderRefs.current.forEach((ref, index) => {
if (ref && !isElementFarFromViewport(ref)) {
newVisibleIndices.add(index);
}
});
setVisibleIndices(prev => {
if (newVisibleIndices.size === prev.size &&
Array.from(newVisibleIndices).every(i => prev.has(i))) {
return prev;
}
return newVisibleIndices;
});
}, [isElementFarFromViewport]);
useEffect(() => {
let index = 0;
const timer = setInterval(() => {
setVisibleCharts((prev) => [
...prev,
...charts.slice(index, index + batchSize),
]);
index += batchSize;
if (index >= charts.length) clearInterval(timer);
}, delay);
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
updateVisibleIndices();
}
});
},
{
root: null,
rootMargin: '500px 0px',
threshold: 0.01
}
);
return () => clearInterval(timer);
}, [charts]);
placeholderRefs.current.forEach(ref => {
if (ref) observerRef.current.observe(ref);
});
const handleScroll = () => {
if (cleanupTimeoutRef.current) {
clearTimeout(cleanupTimeoutRef.current);
}
cleanupTimeoutRef.current = setTimeout(() => {
updateVisibleIndices();
setVisibleIndices(prev => {
const updated = new Set(prev);
let changed = false;
placeholderRefs.current.forEach((ref, index) => {
if (ref && isElementFarFromViewport(ref) && prev.has(index)) {
updated.delete(index);
changed = true;
}
});
return changed ? updated : prev;
});
}, 150);
};
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', updateVisibleIndices, { passive: true });
updateVisibleIndices();
return () => {
if (cleanupTimeoutRef.current) clearTimeout(cleanupTimeoutRef.current);
if (observerRef.current) observerRef.current.disconnect();
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', updateVisibleIndices);
};
}, [updateVisibleIndices]);
const shouldShowChart = (index) => {
return visibleIndices.has(index) ||
visibleIndices.has(index - 1) ||
visibleIndices.has(index + 1);
};
return (
<>
{visibleCharts.map((chart, idx) => (
<div key={idx}>{chart}</div>
<div>
{charts.map((chart, index) => (
<div
key={index}
ref={(el) => (placeholderRefs.current[index] = el)}
data-index={index}
style={{
minHeight: '400px',
marginBottom: '20px',
transition: 'opacity 0.3s ease',
}}
>
{shouldShowChart(index) ? chart : <ChartSkeleton />}
</div>
))}
</>
</div>
);
};
export default LazyChartBatchRenderer;
export default React.memo(LazyChartBatchRenderer);

View File

@ -1,12 +1,33 @@
import SystemStatusChart from "../../Charts/SystemStatusChart";
import TreeTable from "../UI/TreeTable";
import FlowChart from "../TreeChart/FlowChart";
import { getStatusColor } from "../TreeChart/dataUtils";
const TabContent = ({ activeTab, tabs, statusHistories, treeData1, tabContent, handleOpenTab }) => {
// Функция для подсчета количества элементов каждого статуса
const countStatuses = (data) => {
const counts = { green: 0, yellow: 0, orange: 0, red: 0 };
const countRecursive = (node) => {
if (node.status) {
counts[node.status]++;
}
if (node.items && node.items.length > 0) {
node.items.forEach(child => countRecursive(child));
}
};
countRecursive(data);
return counts;
};
const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleOpenTab }) => {
if (activeTab === "Главная") {
const statusCounts = treeData1 ? countStatuses(treeData1) : { green: 0, yellow: 0, orange: 0, red: 0 };
return (
<div>
<h2>Общий мониторинг состояния системы</h2>
<h2 style={{ textAlign: 'center' }}>Общий мониторинг состояния системы</h2>
<div>
<div style={{ display: 'inline-block', width: '48%', marginRight: '2%' }}>
<label>Надежность системы</label>
@ -17,6 +38,32 @@ const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleO
<SystemStatusChart data={statusHistories.history2} />
</div>
</div>
{/* Контейнер для индикаторов статусов */}
<div style={{
display: 'flex',
justifyContent: 'flex-end',
marginTop: '20px',
gap: '10px'
}}>
{Object.entries(statusCounts).map(([status, count]) => (
<div key={status} style={{
width: '30px',
height: '30px',
backgroundColor: getStatusColor(status),
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
color: 'white',
fontWeight: 'bold',
borderRadius: '5px',
boxShadow: '0 2px 5px rgba(0,0,0,0.2)'
}}>
{count}
</div>
))}
</div>
<label>Статус компонентов системы</label>
<TreeTable data={treeData1} />
</div>
@ -24,7 +71,7 @@ const TabContent = ({ activeTab, statusHistories, treeData1, tabContent, handleO
} else if (activeTab === "Визуализация") {
return <FlowChart data={treeData1} onNodeClick={(id, title) => handleOpenTab(id, title)} />;
} else {
const tabData = tabContent[activeTab];
const tabData = tabs.find(t => t.id === activeTab);
return tabData ? tabData.content : <p>Нет данных</p>;
}
};

View File

@ -4,23 +4,43 @@ const useTabs = (initialTab) => {
const [tabs, setTabs] = useState([]);
const [activeTab, setActiveTab] = useState(initialTab);
const handleOpenTab = useCallback((id, title) => {
setTabs((prevTabs) =>
prevTabs.some((tab) => tab.id === id)
? prevTabs
: [...prevTabs, { id, title }]
);
setActiveTab(id);
const handleOpenTab = useCallback((newTab) => {
setTabs((prevTabs) => {
const exists = prevTabs.some((tab) => tab.id === newTab.id);
if (!exists) {
return [...prevTabs, newTab];
}
return prevTabs;
});
setActiveTab(newTab.id);
}, []);
const handleCloseTab = useCallback((id) => {
setTabs((prevTabs) => prevTabs.filter((tab) => tab.id !== id));
setTabs((prevTabs) => {
const newTabs = prevTabs.filter((tab) => tab.id !== id);
if (activeTab === id) {
setActiveTab(tabs.length > 1 ? tabs[tabs.length - 2].id : initialTab);
setActiveTab(newTabs.length > 0 ? newTabs[newTabs.length - 1].id : initialTab);
}
}, [activeTab, tabs, initialTab]);
return newTabs;
});
}, [activeTab, initialTab]);
return { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab };
const updateTabContent = useCallback((id, content) => {
setTabs(prevTabs =>
prevTabs.map(tab =>
tab.id === id ? { ...tab, content } : tab
)
);
}, []);
return {
tabs,
activeTab,
handleOpenTab,
handleCloseTab,
setActiveTab,
updateTabContent
};
};
export default useTabs;

View File

@ -139,7 +139,7 @@ export const darkTheme = createTheme({
// Фоновые цвета
background: {
default: "#1E1E1E", // Основной фон приложения
default: "#2d2d2d", // Основной фон приложения
paper: "#2d2d2d", // Фон "бумажных" поверхностей
},

View File

@ -0,0 +1,11 @@
<svg width="43" height="43" viewBox="0 0 43 43" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.4391 0.0295059V0H21.5049H21.4951H20.5609V0.0295059C9.76424 0.48193 1.02264 8.95014 0.0884977 19.6116C0.0294994 20.2312 0 20.8607 0 21.5C0 22.1295 0.0294994 22.7589 0.0884977 23.3884C1.04231 34.3646 10.2756 43 21.4951 43H22.4391V39.2331H21.4951C12.37 39.2331 4.8182 32.3484 3.87423 23.3884H6.43083H14.6513C14.4349 22.7097 14.3169 21.9819 14.3169 21.2246C14.3169 20.6738 14.3858 20.1329 14.5038 19.6215H11.4752C12.37 14.8808 16.5884 11.3008 21.5049 11.3008C24.9367 11.3008 28.1226 13.0416 29.9909 15.8545H34.2584C32.0656 10.8484 27.0016 7.53385 21.5049 7.53385C14.5038 7.53385 8.58427 12.7761 7.65996 19.6215H6.2145H3.87423C4.8182 10.6615 12.37 3.77676 21.4951 3.77676H21.5049C30.63 3.77676 38.1818 10.6615 39.1258 19.6215H28.4962C28.6142 20.1427 28.6831 20.6738 28.6831 21.2246C28.6831 21.9819 28.5651 22.7097 28.3487 23.3884H28.919H31.5248H35.34H37.3067H43V21.5C43 9.95334 33.8552 0.511436 22.4391 0.0295059Z" fill="#428AC9"/>
<path d="M22.7045 32.25C22.3112 32.2992 21.9081 32.3287 21.5049 32.3287C17.2472 32.3287 13.5205 29.6436 12.016 25.8472H8.06311C9.70523 31.7681 15.1528 36.0956 21.5049 36.0956C21.9081 36.0956 22.3112 36.0759 22.7045 36.0366V32.25Z" fill="#428AC9"/>
<path d="M25.2611 24.3817C23.383 26.457 20.1873 26.6242 18.1125 24.7457C16.0377 22.8769 15.8706 19.6706 17.7388 17.5954C19.617 15.5201 22.8127 15.3529 24.8875 17.2315C26.9623 19.1002 27.1294 22.3065 25.2611 24.3817Z" fill="url(#paint0_radial_2_3)"/>
<defs>
<radialGradient id="paint0_radial_2_3" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(19.8648 18.1752) scale(7.12571 7.12734)">
<stop stop-color="#4A96D2"/>
<stop offset="1" stop-color="#1F2466"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -2,8 +2,6 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
//import './Style/light-theme.css'; // Подключаем светлую тему по умолчанию
//import './Style/dark-theme.css'; // Подключаем темную тему
createRoot(document.getElementById('root')).render(
<StrictMode>