fixed a bug with multiple web socket connection
parent
d83f05e2b5
commit
c7ebbcaf5c
|
|
@ -27,7 +27,8 @@
|
|||
"reactflow": "^11.11.4",
|
||||
"vite-plugin-svgr": "^4.3.0",
|
||||
"react-scripts": "^5.0.1",
|
||||
"socket.io-client": "^4.8.1"
|
||||
"socket.io-client": "^4.8.1",
|
||||
"antd": "^5.24.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ const LineChartComponent = ({
|
|||
{instanceKeys.map((instance, index) => (
|
||||
<Line
|
||||
key={instance}
|
||||
type="monotone"
|
||||
type=""
|
||||
dataKey={instance}
|
||||
name={instance}
|
||||
stroke={colors[index % colors.length]}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
// src/utils/metricsUtils.js
|
||||
import { MINUTE, HOUR, DAY } from './constants';
|
||||
|
||||
export function formatTime(timestamp, rangeSeconds) {
|
||||
const ts = typeof timestamp === 'number' ? timestamp : Date.now();
|
||||
const date = new Date(ts);
|
||||
|
||||
const timeOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
};
|
||||
|
||||
const dateOptions = rangeSeconds > 86400 ? {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
...timeOptions
|
||||
} : timeOptions;
|
||||
|
||||
return {
|
||||
display: date.toLocaleString('ru-RU', dateOptions),
|
||||
fullDisplay: date.toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
...timeOptions
|
||||
}),
|
||||
timestamp: ts
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateStep(start, end) {
|
||||
const rangeSeconds = end - start;
|
||||
|
||||
if (rangeSeconds <= MINUTE) return 1;
|
||||
if (rangeSeconds <= 5 * MINUTE) return 5;
|
||||
if (rangeSeconds <= 15 * MINUTE) return 15;
|
||||
if (rangeSeconds <= HOUR) return 30;
|
||||
if (rangeSeconds <= 3 * HOUR) return 2 * MINUTE;
|
||||
if (rangeSeconds <= 6 * HOUR) return 5 * MINUTE;
|
||||
if (rangeSeconds <= 12 * HOUR) return 10 * MINUTE;
|
||||
if (rangeSeconds <= DAY) return 15 * MINUTE;
|
||||
if (rangeSeconds <= 3 * DAY) return HOUR;
|
||||
return 2 * HOUR;
|
||||
}
|
||||
|
||||
export function processMetricsData(metricName, responseData, prevData, rangeSeconds) {
|
||||
if (!responseData) {
|
||||
console.error('No data received for processing');
|
||||
return prevData || {};
|
||||
}
|
||||
|
||||
// Добавим обработку случая, когда данные приходят в формате {metric, data, metadata}
|
||||
const rawData = responseData.data || (Array.isArray(responseData) ? responseData : [responseData]);
|
||||
|
||||
const newData = { ...(prevData || {}) };
|
||||
|
||||
rawData.forEach(item => {
|
||||
try {
|
||||
const instance = item.instance || item.metric?.instance || 'default';
|
||||
if (!newData[instance]) newData[instance] = [];
|
||||
|
||||
// Обработка timestamp
|
||||
let timestamp = item.timestamp;
|
||||
if (typeof timestamp !== 'number') {
|
||||
timestamp = Date.now();
|
||||
} else if (timestamp < 1e12) { // Если timestamp в секундах
|
||||
timestamp *= 1000;
|
||||
}
|
||||
|
||||
// Обработка value
|
||||
let value = item.value;
|
||||
if (value === undefined && item.metric?.value !== undefined) {
|
||||
value = item.metric.value;
|
||||
}
|
||||
if (typeof value !== 'number') {
|
||||
value = parseFloat(value);
|
||||
if (isNaN(value)) {
|
||||
console.warn('Invalid value, using 0 as fallback:', item);
|
||||
value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const formattedTime = formatTime(timestamp, rangeSeconds);
|
||||
|
||||
newData[instance].push({
|
||||
time: formattedTime.display,
|
||||
fullTime: formattedTime.fullDisplay,
|
||||
value: value,
|
||||
timestamp: timestamp,
|
||||
meta: {
|
||||
description: item.description || item.metric?.description,
|
||||
type: item.type || item.metric?.type,
|
||||
status: item.status || item.metric?.status
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error processing metric item:', item, error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Сортировка и ограничение данных
|
||||
Object.keys(newData).forEach(instance => {
|
||||
newData[instance] = newData[instance]
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
.slice(-1000);
|
||||
});
|
||||
|
||||
return newData;
|
||||
}
|
||||
|
||||
export function interpolateData(data, targetPointCount, timeRangeSeconds) {
|
||||
if (!data || data.length < 2) return data || [];
|
||||
if (data.length >= targetPointCount) return data;
|
||||
|
||||
const interpolated = [];
|
||||
const step = (data.length - 1) / (targetPointCount - 1);
|
||||
|
||||
for (let i = 0; i < targetPointCount; i++) {
|
||||
const index = i * step;
|
||||
const lowerIndex = Math.floor(index);
|
||||
const upperIndex = Math.ceil(index);
|
||||
|
||||
if (lowerIndex === upperIndex) {
|
||||
interpolated.push(data[lowerIndex]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fraction = index - lowerIndex;
|
||||
const lower = data[lowerIndex];
|
||||
const upper = data[upperIndex];
|
||||
|
||||
const interpolatedPoint = {
|
||||
time: '',
|
||||
fullTime: '',
|
||||
value: lower.value + fraction * (upper.value - lower.value),
|
||||
timestamp: lower.timestamp + fraction * (upper.timestamp - lower.timestamp)
|
||||
};
|
||||
|
||||
// Форматирование времени
|
||||
const formatted = formatTime(interpolatedPoint.timestamp, timeRangeSeconds || DAY);
|
||||
interpolatedPoint.time = formatted.display;
|
||||
interpolatedPoint.fullTime = formatted.fullDisplay;
|
||||
|
||||
interpolated.push(interpolatedPoint);
|
||||
|
||||
console.log('Item:', item.value, timestamp, formattedTime.display);
|
||||
}
|
||||
|
||||
return interpolated;
|
||||
}
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceArea } from 'recharts';
|
||||
import io from 'socket.io-client';
|
||||
import axios from 'axios';
|
||||
import { Select, Button, Space, DatePicker, Spin, Alert } from 'antd';
|
||||
import moment from 'moment';
|
||||
|
||||
const { Option } = Select;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const timeRanges = [
|
||||
{ label: '1 мин', value: 1 },
|
||||
{ label: '5 мин', value: 5 },
|
||||
{ label: '30 мин', value: 30 },
|
||||
{ label: '1 час', value: 60 },
|
||||
{ label: '3 часа', value: 180 },
|
||||
{ label: '6 часов', value: 360 },
|
||||
{ label: '12 часов', value: 720 },
|
||||
{ label: '24 часа', value: 1440 },
|
||||
];
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
if (!status) return '#1890ff';
|
||||
switch (status.toUpperCase()) {
|
||||
case 'OK': return '#52c41a';
|
||||
case 'WARNING': return '#faad14';
|
||||
case 'CRITICAL': return '#f5222d';
|
||||
default: return '#1890ff';
|
||||
}
|
||||
};
|
||||
|
||||
const MetricChart = ({ metricName, title }) => {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedRange, setSelectedRange] = useState(timeRanges[0]);
|
||||
const [customRange, setCustomRange] = useState([]); // Используем массив вместо null для RangePicker
|
||||
const [isLive, setIsLive] = useState(true);
|
||||
const [refAreaLeft, setRefAreaLeft] = useState(null);
|
||||
const [refAreaRight, setRefAreaRight] = useState(null);
|
||||
const socketRef = useRef(null);
|
||||
const dataRef = useRef([]);
|
||||
|
||||
// Форматирование данных для графика
|
||||
const formatData = useCallback((rawData) => {
|
||||
if (!Array.isArray(rawData)) {
|
||||
console.error('Expected array but received:', rawData);
|
||||
return [];
|
||||
}
|
||||
return rawData.map(item => ({
|
||||
timestamp: item.timestamp,
|
||||
time: moment(item.timestamp).format('HH:mm:ss'),
|
||||
value: parseFloat(item.value) || 0,
|
||||
status: item.status
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Загрузка исторических данных
|
||||
const fetchHistoricalData = useCallback(async (start, end) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const duration = moment.duration(end.diff(start)).asMinutes();
|
||||
const step = Math.max(1, Math.floor(duration / 100)) + 's';
|
||||
|
||||
const response = await axios.get(`${import.meta.env.VITE_BACK_HTTP_URL}/metrics`, {
|
||||
params: {
|
||||
metric: metricName,
|
||||
start: start.valueOf(),
|
||||
end: end.valueOf(),
|
||||
step: step
|
||||
},
|
||||
headers: {
|
||||
'Accept': 'application/json' // Убедимся, что получаем JSON
|
||||
}
|
||||
});
|
||||
|
||||
if (response.headers['content-type'].includes('text/html')) {
|
||||
throw new Error('Server returned HTML instead of JSON. Check your API endpoint.');
|
||||
}
|
||||
|
||||
const formattedData = formatData(response.data);
|
||||
dataRef.current = formattedData;
|
||||
setData(formattedData);
|
||||
setIsLive(false);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || err.message || 'Failed to fetch data');
|
||||
console.error('Error fetching historical data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [metricName, formatData]);
|
||||
|
||||
// Подключение к WebSocket и загрузка начальных данных
|
||||
const connectWebSocket = useCallback(() => {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
}
|
||||
|
||||
socketRef.current = io(`${import.meta.env.VITE_BACK_WS_URL}/api/metrics-ws`, {
|
||||
transports: ['websocket'],
|
||||
reconnectionAttempts: 5
|
||||
});
|
||||
|
||||
socketRef.current.on('connect', () => {
|
||||
console.log('WebSocket connected');
|
||||
socketRef.current.emit('subscribe-metric', {
|
||||
metric: metricName,
|
||||
interval: 5000
|
||||
});
|
||||
});
|
||||
|
||||
socketRef.current.on('metrics-data', (response) => {
|
||||
if (response.metric === metricName && response.data) {
|
||||
try {
|
||||
const newDataPoint = formatData([response.data])[0]; // Оборачиваем в массив
|
||||
if (newDataPoint) {
|
||||
dataRef.current = [...dataRef.current, newDataPoint].slice(-1000);
|
||||
if (isLive) {
|
||||
const now = moment();
|
||||
const cutoff = now.subtract(selectedRange.value, 'minutes');
|
||||
setData(dataRef.current.filter(item => moment(item.timestamp).isAfter(cutoff)));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error processing WebSocket data:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socketRef.current.on('error', (err) => {
|
||||
setError(err.message || 'WebSocket error');
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.emit('unsubscribe-metric');
|
||||
socketRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [metricName, formatData, isLive, selectedRange.value]);
|
||||
|
||||
// Обработчики изменения диапазона
|
||||
const handleRangeChange = (value) => {
|
||||
const range = timeRanges.find(r => r.value === value);
|
||||
if (!range) return;
|
||||
|
||||
setSelectedRange(range);
|
||||
setCustomRange([]); // Сбрасываем кастомный диапазон
|
||||
setIsLive(true);
|
||||
|
||||
const now = moment();
|
||||
const cutoff = now.subtract(range.value, 'minutes');
|
||||
setData(dataRef.current.filter(item => moment(item.timestamp).isAfter(cutoff)));
|
||||
};
|
||||
|
||||
const handleCustomRange = (dates) => {
|
||||
if (!dates || dates.length !== 2) {
|
||||
setCustomRange([]);
|
||||
setIsLive(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const [start, end] = dates;
|
||||
setCustomRange(dates);
|
||||
fetchHistoricalData(start, end);
|
||||
};
|
||||
|
||||
// Эффекты
|
||||
useEffect(() => {
|
||||
if (isLive) {
|
||||
const cleanup = connectWebSocket();
|
||||
// Загружаем начальные данные
|
||||
const end = moment();
|
||||
const start = end.clone().subtract(selectedRange.value, 'minutes');
|
||||
fetchHistoricalData(start, end);
|
||||
return cleanup;
|
||||
}
|
||||
}, [isLive, connectWebSocket, selectedRange.value, fetchHistoricalData]);
|
||||
|
||||
// Обработчики для zoom на графике
|
||||
const handleMouseDown = (e) => {
|
||||
if (!e || !e.activeLabel) return;
|
||||
setRefAreaLeft(e.activeLabel);
|
||||
setRefAreaRight(e.activeLabel);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!refAreaLeft || !e.activeLabel) return;
|
||||
setRefAreaRight(e.activeLabel);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!refAreaLeft || !refAreaRight) return;
|
||||
|
||||
const leftIdx = data.findIndex(d => d.time === refAreaLeft);
|
||||
const rightIdx = data.findIndex(d => d.time === refAreaRight);
|
||||
|
||||
if (leftIdx !== -1 && rightIdx !== -1) {
|
||||
const start = moment(Math.min(data[leftIdx].timestamp, data[rightIdx].timestamp));
|
||||
const end = moment(Math.max(data[leftIdx].timestamp, data[rightIdx].timestamp));
|
||||
fetchHistoricalData(start, end);
|
||||
}
|
||||
|
||||
setRefAreaLeft(null);
|
||||
setRefAreaRight(null);
|
||||
};
|
||||
|
||||
const handleBackToLive = () => {
|
||||
setIsLive(true);
|
||||
setCustomRange([]);
|
||||
setSelectedRange(timeRanges[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: 400 }}>
|
||||
<h3>{title}</h3>
|
||||
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
value={selectedRange.value}
|
||||
onChange={handleRangeChange}
|
||||
disabled={!isLive}
|
||||
style={{ width: 120 }}
|
||||
>
|
||||
{timeRanges.map(range => (
|
||||
<Option key={range.value} value={range.value}>{range.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<RangePicker
|
||||
showTime={{ format: 'HH:mm' }}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
onChange={handleCustomRange}
|
||||
disabled={!isLive}
|
||||
value={customRange} // Устанавливаем значение для избежания предупреждения
|
||||
/>
|
||||
|
||||
{!isLive && (
|
||||
<Button onClick={handleBackToLive}>Реальное время</Button>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{error && <Alert message={error} type="error" showIcon />}
|
||||
{loading && <Spin tip="Loading..." size="large" />}
|
||||
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart
|
||||
data={data}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" />
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
formatter={(value, name, props) => [
|
||||
`${value} (${props.payload?.status || 'N/A'})`,
|
||||
name
|
||||
]}
|
||||
labelFormatter={(label) => {
|
||||
// Исправляем предупреждение Moment.js
|
||||
if (!label) return '';
|
||||
return moment(label, 'HH:mm:ss').isValid()
|
||||
? moment(label, 'HH:mm:ss').format('YYYY-MM-DD HH:mm:ss')
|
||||
: label;
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#1890ff"
|
||||
fill="#1890ff"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
|
||||
{refAreaLeft && refAreaRight && (
|
||||
<ReferenceArea
|
||||
x1={refAreaLeft}
|
||||
x2={refAreaRight}
|
||||
strokeOpacity={0.3}
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MetricChart);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
import { webSocketManager } from './WebSocketManager';
|
||||
import LineChartComponent from './Components/LineChartComponent';
|
||||
import { TimeRangeSelector } from './Components/TimeRangeSelector';
|
||||
import { ConnectionStatusIndicator } from './Components/ConnectionStatusIndicator';
|
||||
|
|
@ -19,7 +19,6 @@ const PrometheusChart = ({ metricName }) => {
|
|||
const [isSelectingRange, setIsSelectingRange] = useState(false);
|
||||
const [lastCustomRange, setLastCustomRange] = useState(null);
|
||||
const intervalRef = useRef(null);
|
||||
const socketRef = useRef(null);
|
||||
const debounceRef = useRef(null);
|
||||
|
||||
const formatTime = useCallback((timestamp, rangeSeconds) => {
|
||||
|
|
@ -70,25 +69,6 @@ const PrometheusChart = ({ metricName }) => {
|
|||
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) => {
|
||||
console.log('Processing metrics data:', response);
|
||||
|
|
@ -137,58 +117,32 @@ const PrometheusChart = ({ metricName }) => {
|
|||
});
|
||||
}, [metricName, selectedRange.value, formatTime, useCustomRange, startDate, endDate]);
|
||||
|
||||
const setupWebSocket = useCallback(() => {
|
||||
if (socketRef.current) {
|
||||
// Если соединение уже существует, возвращаем его
|
||||
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,
|
||||
});
|
||||
const fetchData = useCallback(() => {
|
||||
if (isSelectingRange) return;
|
||||
|
||||
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', () => {
|
||||
console.log('WebSocket connected');
|
||||
setConnectionStatus('connected');
|
||||
fetchData();
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('WebSocket disconnected:', reason);
|
||||
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;
|
||||
}, []);
|
||||
webSocketManager.getMetricsRange(metricName, start, end, step)
|
||||
.then(data => {
|
||||
processMetricsData({ metric: metricName, data });
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching metrics:', error);
|
||||
});
|
||||
}, [metricName, selectedRange.value, isSelectingRange, calculateStep, processMetricsData]);
|
||||
|
||||
const fetchCustomRangeData = useCallback(async () => {
|
||||
// Добавляем проверку на валидность дат
|
||||
if (!startDate || !endDate || startDate >= endDate) {
|
||||
console.error('Invalid date range');
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Math.floor(startDate.getTime() / 1000);
|
||||
const end = Math.floor(endDate.getTime() / 1000);
|
||||
const end = Math.ceil(endDate.getTime() / 1000); // Используем Math.ceil для конечной даты
|
||||
const rangeSeconds = end - start;
|
||||
|
||||
try {
|
||||
|
|
@ -202,11 +156,11 @@ const PrometheusChart = ({ metricName }) => {
|
|||
});
|
||||
|
||||
if (response.data?.length) {
|
||||
// Преобразуем данные перед передачей в processMetricsData
|
||||
// Добавляем нормализацию timestamp
|
||||
const processedData = response.data.map(item => ({
|
||||
...item,
|
||||
timestamp: item.timestamp, // оставляем в секундах - processMetricsData конвертирует
|
||||
value: item.value.toString()
|
||||
timestamp: item.timestamp > 1e12 ? item.timestamp : item.timestamp * 1000,
|
||||
value: parseFloat(item.value)
|
||||
}));
|
||||
|
||||
processMetricsData({
|
||||
|
|
@ -220,8 +174,7 @@ const PrometheusChart = ({ metricName }) => {
|
|||
}, [metricName, startDate, endDate, calculateStep, processMetricsData]);
|
||||
|
||||
|
||||
const handleRangeChange = useCallback((event) => {
|
||||
// Очищаем текущий интервал
|
||||
const handleRangeChange = useCallback(async (event) => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
|
|
@ -230,9 +183,10 @@ const PrometheusChart = ({ metricName }) => {
|
|||
const selectedValue = event.target.value;
|
||||
const range = TIME_RANGES.find(r => r.value === parseInt(selectedValue, 10));
|
||||
|
||||
// Полный сброс состояния перед загрузкой новых данных
|
||||
setChartData(null);
|
||||
setSelectedRange(range);
|
||||
setUseCustomRange(false);
|
||||
setChartData(null);
|
||||
setSelectedGraphRange(null);
|
||||
setFilteredData(null);
|
||||
|
||||
|
|
@ -240,20 +194,12 @@ const PrometheusChart = ({ metricName }) => {
|
|||
setEndDate(now);
|
||||
setStartDate(new Date(now.getTime() - range.value * 1000));
|
||||
|
||||
// Переподключение сокета
|
||||
if (!socketRef.current?.connected) {
|
||||
socketRef.current?.connect();
|
||||
}
|
||||
}, []);
|
||||
// Ждем завершения обновления состояния перед загрузкой
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleCustomRangeChange = useCallback(() => {
|
||||
// Отключаем WebSocket соединение
|
||||
if (socketRef.current?.connected) {
|
||||
socketRef.current.disconnect();
|
||||
setConnectionStatus('disconnected');
|
||||
}
|
||||
|
||||
// Очищаем интервал обновления
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
|
|
@ -266,25 +212,7 @@ const PrometheusChart = ({ metricName }) => {
|
|||
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) => {
|
||||
if (!data || data.length < 2) return data;
|
||||
|
|
@ -337,10 +265,6 @@ const PrometheusChart = ({ metricName }) => {
|
|||
setIsSelectingRange(true);
|
||||
setSelectedGraphRange(range);
|
||||
|
||||
// Отключаем автоматические обновления
|
||||
if (socketRef.current?.connected) {
|
||||
socketRef.current.disconnect();
|
||||
}
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
|
|
@ -366,23 +290,52 @@ const PrometheusChart = ({ metricName }) => {
|
|||
setIsSelectingRange(false);
|
||||
}, [chartData, interpolateData, formatTime]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = setupWebSocket();
|
||||
return () => {
|
||||
clearInterval(intervalRef.current);
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [setupWebSocket]);
|
||||
const handleResetZoom = useCallback(() => {
|
||||
setSelectedGraphRange(null);
|
||||
setFilteredData(null);
|
||||
setIsSelectingRange(false);
|
||||
|
||||
if (useCustomRange) {
|
||||
fetchCustomRangeData();
|
||||
} else {
|
||||
fetchData();
|
||||
}
|
||||
|
||||
if (lastCustomRange) {
|
||||
handleRangeSelect(lastCustomRange);
|
||||
}
|
||||
}, [fetchData, fetchCustomRangeData, useCustomRange, lastCustomRange, handleRangeSelect]);
|
||||
|
||||
useEffect(() => {
|
||||
// Обработчик данных с сервера
|
||||
const handleMetricsData = (data) => {
|
||||
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(() => {
|
||||
if (useCustomRange && !isSelectingRange) {
|
||||
// Очищаем предыдущий таймер
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
// Устанавливаем новый таймер с задержкой 500 мс
|
||||
debounceRef.current = setTimeout(() => {
|
||||
fetchCustomRangeData();
|
||||
}, 500);
|
||||
|
|
@ -406,12 +359,11 @@ const PrometheusChart = ({ metricName }) => {
|
|||
}
|
||||
};
|
||||
|
||||
// Очищаем предыдущий интервал
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
// Запускаем сразу и затем по интервалу
|
||||
|
||||
fetchDataWrapper();
|
||||
intervalRef.current = setInterval(fetchDataWrapper, selectedRange.interval);
|
||||
|
||||
|
|
@ -490,5 +442,4 @@ const PrometheusChart = ({ metricName }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default React.memo(PrometheusChart);
|
||||
|
||||
export default React.memo(PrometheusChart);
|
||||
|
|
@ -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();
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Drawer,
|
||||
List,
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from "@mui/icons-material";
|
||||
import MenuItem from "./SidebarMenuComponents/MenuItem";
|
||||
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
|
||||
import { statusManager1 } from "../TreeChart/dataUtils";
|
||||
|
||||
const SidebarResizer = styled('div')(({ theme }) => ({
|
||||
width: "5px",
|
||||
|
|
@ -34,6 +35,17 @@ const SidebarResizer = styled('div')(({ theme }) => ({
|
|||
const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => {
|
||||
const [collapsed, setCollapsed] = 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 = () => {
|
||||
setCollapsed(!collapsed);
|
||||
|
|
@ -126,11 +138,13 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing }) => {
|
|||
Меню
|
||||
</Typography>
|
||||
)}
|
||||
<MenuItem
|
||||
item={data}
|
||||
onSelectItem={handleSelectItem}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
{menuData && (
|
||||
<MenuItem
|
||||
item={menuData}
|
||||
onSelectItem={handleSelectItem}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
)}
|
||||
</List>
|
||||
|
||||
{/* Футер */}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ import {
|
|||
styled
|
||||
} from "@mui/material";
|
||||
import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
|
||||
import { getStatusColor } from "../../TreeChart/dataUtils";
|
||||
|
||||
|
||||
|
||||
const StyledListItem = styled(ListItem)(({ theme, level }) => ({
|
||||
cursor: "pointer",
|
||||
paddingLeft: theme.spacing(2 + level * 2),
|
||||
position: 'relative', // Добавляем для позиционирования индикатора
|
||||
'&: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 }) => ({
|
||||
cursor: "pointer",
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
|
|
@ -33,7 +48,6 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
|
|||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
||||
|
||||
|
||||
const handleToggle = (e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
|
|
@ -52,17 +66,20 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
|
|||
onClick={hasChildren ? handleToggle : handleOpenTab}
|
||||
level={level}
|
||||
sx={{
|
||||
pl: collapsed ? 2 : 2 + level * 2, // Адаптируем отступы
|
||||
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 && ( // Показываем текст только в развернутом состоянии
|
||||
{!collapsed && (
|
||||
<>
|
||||
<ListItemText
|
||||
primary={item.title}
|
||||
|
|
@ -75,7 +92,7 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
|
|||
)}
|
||||
</StyledListItem>
|
||||
|
||||
{hasChildren && !collapsed && ( // Показываем детей только в развернутом состоянии
|
||||
{hasChildren && !collapsed && (
|
||||
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{item.items.map((child, index) => (
|
||||
|
|
@ -94,7 +111,6 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
|
|||
);
|
||||
};
|
||||
|
||||
// Вспомогательная функция (остается без изменений)
|
||||
const getAllChildren = (node) => {
|
||||
let children = [];
|
||||
if (node.items && node.items.length > 0) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
// src/services/StatusService.js
|
||||
import { statusManager1, statusManager2 } from "../TreeChart/dataUtils";
|
||||
|
||||
class StatusService {
|
||||
constructor() {
|
||||
this.statusData = null;
|
||||
this.subscribers = new Set();
|
||||
this.pollingInterval = null;
|
||||
}
|
||||
|
||||
// Подписка на обновления статусов
|
||||
subscribe(callback) {
|
||||
this.subscribers.add(callback);
|
||||
return () => this.unsubscribe(callback);
|
||||
}
|
||||
|
||||
unsubscribe(callback) {
|
||||
this.subscribers.delete(callback);
|
||||
}
|
||||
|
||||
// Запуск периодического обновления статусов
|
||||
startPolling(interval = 30000) {
|
||||
this.fetchStatuses(); // Первый запрос сразу
|
||||
this.pollingInterval = setInterval(() => this.fetchStatuses(), interval);
|
||||
}
|
||||
|
||||
stopPolling() {
|
||||
if (this.pollingInterval) {
|
||||
clearInterval(this.pollingInterval);
|
||||
this.pollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Запрос статусов с бэкенда
|
||||
async fetchStatuses() {
|
||||
try {
|
||||
const response = await fetch('/api/metrics/all-values');
|
||||
const data = await response.json();
|
||||
|
||||
// Преобразуем данные в нужную структуру
|
||||
const transformedData = this.transformData(data);
|
||||
|
||||
// Обновляем статусы с помощью менеджера
|
||||
statusManager1.updateStatuses(transformedData);
|
||||
|
||||
// Сохраняем данные
|
||||
this.statusData = transformedData;
|
||||
|
||||
// Оповещаем подписчиков
|
||||
this.notifySubscribers();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching statuses:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Преобразование данных от бэкенда в древовидную структуру
|
||||
transformData(apiData) {
|
||||
// Здесь реализуйте преобразование под вашу структуру
|
||||
// Пример:
|
||||
return {
|
||||
name: "Root System",
|
||||
status: "0",
|
||||
items: apiData.map(item => ({
|
||||
id: item.metric.__name__,
|
||||
title: item.metric.__name__,
|
||||
status: item.data[0]?.status || "0",
|
||||
items: [] // Могут быть вложенные элементы
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
// Получение текущих данных
|
||||
getStatusData() {
|
||||
return this.statusData;
|
||||
}
|
||||
|
||||
// Оповещение подписчиков
|
||||
notifySubscribers() {
|
||||
this.subscribers.forEach(callback => callback(this.statusData));
|
||||
}
|
||||
}
|
||||
|
||||
// Экспортируем singleton экземпляр сервиса
|
||||
export const statusService = new StatusService();
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import React, { lazy, Suspense } from "react";
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
const PrometheusChart = lazy(() => import('../../Charts/PrometheusChart'));
|
||||
import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender";
|
||||
|
|
@ -22,9 +24,32 @@ const getAllChildIds = (node) => {
|
|||
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 = {};
|
||||
|
||||
// Функция для рекурсивного обхода и сбора данных
|
||||
// Функция для рекурсивного обхода и сбора данных
|
||||
const generateContent = (nodes) => {
|
||||
nodes.forEach((node) => {
|
||||
|
|
@ -36,9 +61,11 @@ const tabContent = (data) => {
|
|||
const content = (
|
||||
<div>
|
||||
<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>
|
||||
{childrenContent}
|
||||
{/*childrenContent*/}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -53,7 +80,7 @@ const tabContent = (data) => {
|
|||
const content = (
|
||||
<div key={node.id}>
|
||||
<h3>{node.title}</h3> {/* Используем title узла */}
|
||||
<Suspense fallback={<div>Загрузка графика...</div>}>
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<PrometheusChart metricName={metricName} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export const lightTheme = createTheme({
|
|||
|
||||
// Фоновые цвета
|
||||
background: {
|
||||
default: "#6CACE4", // Основной фон приложения
|
||||
default: "#FFFFFF", // Основной фон приложения
|
||||
paper: "#FFFFFF", // Фон "бумажных" поверхностей (карточек, панелей)
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue