authorization-token #38

Merged
Ghost merged 5 commits from authorization-token into rc 2025-04-21 16:10:31 +03:00
13 changed files with 413 additions and 158 deletions

View File

@ -27,7 +27,8 @@
"reactflow": "^11.11.4", "reactflow": "^11.11.4",
"vite-plugin-svgr": "^4.3.0", "vite-plugin-svgr": "^4.3.0",
"react-scripts": "^5.0.1", "react-scripts": "^5.0.1",
"socket.io-client": "^4.8.1" "socket.io-client": "^4.8.1",
"antd": "^5.24.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",

View File

@ -1,37 +1,109 @@
import React, { useState, useMemo } from "react"; import React, { useState, useMemo, useEffect } from "react";
import { ThemeProvider, CssBaseline, Switch, Box } from "@mui/material"; import { ThemeProvider, CssBaseline, Switch, Box, CircularProgress } from "@mui/material";
import Dashboard from "./Components/Layout/Dashboard"; import Dashboard from "./Components/Layout/Dashboard";
import LoginModal from "./Components/UI/LoginModal"; import LoginModal from "./Components/UI/LoginModal";
import { lightTheme, darkTheme } from "./Style/theme"; import { lightTheme, darkTheme } from "./Style/theme";
import Logo from './assets/images/logo.svg?react'; // Импорт как компонента import Logo from './assets/images/logo.svg?react';
import { checkAuth } from "./Components/UI/auth";
import axios from 'axios';
function App() { function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false); const [authState, setAuthState] = useState({
const [showLoginModal, setShowLoginModal] = useState(true); isAuthenticated: false,
isLoading: true,
user: null
});
const [showLoginModal, setShowLoginModal] = useState(false);
const [isDarkMode, setIsDarkMode] = useState( const [isDarkMode, setIsDarkMode] = useState(
window.matchMedia("(prefers-color-scheme: dark)").matches window.matchMedia("(prefers-color-scheme: dark)").matches
); );
const theme = useMemo(() => (isDarkMode ? darkTheme : lightTheme), [isDarkMode]); const theme = useMemo(() => (isDarkMode ? darkTheme : lightTheme), [isDarkMode]);
const handleLogin = () => { useEffect(() => {
setIsAuthenticated(true); 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);
setAuthState({
isAuthenticated: false,
isLoading: false,
user: null
});
setShowLoginModal(true);
}
};
verifyAuth();
}, []);
const handleLogin = (userData) => {
setAuthState({
isAuthenticated: true,
isLoading: false,
user: userData
});
setShowLoginModal(false); setShowLoginModal(false);
}; };
const handleLogout = async () => {
try {

почему не в .env?

почему не в .env?
await axios.get(`${import.meta.env.VITE_BACK_URL}/api/metrics`, {
method: 'POST',
credentials: 'include'
});
localStorage.removeItem('access_token');
setAuthState({
isAuthenticated: false,
isLoading: false,
user: null
});
setShowLoginModal(true);
} catch (error) {
console.error('Logout failed:', error);
}
};
if (authState.isLoading) {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
flexDirection: 'column',
gap: 2
}}>
<CircularProgress />
<p>Проверка авторизации...</p>
</Box>
</ThemeProvider>
);
}
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
{!isAuthenticated && showLoginModal ? ( {!authState.isAuthenticated && showLoginModal ? (
<> <>
{/* Логотип */}
<Box <Box
component="div" component="div"
sx={{ sx={{
position: "fixed", position: "fixed",
top: 24, top: 24,
left: "50%", // Сдвигаем начало логотипа в центр left: "50%",
transform: "translateX(-50%)", // Смещаем назад на половину ширины логотипа transform: "translateX(-50%)",
zIndex: 1200, zIndex: 1200,
'& svg': { '& svg': {
width: 400, width: 400,
@ -55,9 +127,15 @@ function App() {
bgcolor: "background.default", bgcolor: "background.default",
color: "text.primary" color: "text.primary"
}}> }}>
<Dashboard /> <Dashboard
user={authState.user}
onLogout={handleLogout}
/>
<Box sx={{ position: "absolute", top: 10, right: 10 }}> <Box sx={{ position: "absolute", top: 10, right: 10 }}>
<Switch checked={isDarkMode} onChange={() => setIsDarkMode((prev) => !prev)} /> <Switch
checked={isDarkMode}
onChange={() => setIsDarkMode((prev) => !prev)}
/>
</Box> </Box>
</Box> </Box>
)} )}

View File

@ -203,7 +203,7 @@ const LineChartComponent = ({
{instanceKeys.map((instance, index) => ( {instanceKeys.map((instance, index) => (
<Line <Line
key={instance} key={instance}
type="monotone" type=""
dataKey={instance} dataKey={instance}
name={instance} name={instance}
stroke={colors[index % colors.length]} stroke={colors[index % colors.length]}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef, useCallback } from 'react'; import React, { useEffect, useState, useRef, useCallback } from 'react';
import { io } from 'socket.io-client'; import { webSocketManager } from './WebSocketManager';
import LineChartComponent from './Components/LineChartComponent'; import LineChartComponent from './Components/LineChartComponent';
import { TimeRangeSelector } from './Components/TimeRangeSelector'; import { TimeRangeSelector } from './Components/TimeRangeSelector';
import { ConnectionStatusIndicator } from './Components/ConnectionStatusIndicator'; import { ConnectionStatusIndicator } from './Components/ConnectionStatusIndicator';
@ -19,7 +19,6 @@ const PrometheusChart = ({ metricName }) => {
const [isSelectingRange, setIsSelectingRange] = useState(false); const [isSelectingRange, setIsSelectingRange] = useState(false);
const [lastCustomRange, setLastCustomRange] = useState(null); const [lastCustomRange, setLastCustomRange] = useState(null);
const intervalRef = useRef(null); const intervalRef = useRef(null);
const socketRef = useRef(null);
const debounceRef = useRef(null); const debounceRef = useRef(null);
const formatTime = useCallback((timestamp, rangeSeconds) => { const formatTime = useCallback((timestamp, rangeSeconds) => {
@ -70,25 +69,6 @@ const PrometheusChart = ({ metricName }) => {
return 1800; // > 24 часов return 1800; // > 24 часов
}, []); }, []);
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);
if (socketRef.current?.connected) {
socketRef.current.emit('get-metrics', {
metric: metricName,
start,
end,
step,
_t: Date.now()
});
}
}, [metricName, selectedRange.value, isSelectingRange]);
const processMetricsData = useCallback((response) => { const processMetricsData = useCallback((response) => {
console.log('Processing metrics data:', response); console.log('Processing metrics data:', response);
@ -137,58 +117,32 @@ const PrometheusChart = ({ metricName }) => {
}); });
}, [metricName, selectedRange.value, formatTime, useCustomRange, startDate, endDate]); }, [metricName, selectedRange.value, formatTime, useCustomRange, startDate, endDate]);
const setupWebSocket = useCallback(() => { const fetchData = useCallback(() => {
if (socketRef.current) { if (isSelectingRange) return;
// Если соединение уже существует, возвращаем его
if (socketRef.current.connected) return socketRef.current;
// Если соединение в процессе переподключения, тоже возвращаем
if (socketRef.current.reconnecting) return socketRef.current;
}
//VITE_BACK_WS_URL
const socket = io(`${import.meta.env.VITE_BACK_WS_URL}/api/metrics-ws`, {
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
socketRef.current = socket; const now = Math.floor(Date.now() / 1000);
const start = now - selectedRange.value;
const end = now;
const step = calculateStep(start, end);
socket.on('connect', () => { webSocketManager.getMetricsRange(metricName, start, end, step)
console.log('WebSocket connected'); .then(data => {
setConnectionStatus('connected'); processMetricsData({ metric: metricName, data });
fetchData(); })
}); .catch(error => {
console.error('Error fetching metrics:', error);
socket.on('disconnect', (reason) => { });
console.log('WebSocket disconnected:', reason); }, [metricName, selectedRange.value, isSelectingRange, calculateStep, processMetricsData]);
setConnectionStatus('disconnected');
if (reason === 'io server disconnect') socket.connect();
});
socket.on('connect_error', (error) => {
console.error('WebSocket connection error:', error);
setConnectionStatus('error');
setTimeout(() => socket.connect(), 1000);
});
socket.on('metrics-data', (response) => {
console.log('Received raw metrics data:', response);
processMetricsData(response);
});
socket.on('metrics-error', (error) => {
console.error('Metrics error:', error);
setConnectionStatus('error');
});
return socket;
}, []);
const fetchCustomRangeData = useCallback(async () => { const fetchCustomRangeData = useCallback(async () => {
// Добавляем проверку на валидность дат

комментарий убрать

комментарий убрать
if (!startDate || !endDate || startDate >= endDate) {
console.error('Invalid date range');
return;
}
const start = Math.floor(startDate.getTime() / 1000); const start = Math.floor(startDate.getTime() / 1000);
const end = Math.floor(endDate.getTime() / 1000); const end = Math.ceil(endDate.getTime() / 1000); // Используем Math.ceil для конечной даты
const rangeSeconds = end - start; const rangeSeconds = end - start;
try { try {
@ -202,11 +156,11 @@ const PrometheusChart = ({ metricName }) => {
}); });
if (response.data?.length) { if (response.data?.length) {
// Преобразуем данные перед передачей в processMetricsData // Добавляем нормализацию timestamp

все комментарии убрать

все комментарии убрать
const processedData = response.data.map(item => ({ const processedData = response.data.map(item => ({
...item, ...item,
timestamp: item.timestamp, // оставляем в секундах - processMetricsData конвертирует timestamp: item.timestamp > 1e12 ? item.timestamp : item.timestamp * 1000,

что такое ie12? Может лучше в отдельную переменную вывести? Что значит item.timestamp * 1000? Может тоже в отдельную переменную?

что такое _ie12_? Может лучше в отдельную переменную вывести? Что значит item.timestamp * 1000? Может тоже в отдельную переменную?
value: item.value.toString() value: parseFloat(item.value)
})); }));
processMetricsData({ processMetricsData({
@ -220,8 +174,7 @@ const PrometheusChart = ({ metricName }) => {
}, [metricName, startDate, endDate, calculateStep, processMetricsData]); }, [metricName, startDate, endDate, calculateStep, processMetricsData]);
const handleRangeChange = useCallback((event) => { const handleRangeChange = useCallback(async (event) => {
// Очищаем текущий интервал
if (intervalRef.current) { if (intervalRef.current) {
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
intervalRef.current = null; intervalRef.current = null;
@ -230,9 +183,10 @@ const PrometheusChart = ({ metricName }) => {
const selectedValue = event.target.value; const selectedValue = event.target.value;
const range = TIME_RANGES.find(r => r.value === parseInt(selectedValue, 10)); const range = TIME_RANGES.find(r => r.value === parseInt(selectedValue, 10));
// Полный сброс состояния перед загрузкой новых данных

убрать

убрать
setChartData(null);
setSelectedRange(range); setSelectedRange(range);
setUseCustomRange(false); setUseCustomRange(false);
setChartData(null);
setSelectedGraphRange(null); setSelectedGraphRange(null);
setFilteredData(null); setFilteredData(null);
@ -240,20 +194,12 @@ const PrometheusChart = ({ metricName }) => {
setEndDate(now); setEndDate(now);
setStartDate(new Date(now.getTime() - range.value * 1000)); setStartDate(new Date(now.getTime() - range.value * 1000));
// Переподключение сокета // Ждем завершения обновления состояния перед загрузкой

убрать

убрать
if (!socketRef.current?.connected) { await new Promise(resolve => setTimeout(resolve, 0));
socketRef.current?.connect(); fetchData();
} }, [fetchData]);
}, []);
const handleCustomRangeChange = useCallback(() => { const handleCustomRangeChange = useCallback(() => {
// Отключаем WebSocket соединение
if (socketRef.current?.connected) {
socketRef.current.disconnect();
setConnectionStatus('disconnected');
}
// Очищаем интервал обновления
if (intervalRef.current) { if (intervalRef.current) {
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
intervalRef.current = null; intervalRef.current = null;
@ -266,25 +212,7 @@ const PrometheusChart = ({ metricName }) => {
fetchCustomRangeData(); fetchCustomRangeData();
}, [fetchCustomRangeData]); }, [fetchCustomRangeData]);
const handleResetZoom = useCallback(() => {
setSelectedGraphRange(null);
setFilteredData(null);
setIsSelectingRange(false);
if (useCustomRange) {
fetchCustomRangeData();
} else {
if (!socketRef.current?.connected) {
socketRef.current?.connect();
}
fetchData();
}
if (lastCustomRange) {
handleRangeSelect(lastCustomRange);
return;
}
}, [fetchData, fetchCustomRangeData, useCustomRange]);
const interpolateData = useCallback((data, targetPointCount) => { const interpolateData = useCallback((data, targetPointCount) => {
if (!data || data.length < 2) return data; if (!data || data.length < 2) return data;

если нет data вернуть data? поясни

если нет data вернуть data? поясни

if (!data)
Проверяет, что data существует не null, не undefined и не false.
Если data нет — функция возвращает data, то есть null/undefined.

|| data.length < 2
или проверяет, что в data меньше 2 элементов (массив пуст или содержит 1 элемент).
Если да — функция возвращает исходный data без изменений.

Интерполяция требует как минимум 2 точки начало и конец,если точек меньше, то интерполировать нечего

if (!data) Проверяет, что data существует не null, не undefined и не false. Если data нет — функция возвращает data, то есть null/undefined. || data.length < 2 или проверяет, что в data меньше 2 элементов (массив пуст или содержит 1 элемент). Если да — функция возвращает исходный data без изменений. Интерполяция требует как минимум 2 точки начало и конец,если точек меньше, то интерполировать нечего
@ -337,10 +265,6 @@ const PrometheusChart = ({ metricName }) => {
setIsSelectingRange(true); setIsSelectingRange(true);
setSelectedGraphRange(range); setSelectedGraphRange(range);
// Отключаем автоматические обновления
if (socketRef.current?.connected) {
socketRef.current.disconnect();
}
if (intervalRef.current) { if (intervalRef.current) {
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
intervalRef.current = null; intervalRef.current = null;
@ -366,23 +290,52 @@ const PrometheusChart = ({ metricName }) => {
setIsSelectingRange(false); setIsSelectingRange(false);
}, [chartData, interpolateData, formatTime]); }, [chartData, interpolateData, formatTime]);
useEffect(() => { const handleResetZoom = useCallback(() => {
const socket = setupWebSocket(); setSelectedGraphRange(null);
return () => { setFilteredData(null);
clearInterval(intervalRef.current); setIsSelectingRange(false);
socket.disconnect();
}; if (useCustomRange) {
}, [setupWebSocket]); fetchCustomRangeData();
} else {
fetchData();
}
if (lastCustomRange) {
handleRangeSelect(lastCustomRange);
}
}, [fetchData, fetchCustomRangeData, useCustomRange, lastCustomRange, handleRangeSelect]);
useEffect(() => {
// Обработчик данных с сервера
const handleMetricsData = (data) => {
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, processMetricsData]);
// Обновим useEffect для кастомного диапазона
useEffect(() => { useEffect(() => {
if (useCustomRange && !isSelectingRange) { if (useCustomRange && !isSelectingRange) {
// Очищаем предыдущий таймер
if (debounceRef.current) { if (debounceRef.current) {
clearTimeout(debounceRef.current); clearTimeout(debounceRef.current);
} }
// Устанавливаем новый таймер с задержкой 500 мс
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
fetchCustomRangeData(); fetchCustomRangeData();
}, 500); }, 500);
@ -406,12 +359,11 @@ const PrometheusChart = ({ metricName }) => {
} }
}; };
// Очищаем предыдущий интервал
if (intervalRef.current) { if (intervalRef.current) {
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
} }
// Запускаем сразу и затем по интервалу
fetchDataWrapper(); fetchDataWrapper();
intervalRef.current = setInterval(fetchDataWrapper, selectedRange.interval); intervalRef.current = setInterval(fetchDataWrapper, selectedRange.interval);
@ -491,4 +443,3 @@ const PrometheusChart = ({ metricName }) => {
}; };
export default React.memo(PrometheusChart); export default React.memo(PrometheusChart);

View File

@ -0,0 +1,115 @@
// src/services/WebSocketManager.js
import { io } from 'socket.io-client';
class WebSocketManager {
constructor() {
this.socket = null;
this.subscribers = new Map();
this.connectionStatus = 'disconnected';
this.connectionCallbacks = new Set();
}
connect() {
if (this.socket && (this.socket.connected || this.socket.reconnecting)) {
return this.socket;
}
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.notifyConnectionStatus();
});
this.socket.on('disconnect', (reason) => {
this.connectionStatus = 'disconnected';
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) {
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();

View File

@ -48,9 +48,9 @@ const MainContent = styled(Box)(({ theme }) => ({
const Content = styled(Box)(({ theme }) => ({ const Content = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.custom.modalBackground, backgroundColor: theme.palette.custom.modalBackground,
padding: theme.spacing(2.5), //padding: theme.spacing(2.5),
borderRadius: '10px', borderRadius: '10px',
boxShadow: theme.shadows[2], //boxShadow: theme.shadows[2],
maxWidth: '100%', maxWidth: '100%',
overflow: 'auto', overflow: 'auto',
color: theme.palette.custom.modalText, color: theme.palette.custom.modalText,

View File

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { import {
Drawer, Drawer,
List, List,
@ -15,6 +15,7 @@ import {
} from "@mui/icons-material"; } from "@mui/icons-material";
import MenuItem from "./SidebarMenuComponents/MenuItem"; import MenuItem from "./SidebarMenuComponents/MenuItem";
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter"; import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
import { statusManager1 } from "../TreeChart/dataUtils";
const SidebarResizer = styled('div')(({ theme }) => ({ const SidebarResizer = styled('div')(({ theme }) => ({
width: "5px", width: "5px",
@ -34,6 +35,17 @@ const SidebarResizer = styled('div')(({ theme }) => ({
const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => { const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [hovered, setHovered] = useState(false); 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 = () => { const handleToggleCollapse = () => {
setCollapsed(!collapsed); setCollapsed(!collapsed);
@ -126,11 +138,13 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => {
Меню Меню
</Typography> </Typography>
)} )}
<MenuItem {menuData && (
item={data} <MenuItem
onSelectItem={handleSelectItem} item={menuData}
collapsed={collapsed} onSelectItem={handleSelectItem}
/> collapsed={collapsed}
/>
)}
</List> </List>
{/* Футер */} {/* Футер */}

View File

@ -8,10 +8,14 @@ import {
styled styled
} from "@mui/material"; } from "@mui/material";
import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material"; import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
import { getStatusColor } from "../../TreeChart/dataUtils";
const StyledListItem = styled(ListItem)(({ theme, level }) => ({ const StyledListItem = styled(ListItem)(({ theme, level }) => ({
cursor: "pointer", cursor: "pointer",
paddingLeft: theme.spacing(2 + level * 2), paddingLeft: theme.spacing(2 + level * 2),
position: 'relative', // Добавляем для позиционирования индикатора
'&:hover': { '&:hover': {
backgroundColor: theme.palette.action.hover, backgroundColor: theme.palette.action.hover,
}, },
@ -20,6 +24,17 @@ 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 }) => ({ const IconWrapper = styled('div')(({ theme }) => ({
cursor: "pointer", cursor: "pointer",
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
@ -33,7 +48,6 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
const [isOpen, setIsOpen] = React.useState(false); const [isOpen, setIsOpen] = React.useState(false);
const hasChildren = Array.isArray(item.items) && item.items.length > 0; const hasChildren = Array.isArray(item.items) && item.items.length > 0;
const handleToggle = (e) => { const handleToggle = (e) => {
e.stopPropagation(); e.stopPropagation();
setIsOpen(!isOpen); setIsOpen(!isOpen);
@ -52,17 +66,20 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
onClick={hasChildren ? handleToggle : handleOpenTab} onClick={hasChildren ? handleToggle : handleOpenTab}
level={level} level={level}
sx={{ sx={{
pl: collapsed ? 2 : 2 + level * 2, // Адаптируем отступы pl: collapsed ? 2 : 2 + level * 2,
justifyContent: collapsed ? 'center' : 'flex-start', justifyContent: collapsed ? 'center' : 'flex-start',
}} }}
> >
{/* Индикатор статуса */}
{!collapsed && <StatusIndicator status={item.status} />}
<ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}> <ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
<IconWrapper onClick={handleOpenTab}> <IconWrapper onClick={handleOpenTab}>
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />} {hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
</IconWrapper> </IconWrapper>
</ListItemIcon> </ListItemIcon>
{!collapsed && ( // Показываем текст только в развернутом состоянии {!collapsed && (
<> <>
<ListItemText <ListItemText
primary={item.title} primary={item.title}
@ -75,7 +92,7 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
)} )}
</StyledListItem> </StyledListItem>
{hasChildren && !collapsed && ( // Показываем детей только в развернутом состоянии {hasChildren && !collapsed && (
<Collapse in={isOpen} timeout="auto" unmountOnExit> <Collapse in={isOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding> <List component="div" disablePadding>
{item.items.map((child, index) => ( {item.items.map((child, index) => (
@ -94,7 +111,6 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
); );
}; };
// Вспомогательная функция (остается без изменений)
const getAllChildren = (node) => { const getAllChildren = (node) => {
let children = []; let children = [];
if (node.items && node.items.length > 0) { if (node.items && node.items.length > 0) {

View File

@ -0,0 +1,25 @@
import React from "react";
import { styled } from "@mui/material";
const StatusIndicator = styled('div')(({ theme, status }) => ({
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '4px',
backgroundColor: getStatusColor(status),
borderRadius: '0 2px 2px 0',
transition: 'background-color 0.3s ease'
}));
const getStatusColor = (status) => {
switch (status) {
case 'red': return '#F44336';
case 'orange': return '#FF9800';
case 'yellow': return '#cebd21';
case 'green': return '#4CAF50';
default: return 'transparent';
}
};
export default StatusIndicator;

View File

@ -1,4 +1,6 @@
import React, { lazy, Suspense } from "react"; import React, { lazy, Suspense } from "react";
import Skeleton from '@mui/material/Skeleton';
import Box from '@mui/material/Box';
const PrometheusChart = lazy(() => import('../../Charts/PrometheusChart')); const PrometheusChart = lazy(() => import('../../Charts/PrometheusChart'));
import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender"; import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender";
@ -22,9 +24,32 @@ const getAllChildIds = (node) => {
return ids; 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>
);
// Компонент 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={{ mt: 2 }}>
{[...Array(3)].map((_, i) => (
<ChartSkeleton key={i} />
))}
</Box>
</Box>
);
const tabContent = (data) => { const tabContent = (data) => {
const tabContent = {}; const tabContent = {};
// Функция для рекурсивного обхода и сбора данных
// Функция для рекурсивного обхода и сбора данных // Функция для рекурсивного обхода и сбора данных
const generateContent = (nodes) => { const generateContent = (nodes) => {
nodes.forEach((node) => { nodes.forEach((node) => {
@ -36,9 +61,11 @@ const tabContent = (data) => {
const content = ( const content = (
<div> <div>
<h2>{node.title}</h2> <h2>{node.title}</h2>
<LazyChartBatchRenderer charts={node.items.map((child) => tabContent[child.id].content)} /> <Suspense fallback={<ContainerSkeleton />}>
<LazyChartBatchRenderer charts={node.items.map((child) => tabContent[child.id].content)} />
</Suspense>
<p>Контент для {node.title}.</p> <p>Контент для {node.title}.</p>
{childrenContent} {/*childrenContent*/}
</div> </div>
); );
@ -53,7 +80,7 @@ const tabContent = (data) => {
const content = ( const content = (
<div key={node.id}> <div key={node.id}>
<h3>{node.title}</h3> {/* Используем title узла */} <h3>{node.title}</h3> {/* Используем title узла */}
<Suspense fallback={<div>Загрузка графика...</div>}> <Suspense fallback={<ChartSkeleton />}>
<PrometheusChart metricName={metricName} /> <PrometheusChart metricName={metricName} />
</Suspense> </Suspense>
</div> </div>

View File

@ -3,6 +3,7 @@ import Modal from "./Modal";
import "../../Style/LoginModal.css"; import "../../Style/LoginModal.css";
import Logo from '../../assets/images/logo.svg?react'; import Logo from '../../assets/images/logo.svg?react';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import axios from 'axios';
const LoginModal = ({ onLogin, onClose }) => { const LoginModal = ({ onLogin, onClose }) => {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@ -16,9 +17,10 @@ const LoginModal = ({ onLogin, onClose }) => {
e.preventDefault(); e.preventDefault();
try { try {
// Отправляем данные на бэкенд const response = await axios.post(
const response = await fetch(`${import.meta.env.VITE_BACK_URL}/api/auth/login`, { `${import.meta.env.VITE_BACK_URL}/api/auth/login`, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@ -28,8 +30,9 @@ const LoginModal = ({ onLogin, onClose }) => {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
onLogin(); // Успешная авторизация localStorage.setItem('access_token', data.access_token);
onClose(); // Закрыть модальное окно onLogin(data.user); // Передаем данные пользователя
onClose();
} else { } else {
setError(data.message || "Неверный логин или пароль"); setError(data.message || "Неверный логин или пароль");
} }

View File

@ -0,0 +1,25 @@
import axios from 'axios';
export const checkAuth = async () => {
try {
const response = await axios.get(
`${import.meta.env.VITE_BACK_URL}/api/auth/check`,
{
withCredentials: true, // аналог `credentials: 'include'` в fetch
headers: {
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');
}
} catch (err) {
console.error('Auth check failed:', err);
return { isAuthenticated: false };
}
};

View File

@ -76,7 +76,7 @@ export const lightTheme = createTheme({
// Фоновые цвета // Фоновые цвета
background: { background: {
default: "#6CACE4", // Основной фон приложения default: "#FFFFFF", // Основной фон приложения

BACKGROUND_COLOR = #FFFFFF

BACKGROUND_COLOR = #FFFFFF
paper: "#FFFFFF", // Фон "бумажных" поверхностей (карточек, панелей) paper: "#FFFFFF", // Фон "бумажных" поверхностей (карточек, панелей)
}, },