forked from BLC/AyposWeb
fix: improve stress testing functionality and stop button behavior
This commit is contained in:
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
/coverage
|
||||
|
||||
# Production
|
||||
/build
|
||||
/dist
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
58
index.html
Normal file
58
index.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/src/assets/blc-logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BLC Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: linear-gradient(135deg, #0a1929 0%, #132f4c 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #00bcd4;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #62efff;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8841
package-lock.json
generated
Normal file
8841
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
package.json
Normal file
46
package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "ayposweb",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.3",
|
||||
"@mui/lab": "^5.0.0-alpha.165",
|
||||
"@mui/material": "^5.15.3",
|
||||
"@mui/x-tree-view": "^7.26.0",
|
||||
"@types/plotly.js": "^2.35.2",
|
||||
"axios": "^1.8.4",
|
||||
"chart.js": "^4.4.1",
|
||||
"date-fns": "^3.0.6",
|
||||
"plotly.js": "^3.0.1",
|
||||
"plotly.js-dist": "^3.0.1",
|
||||
"plotly.js-dist-min": "^3.0.1",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-google-charts": "^5.2.1",
|
||||
"react-plotly.js": "^2.6.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"recharts": "^2.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
32
src/App.tsx
Normal file
32
src/App.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { theme } from './theme';
|
||||
import MainLayout from './components/Layout/MainLayout';
|
||||
import Home from './pages/Home';
|
||||
import Temperature from './pages/Temperature';
|
||||
import Maintenance from './pages/Maintenance';
|
||||
import Migration from './pages/Migration';
|
||||
import MonitoringSystem from './pages/MonitoringSystem';
|
||||
import StressTesting from './pages/StressTesting';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Router>
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/temperature" element={<Temperature />} />
|
||||
<Route path="/maintenance" element={<Maintenance />} />
|
||||
<Route path="/migration" element={<Migration />} />
|
||||
<Route path="/monitoring" element={<MonitoringSystem />} />
|
||||
<Route path="/stress-testing" element={<StressTesting />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
BIN
src/assets/bgreen-logo.png
Normal file
BIN
src/assets/bgreen-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
src/assets/blc-logo.png
Normal file
BIN
src/assets/blc-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
15
src/assets/blc-logo.svg
Normal file
15
src/assets/blc-logo.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="120" height="40" viewBox="0 0 120 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<!-- Cloud part -->
|
||||
<path d="M30 15c-3.3 0-6 2.7-6 6 0 .3 0 .7.1 1H24c-2.2 0-4 1.8-4 4s1.8 4 4 4h12c2.2 0 4-1.8 4-4 0-1.5-.8-2.8-2-3.4-.3-4.2-3.8-7.6-8-7.6z" fill="#00B4E6"/>
|
||||
<!-- Connection dots -->
|
||||
<circle cx="18" cy="28" r="2" fill="#00B4E6"/>
|
||||
<circle cx="14" cy="28" r="2" fill="#00B4E6"/>
|
||||
<!-- BLC text part -->
|
||||
<path d="M60 15h20c5.5 0 10 4.5 10 10s-4.5 10-10 10H60V15z" fill="#FF6B35"/>
|
||||
<!-- Radar/signal part -->
|
||||
<path d="M95 15c0 11 9 20 20 20" stroke="#FF6B35" stroke-width="4" fill="none"/>
|
||||
<circle cx="115" cy="35" r="3" fill="#1A237E"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 767 B |
110
src/components/DebugConsole.tsx
Normal file
110
src/components/DebugConsole.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
IconButton,
|
||||
Button,
|
||||
Collapse,
|
||||
Divider,
|
||||
styled,
|
||||
} from '@mui/material';
|
||||
import BugReportIcon from '@mui/icons-material/BugReport';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
|
||||
const DebugPanel = styled(Paper)(({ theme }) => ({
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
right: 20,
|
||||
width: 400,
|
||||
maxHeight: '60vh',
|
||||
borderRadius: theme.spacing(2, 2, 0, 0),
|
||||
padding: theme.spacing(2),
|
||||
zIndex: 1000,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
boxShadow: theme.shadows[10],
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}));
|
||||
|
||||
const ConfigDisplay = styled(Box)(({ theme }) => ({
|
||||
backgroundColor: theme.palette.mode === 'dark' ? '#1e1e1e' : '#f5f5f5',
|
||||
padding: theme.spacing(1.5),
|
||||
borderRadius: theme.spacing(1),
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'auto',
|
||||
maxHeight: '50vh',
|
||||
'& pre': {
|
||||
margin: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
interface DebugConsoleProps {
|
||||
configData: any | null;
|
||||
}
|
||||
|
||||
const DebugConsole: React.FC<DebugConsoleProps> = ({ configData }) => {
|
||||
const [showDebugPanel, setShowDebugPanel] = React.useState(false);
|
||||
|
||||
const toggleDebugPanel = () => {
|
||||
setShowDebugPanel(prev => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Debug Panel Toggle Button */}
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<BugReportIcon />}
|
||||
onClick={toggleDebugPanel}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: showDebugPanel ? 'auto' : 20,
|
||||
right: 20,
|
||||
zIndex: 1001,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
{showDebugPanel ? 'Hide' : 'Show'} Debug Info
|
||||
</Button>
|
||||
|
||||
{/* Debug Panel */}
|
||||
<Collapse in={showDebugPanel}>
|
||||
<DebugPanel>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CodeIcon color="primary" />
|
||||
Last Monitoring Configuration
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={toggleDebugPanel}>
|
||||
<KeyboardArrowDownIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{configData ? (
|
||||
<ConfigDisplay>
|
||||
<pre>{JSON.stringify(configData, null, 2)}</pre>
|
||||
</ConfigDisplay>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic', textAlign: 'center', py: 2 }}>
|
||||
No configuration has been sent yet. Start monitoring to see the data structure.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Tip: You can also view this information in the browser console by pressing F12.
|
||||
</Typography>
|
||||
</Box>
|
||||
</DebugPanel>
|
||||
</Collapse>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebugConsole;
|
||||
63
src/components/Layout/MainLayout.tsx
Normal file
63
src/components/Layout/MainLayout.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, IconButton, useTheme, useMediaQuery } from '@mui/material';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
const MainLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const [sidebarOpen, setSidebarOpen] = useState(!isMobile);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setSidebarOpen(!sidebarOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar
|
||||
open={sidebarOpen}
|
||||
onToggle={toggleSidebar}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
bgcolor: 'background.default',
|
||||
position: 'relative',
|
||||
overflow: 'auto',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
{isMobile && (
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
edge="start"
|
||||
onClick={toggleSidebar}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
left: 16,
|
||||
zIndex: theme.zIndex.drawer + 2,
|
||||
bgcolor: theme.palette.primary.main,
|
||||
color: '#fff',
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette.primary.dark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<Box sx={{ p: { xs: 2, sm: 3 }, mt: { xs: 7, md: 0 } }}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
200
src/components/Layout/Sidebar.tsx
Normal file
200
src/components/Layout/Sidebar.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
Box,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
styled,
|
||||
Typography,
|
||||
ListItemIcon,
|
||||
Drawer,
|
||||
useTheme,
|
||||
IconButton
|
||||
} from '@mui/material';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import ThermostatIcon from '@mui/icons-material/Thermostat';
|
||||
import BuildIcon from '@mui/icons-material/Build';
|
||||
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
import bgreenLogo from '../../assets/bgreen-logo.png';
|
||||
|
||||
const DRAWER_WIDTH = 240;
|
||||
|
||||
const LogoContainer = styled(Box)(() => ({
|
||||
padding: '20px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.1)',
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0) 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}));
|
||||
|
||||
const StyledListItemButton = styled(ListItemButton)(() => ({
|
||||
margin: '4px 8px',
|
||||
borderRadius: 4,
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||
},
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: '#ffffff',
|
||||
},
|
||||
'& .MuiListItemText-primary': {
|
||||
color: '#ffffff',
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
},
|
||||
}));
|
||||
|
||||
const menuItems = [
|
||||
{ text: 'Home', path: '/', icon: <HomeIcon /> },
|
||||
{ text: 'Environmental Temperature', path: '/temperature', icon: <ThermostatIcon /> },
|
||||
{ text: 'Preventive Maintenance', path: '/maintenance', icon: <BuildIcon /> },
|
||||
{ text: 'Migration Advice', path: '/migration', icon: <SwapHorizIcon /> },
|
||||
{ text: 'Stress Testing', path: '/stress-testing', icon: <SpeedIcon /> },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
const Sidebar = ({ open, onToggle, isMobile }: SidebarProps) => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleNavigation = (path: string) => {
|
||||
navigate(path);
|
||||
if (isMobile) {
|
||||
onToggle();
|
||||
}
|
||||
};
|
||||
|
||||
const drawerContent = (
|
||||
<>
|
||||
<LogoContainer>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<img
|
||||
src={bgreenLogo}
|
||||
alt="B'GREEN Logo"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#ffffff',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
B'GREEN
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
display: 'block',
|
||||
marginTop: '-2px',
|
||||
}}
|
||||
>
|
||||
Monitor System
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{isMobile && (
|
||||
<IconButton
|
||||
onClick={onToggle}
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
'&:hover': { color: '#ffffff' }
|
||||
}}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</LogoContainer>
|
||||
|
||||
<List sx={{ flexGrow: 1, mt: 2 }}>
|
||||
{menuItems.map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<StyledListItemButton
|
||||
selected={location.pathname === item.path}
|
||||
onClick={() => handleNavigation(item.path)}
|
||||
>
|
||||
<ListItemIcon sx={{ color: 'rgba(255,255,255,0.7)', minWidth: 40 }}>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.text}
|
||||
sx={{
|
||||
'& .MuiListItemText-primary': {
|
||||
fontSize: '0.9rem',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</StyledListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderTop: '1px solid rgba(255,255,255,0.1)',
|
||||
textAlign: 'center',
|
||||
background: 'linear-gradient(0deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0) 100%)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)' }}>
|
||||
2024 B'GREEN
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{
|
||||
width: { md: DRAWER_WIDTH },
|
||||
flexShrink: { md: 0 },
|
||||
}}
|
||||
>
|
||||
<Drawer
|
||||
variant={isMobile ? "temporary" : "permanent"}
|
||||
open={isMobile ? open : true}
|
||||
onClose={isMobile ? onToggle : undefined}
|
||||
ModalProps={{
|
||||
keepMounted: true, // Better open performance on mobile
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: 'block' },
|
||||
'& .MuiDrawer-paper': {
|
||||
width: DRAWER_WIDTH,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
border: 'none',
|
||||
height: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
247
src/components/Migration/MigrationAdviceCard.tsx
Normal file
247
src/components/Migration/MigrationAdviceCard.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Typography,
|
||||
IconButton,
|
||||
Box,
|
||||
Grid,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import { GainBeforeData, MigrationAdviceData } from './types';
|
||||
|
||||
interface MigrationAdviceCardProps {
|
||||
isCardExpanded: boolean;
|
||||
setIsCardExpanded: (expanded: boolean) => void;
|
||||
gainBeforeData: GainBeforeData | null;
|
||||
migrationAdviceData: MigrationAdviceData | null;
|
||||
isLoadingGainData: boolean;
|
||||
onRefresh: (e: React.MouseEvent) => void;
|
||||
migrationMode: 'auto' | 'semiauto';
|
||||
}
|
||||
|
||||
const MigrationAdviceCard: React.FC<MigrationAdviceCardProps> = ({
|
||||
isCardExpanded,
|
||||
setIsCardExpanded,
|
||||
gainBeforeData,
|
||||
migrationAdviceData,
|
||||
isLoadingGainData,
|
||||
onRefresh,
|
||||
migrationMode,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper
|
||||
onClick={() => setIsCardExpanded(!isCardExpanded)}
|
||||
sx={{
|
||||
height: isCardExpanded ? 'auto' : '320px',
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 3,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
'&:hover': {
|
||||
boxShadow: 6,
|
||||
},
|
||||
position: isCardExpanded ? 'absolute' : 'relative',
|
||||
right: isCardExpanded ? 0 : 'auto',
|
||||
zIndex: isCardExpanded ? 1000 : 1,
|
||||
width: isCardExpanded ? '100%' : 'auto',
|
||||
maxHeight: isCardExpanded ? '80vh' : '320px',
|
||||
overflowY: isCardExpanded ? 'auto' : 'hidden'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 1,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
bgcolor: 'background.paper',
|
||||
zIndex: 2,
|
||||
py: 1
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h6">Migration Advice</Typography>
|
||||
<Chip
|
||||
icon={<AutoFixHighIcon fontSize="small" />}
|
||||
label={`${migrationMode === 'auto' ? 'Auto' : 'Semi-Auto'} Mode`}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: migrationMode === 'auto' ? theme.palette.success.light : theme.palette.warning.light,
|
||||
color: migrationMode === 'auto' ? theme.palette.success.dark : theme.palette.warning.dark,
|
||||
fontWeight: 500,
|
||||
fontSize: '0.7rem',
|
||||
height: '24px',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{isLoadingGainData && <CircularProgress size={20} />}
|
||||
<KeyboardArrowDownIcon
|
||||
fontSize="small"
|
||||
sx={{
|
||||
transform: isCardExpanded ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.2s ease-in-out'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2
|
||||
}}>
|
||||
{/* Power Gain Information */}
|
||||
{gainBeforeData && (
|
||||
<Box sx={{
|
||||
p: 1.5,
|
||||
bgcolor: theme.palette.primary.light,
|
||||
borderRadius: 1,
|
||||
color: 'white',
|
||||
position: 'sticky',
|
||||
top: 48,
|
||||
zIndex: 1
|
||||
}}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="caption" sx={{ opacity: 0.9 }}>
|
||||
Current Power
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{gainBeforeData.cur_power.toFixed(2)} W
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="caption" sx={{ opacity: 0.9 }}>
|
||||
Proposed Power
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{gainBeforeData.prop_power.toFixed(2)} W
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="caption" sx={{ opacity: 0.9 }}>
|
||||
Expected Gain
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: gainBeforeData.prop_gain > 0 ? '#4caf50' : '#f44336'
|
||||
}}
|
||||
>
|
||||
{(gainBeforeData.prop_gain * 100).toFixed(2)}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Migration Advice Table */}
|
||||
{migrationAdviceData && (
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow sx={{
|
||||
'& th': {
|
||||
fontWeight: 'bold',
|
||||
bgcolor: 'primary.main',
|
||||
color: 'white',
|
||||
padding: '4px 8px'
|
||||
}
|
||||
}}>
|
||||
<TableCell>Virtual Machine</TableCell>
|
||||
<TableCell>Current PM</TableCell>
|
||||
<TableCell sx={{ width: 30, p: 0 }}></TableCell>
|
||||
<TableCell>Proposed PM</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.entries(migrationAdviceData).map(([vm, data]) => (
|
||||
<TableRow key={vm}>
|
||||
<TableCell sx={{ padding: '4px 8px' }}>
|
||||
<Chip
|
||||
label={vm}
|
||||
size="small"
|
||||
sx={{ bgcolor: 'info.light', color: 'white', height: '24px' }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ padding: '4px 8px' }}>
|
||||
<Chip
|
||||
label={data.current_pm}
|
||||
size="small"
|
||||
sx={{ bgcolor: 'warning.light', height: '24px' }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{
|
||||
p: 0,
|
||||
textAlign: 'center',
|
||||
fontSize: '20px',
|
||||
color: theme.palette.success.main,
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
→
|
||||
</TableCell>
|
||||
<TableCell sx={{ padding: '4px 8px' }}>
|
||||
<Chip
|
||||
label={data.proposed_pm}
|
||||
size="small"
|
||||
sx={{ bgcolor: 'success.light', color: 'white', height: '24px' }}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!gainBeforeData && !migrationAdviceData && !isLoadingGainData && (
|
||||
<Typography variant="body2" sx={{ textAlign: 'center', color: 'text.secondary' }}>
|
||||
No migration advice available at this time
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{
|
||||
borderTop: 1,
|
||||
borderColor: theme.palette.grey[200],
|
||||
p: 1,
|
||||
bgcolor: theme.palette.grey[50],
|
||||
mt: 'auto',
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 2
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<IconButton
|
||||
onClick={onRefresh}
|
||||
size="small"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default MigrationAdviceCard;
|
||||
228
src/components/Migration/ResourceDistributionChart.tsx
Normal file
228
src/components/Migration/ResourceDistributionChart.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import React from 'react';
|
||||
import { Paper, Typography, IconButton, Box, Grid } from '@mui/material';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
import { Chart } from 'react-chartjs-2';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
interface VM {
|
||||
name: string;
|
||||
power: number;
|
||||
}
|
||||
|
||||
interface VMPlacementData {
|
||||
data_center: string;
|
||||
id: number;
|
||||
physical_machines: Array<{
|
||||
name: string;
|
||||
power_consumption: number;
|
||||
vms: {
|
||||
active: VM[];
|
||||
inactive: VM[];
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ResourceDistributionChartProps {
|
||||
vmPlacementData: VMPlacementData | null;
|
||||
isLoading: boolean;
|
||||
onRefresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ResourceDistributionChart: React.FC<ResourceDistributionChartProps> = ({
|
||||
vmPlacementData,
|
||||
isLoading,
|
||||
onRefresh
|
||||
}) => {
|
||||
const chartData = React.useMemo(() => {
|
||||
if (!vmPlacementData?.physical_machines) {
|
||||
return {
|
||||
labels: [],
|
||||
datasets: []
|
||||
};
|
||||
}
|
||||
|
||||
const physicalMachines = vmPlacementData.physical_machines;
|
||||
const labels = physicalMachines.map(pm => pm.name);
|
||||
const activeVMs = physicalMachines.map(pm => pm.vms.active.length);
|
||||
const inactiveVMs = physicalMachines.map(pm => pm.vms.inactive.length);
|
||||
const totalPower = physicalMachines.map(pm => pm.power_consumption);
|
||||
const vmPower = physicalMachines.map(pm =>
|
||||
pm.vms.active.reduce((sum, vm) => sum + vm.power, 0)
|
||||
);
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
type: 'bar' as const,
|
||||
label: 'Active VMs',
|
||||
data: activeVMs,
|
||||
backgroundColor: '#4caf50',
|
||||
yAxisID: 'vmAxis',
|
||||
stack: 'vms',
|
||||
},
|
||||
{
|
||||
type: 'bar' as const,
|
||||
label: 'Inactive VMs',
|
||||
data: inactiveVMs,
|
||||
backgroundColor: '#ff9800',
|
||||
yAxisID: 'vmAxis',
|
||||
stack: 'vms',
|
||||
},
|
||||
{
|
||||
type: 'line' as const,
|
||||
label: 'Total Power (W)',
|
||||
data: totalPower,
|
||||
borderColor: '#f44336',
|
||||
backgroundColor: 'transparent',
|
||||
yAxisID: 'powerAxis',
|
||||
tension: 0.4,
|
||||
borderWidth: 2,
|
||||
},
|
||||
{
|
||||
type: 'line' as const,
|
||||
label: 'VM Power (W)',
|
||||
data: vmPower,
|
||||
borderColor: '#2196f3',
|
||||
backgroundColor: 'transparent',
|
||||
yAxisID: 'powerAxis',
|
||||
borderDash: [5, 5],
|
||||
tension: 0.4,
|
||||
borderWidth: 2,
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [vmPlacementData]);
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const label = context.dataset.label || '';
|
||||
const value = context.parsed.y;
|
||||
if (label.includes('Power')) {
|
||||
return `${label}: ${value.toFixed(2)} W`;
|
||||
}
|
||||
return `${label}: ${value}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
vmAxis: {
|
||||
type: 'linear' as const,
|
||||
position: 'left' as const,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Number of VMs',
|
||||
},
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
},
|
||||
stacked: true,
|
||||
},
|
||||
powerAxis: {
|
||||
type: 'linear' as const,
|
||||
position: 'right' as const,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Power (W)',
|
||||
},
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid item xs={12} md={8}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
height: '320px',
|
||||
position: 'relative',
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 2
|
||||
}}>
|
||||
<Typography variant="h6">Resource Distribution by Node</Typography>
|
||||
<IconButton
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
sx={{ '&:disabled': { opacity: 0.5 } }}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ height: 'calc(100% - 48px)' }}>
|
||||
<Chart type="bar" data={chartData} options={options} />
|
||||
</Box>
|
||||
{isLoading && (
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.7)',
|
||||
}}>
|
||||
<Typography variant="body2">Loading...</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceDistributionChart;
|
||||
138
src/components/Migration/SummaryStats.tsx
Normal file
138
src/components/Migration/SummaryStats.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Grid, Paper, Typography, Box, Divider } from '@mui/material';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import ComputerIcon from '@mui/icons-material/Computer';
|
||||
|
||||
interface VM {
|
||||
name: string;
|
||||
power: number;
|
||||
}
|
||||
|
||||
interface VMPlacementData {
|
||||
data_center: string;
|
||||
id: number;
|
||||
physical_machines: Array<{
|
||||
name: string;
|
||||
power_consumption: number;
|
||||
vms: {
|
||||
active: VM[];
|
||||
inactive: VM[];
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
const ENDPOINT = 'http://141.196.83.136:8003/prom/get_chart_data/vm_placement';
|
||||
const REFRESH_INTERVAL = 30000; // 30 seconds
|
||||
|
||||
const SummaryStats: React.FC = () => {
|
||||
const [data, setData] = useState<VMPlacementData | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await fetch(ENDPOINT);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch data: ${response.status}`);
|
||||
}
|
||||
const jsonData = await response.json();
|
||||
setData(jsonData);
|
||||
} catch (err) {
|
||||
console.error('Error fetching VM placement data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, REFRESH_INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const stats = React.useMemo(() => {
|
||||
if (!data?.physical_machines) {
|
||||
return {
|
||||
activeComputes: 0,
|
||||
totalComputes: 0,
|
||||
activeVMs: 0,
|
||||
inactiveVMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const activeComputes = data.physical_machines.filter(
|
||||
pm => pm.power_consumption > 0 || pm.vms.active.length > 0
|
||||
).length;
|
||||
|
||||
const totalActiveVMs = data.physical_machines.reduce(
|
||||
(sum, pm) => sum + pm.vms.active.length,
|
||||
0
|
||||
);
|
||||
|
||||
const totalInactiveVMs = data.physical_machines.reduce(
|
||||
(sum, pm) => sum + pm.vms.inactive.length,
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
activeComputes,
|
||||
totalComputes: data.physical_machines.length,
|
||||
activeVMs: totalActiveVMs,
|
||||
inactiveVMs: totalInactiveVMs,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Grid item xs={12}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 3,
|
||||
borderRadius: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<StorageIcon sx={{ color: 'primary.main', fontSize: 28 }} />
|
||||
<Box>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 0.5 }}>
|
||||
Compute Nodes
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ lineHeight: 1, fontWeight: 500 }}>
|
||||
{stats.activeComputes}/{stats.totalComputes}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider orientation="vertical" flexItem />
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<ComputerIcon sx={{ color: 'info.main', fontSize: 28 }} />
|
||||
<Box>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 0.5 }}>
|
||||
Virtual Machines
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
||||
<Typography variant="h6" sx={{ lineHeight: 1, fontWeight: 500 }}>
|
||||
{stats.activeVMs}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="success.main" sx={{ fontWeight: 500 }}>
|
||||
active
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
/
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ lineHeight: 1, fontWeight: 500 }}>
|
||||
{stats.activeVMs + stats.inactiveVMs}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 500 }}>
|
||||
total
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default SummaryStats;
|
||||
163
src/components/Migration/VerifiedMigration.tsx
Normal file
163
src/components/Migration/VerifiedMigration.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Typography,
|
||||
Box,
|
||||
Grid,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
|
||||
interface VerifiedMigrationProps {
|
||||
gainAfterData: {
|
||||
past_power: number;
|
||||
cur_power: number;
|
||||
prop_power: number;
|
||||
actual_ratio: number;
|
||||
val_ratio: number;
|
||||
} | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const VerifiedMigration: React.FC<VerifiedMigrationProps> = ({
|
||||
gainAfterData,
|
||||
isLoading,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!gainAfterData) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<Typography color="text.secondary">No verification data available</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const isValidated = gainAfterData.val_ratio >= 0.95;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 1.5,
|
||||
mb: 2,
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ mb: 1.5 }}>Migration Verification Results</Typography>
|
||||
|
||||
{/* Power Optimization Results */}
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
bgcolor: theme.palette.primary.light,
|
||||
color: 'white',
|
||||
borderRadius: 2,
|
||||
boxShadow: 1
|
||||
}}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5, opacity: 0.9, fontSize: '0.875rem' }}>
|
||||
Current Power
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||
{gainAfterData.cur_power.toFixed(2)} W
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 1.5,
|
||||
bgcolor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 1,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5, fontSize: '0.875rem' }}>
|
||||
Accuracy of migration proposal
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{
|
||||
fontWeight: 'bold',
|
||||
mb: 0.5,
|
||||
color: isValidated ? '#4caf50' : '#ff9800'
|
||||
}}>
|
||||
{(gainAfterData.val_ratio * 100).toFixed(2)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 1.5,
|
||||
bgcolor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 1,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5, fontSize: '0.875rem' }}>
|
||||
Actual Power Change
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{
|
||||
fontWeight: 'bold',
|
||||
mb: 0.5,
|
||||
color: gainAfterData.actual_ratio > 0 ? '#4caf50' : '#ff9800'
|
||||
}}>
|
||||
{(gainAfterData.actual_ratio * 100).toFixed(2)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Summary Footer */}
|
||||
<Box sx={{
|
||||
mt: 1.5,
|
||||
p: 1.5,
|
||||
bgcolor: theme.palette.grey[50],
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
border: 1,
|
||||
borderColor: theme.palette.grey[200]
|
||||
}}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Previous Power Consumption
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ color: theme.palette.text.primary, fontWeight: 'bold' }}>
|
||||
{gainAfterData.past_power.toFixed(2)} W
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
label={isValidated ? "Optimization Verified" : "Verification Needed"}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: isValidated ? theme.palette.success.light : theme.palette.warning.light,
|
||||
color: isValidated ? theme.palette.success.dark : theme.palette.warning.dark,
|
||||
fontWeight: 'bold',
|
||||
height: '24px'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifiedMigration;
|
||||
161
src/components/Migration/hooks.ts
Normal file
161
src/components/Migration/hooks.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { VMDetails, GainBeforeData, MigrationAdviceData } from './types';
|
||||
|
||||
const API_BASE_URL = 'http://141.196.83.136:8003';
|
||||
const REFRESH_INTERVAL = 30000; // 30 seconds
|
||||
|
||||
interface GainAfterData {
|
||||
past_power: number;
|
||||
cur_power: number;
|
||||
prop_power: number;
|
||||
prop_ratio: number;
|
||||
actual_ratio: number;
|
||||
val_ratio: number;
|
||||
val_difference: number;
|
||||
}
|
||||
|
||||
export const useMigrationData = () => {
|
||||
const [gainBeforeData, setGainBeforeData] = useState<GainBeforeData | null>(null);
|
||||
const [migrationAdviceData, setMigrationAdviceData] = useState<MigrationAdviceData | null>(null);
|
||||
const [isLoadingGainData, setIsLoadingGainData] = useState(false);
|
||||
|
||||
const fetchMigrationData = async () => {
|
||||
try {
|
||||
setIsLoadingGainData(true);
|
||||
|
||||
const [gainResponse, migrationResponse] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/prom/get_chart_data/gain_before`),
|
||||
fetch(`${API_BASE_URL}/prom/get_chart_data/migration`)
|
||||
]);
|
||||
|
||||
if (!gainResponse.ok || !migrationResponse.ok) {
|
||||
throw new Error('Failed to fetch migration data');
|
||||
}
|
||||
|
||||
const [gainData, migrationData] = await Promise.all([
|
||||
gainResponse.json(),
|
||||
migrationResponse.json()
|
||||
]);
|
||||
|
||||
setGainBeforeData(gainData);
|
||||
setMigrationAdviceData(migrationData);
|
||||
} catch (error) {
|
||||
console.error('Error fetching migration data:', error);
|
||||
} finally {
|
||||
setIsLoadingGainData(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMigrationData();
|
||||
const interval = setInterval(fetchMigrationData, REFRESH_INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return { gainBeforeData, migrationAdviceData, isLoadingGainData, fetchMigrationData };
|
||||
};
|
||||
|
||||
export const useMonitoringData = () => {
|
||||
const [monitoringData, setMonitoringData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stableHosts, setStableHosts] = useState<string[]>([]);
|
||||
const [computeCount, setComputeCount] = useState<number>(0);
|
||||
const [vmCount, setVmCount] = useState<number>(0);
|
||||
|
||||
const fetchMonitoringData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_BASE_URL}/prom/monitoring`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
const result = await response.json();
|
||||
|
||||
if (result?.data) {
|
||||
setMonitoringData(result.data);
|
||||
const filteredData = result.data.filter((pm: any) => pm.virtual_machines?.length > 0);
|
||||
setComputeCount(filteredData.length);
|
||||
setVmCount(filteredData.reduce((acc: number, pm: any) => acc + pm.virtual_machines.length, 0));
|
||||
|
||||
const newHosts = result.data.map((pm: any) => pm.host);
|
||||
setStableHosts(prevHosts => {
|
||||
const allHosts = Array.from(new Set([...prevHosts, ...newHosts]));
|
||||
return allHosts.filter(host => newHosts.includes(host));
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching monitoring data:', error);
|
||||
setMonitoringData([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMonitoringData();
|
||||
const interval = setInterval(fetchMonitoringData, REFRESH_INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return { monitoringData, loading, stableHosts, computeCount, vmCount };
|
||||
};
|
||||
|
||||
export const useVMDetails = () => {
|
||||
const [vmDetails, setVmDetails] = useState<Record<string, VMDetails>>({});
|
||||
const [expandedVMs, setExpandedVMs] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleVMDetails = (vmId: string) => {
|
||||
setExpandedVMs(prev => ({
|
||||
...prev,
|
||||
[vmId]: !prev[vmId]
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVMDetails = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/prom/vm_mac_details`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch VM details');
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data?.res) {
|
||||
setVmDetails(data.res);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching VM details:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVMDetails();
|
||||
const interval = setInterval(fetchVMDetails, REFRESH_INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return { vmDetails, expandedVMs, toggleVMDetails };
|
||||
};
|
||||
|
||||
export const useGainAfterData = () => {
|
||||
const [gainAfterData, setGainAfterData] = useState<GainAfterData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const fetchGainAfterData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(`${API_BASE_URL}/prom/get_chart_data/gain_after`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch gain-after data');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setGainAfterData(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching gain-after data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { gainAfterData, isLoading, fetchGainAfterData };
|
||||
};
|
||||
34
src/components/Migration/types.ts
Normal file
34
src/components/Migration/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface VMDetails {
|
||||
disk: number;
|
||||
ephemeral: number;
|
||||
extra_specs: Record<string, any>;
|
||||
host: string;
|
||||
ip: string;
|
||||
name: string;
|
||||
original_name: string;
|
||||
ram: number;
|
||||
swap: number;
|
||||
vcpus: number;
|
||||
}
|
||||
|
||||
export interface GainBeforeData {
|
||||
prop_gain: number;
|
||||
prop_power: number;
|
||||
cur_power: number;
|
||||
}
|
||||
|
||||
export interface MigrationAdviceData {
|
||||
[key: string]: {
|
||||
current_pm: string;
|
||||
proposed_pm: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChartData {
|
||||
labels: string[];
|
||||
datasets: {
|
||||
label: string;
|
||||
data: number[];
|
||||
backgroundColor: string;
|
||||
}[];
|
||||
}
|
||||
10
src/components/styled/Logo.tsx
Normal file
10
src/components/styled/Logo.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import styled from '@mui/material/styles/styled';
|
||||
|
||||
export const Logo = styled('img')({
|
||||
height: '40px',
|
||||
width: 'auto',
|
||||
objectFit: 'contain'
|
||||
});
|
||||
|
||||
// Then use it like:
|
||||
// <Logo src={bgreenLogo} alt="B'GREEN Logo" />
|
||||
9
src/main.tsx
Normal file
9
src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
1
src/pages/.gitignore
vendored
Normal file
1
src/pages/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
1305
src/pages/Home.tsx
Normal file
1305
src/pages/Home.tsx
Normal file
File diff suppressed because it is too large
Load Diff
247
src/pages/Maintenance.tsx
Normal file
247
src/pages/Maintenance.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Paper, Typography, Fade, useTheme, AppBar, Toolbar, Chip } from '@mui/material';
|
||||
import Plot from 'react-plotly.js';
|
||||
import { Layout, PlotData } from 'plotly.js';
|
||||
|
||||
interface DataItem {
|
||||
now_timestamp: string;
|
||||
future_timestamp: string;
|
||||
power: string;
|
||||
power_future_15min: string;
|
||||
positive_3p: string;
|
||||
negative_3p: string;
|
||||
positive_7p: string;
|
||||
negative_7p: string;
|
||||
flag: string;
|
||||
}
|
||||
|
||||
const Maintenance = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const [chartData, setChartData] = useState<Partial<PlotData>[]>([]);
|
||||
const [currentFlag, setCurrentFlag] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await fetch('http://141.196.83.136:8003/prom/get_chart_data/maintenance/20');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.data && result.data.length > 0) {
|
||||
const last20Data = result.data.slice(-20);
|
||||
setCurrentFlag(last20Data[last20Data.length - 1].flag);
|
||||
|
||||
const traces: Partial<PlotData>[] = [
|
||||
{
|
||||
x: last20Data.map((item: DataItem) => item.now_timestamp),
|
||||
y: last20Data.map((item: DataItem) => parseFloat(item.power)),
|
||||
type: 'scatter' as const,
|
||||
mode: 'lines+markers' as const,
|
||||
name: 'Current Power',
|
||||
line: { color: '#2196f3', width: 2 },
|
||||
marker: { size: 6, symbol: 'circle' }
|
||||
},
|
||||
{
|
||||
x: last20Data.map((item: DataItem) => item.future_timestamp),
|
||||
y: last20Data.map((item: DataItem) => parseFloat(item.power_future_15min)),
|
||||
type: 'scatter' as const,
|
||||
mode: 'lines+markers' as const,
|
||||
name: 'Predicted (15min)',
|
||||
line: { color: '#4caf50', width: 2, dash: 'dot' },
|
||||
marker: { size: 6, symbol: 'circle' }
|
||||
},
|
||||
{
|
||||
x: last20Data.map((item: DataItem) => item.future_timestamp),
|
||||
y: last20Data.map((item: DataItem) => parseFloat(item.positive_3p)),
|
||||
type: 'scatter' as const,
|
||||
mode: 'lines' as const,
|
||||
name: '+3% Positive',
|
||||
line: { color: '#2ca02c', width: 1.5 },
|
||||
showlegend: true,
|
||||
},
|
||||
{
|
||||
x: last20Data.map((item: DataItem) => item.future_timestamp),
|
||||
y: last20Data.map((item: DataItem) => parseFloat(item.negative_3p)),
|
||||
type: 'scatter' as const,
|
||||
mode: 'lines' as const,
|
||||
name: '-3% Negative',
|
||||
line: { color: '#d62728', width: 1.5 },
|
||||
showlegend: true,
|
||||
},
|
||||
{
|
||||
x: last20Data.map((item: DataItem) => item.future_timestamp),
|
||||
y: last20Data.map((item: DataItem) => parseFloat(item.positive_7p)),
|
||||
type: 'scatter' as const,
|
||||
mode: 'lines' as const,
|
||||
name: '+7% Positive',
|
||||
line: { color: '#9467bd', width: 1.5 },
|
||||
showlegend: true,
|
||||
},
|
||||
{
|
||||
x: last20Data.map((item: DataItem) => item.future_timestamp),
|
||||
y: last20Data.map((item: DataItem) => parseFloat(item.negative_7p)),
|
||||
type: 'scatter' as const,
|
||||
mode: 'lines' as const,
|
||||
name: '-7% Negative',
|
||||
line: { color: '#8c564b', width: 1.5 },
|
||||
showlegend: true,
|
||||
}
|
||||
];
|
||||
setChartData(traces);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const layout: Partial<Layout> = {
|
||||
xaxis: {
|
||||
title: {
|
||||
text: 'Time',
|
||||
font: { size: 14, color: '#666', family: undefined }
|
||||
},
|
||||
type: 'date',
|
||||
gridcolor: '#eee',
|
||||
tickfont: { size: 12, family: undefined, color: '#666' },
|
||||
showgrid: true,
|
||||
gridwidth: 1,
|
||||
rangeslider: { visible: true }
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'Power (W)',
|
||||
font: { size: 14, color: '#666', family: undefined }
|
||||
},
|
||||
gridcolor: '#eee',
|
||||
tickfont: { size: 12, family: undefined, color: '#666' },
|
||||
showgrid: true,
|
||||
gridwidth: 1,
|
||||
rangemode: 'tozero' as const,
|
||||
fixedrange: false,
|
||||
range: [0, Math.max(...chartData.flatMap(trace => trace.y as number[]).filter(Boolean)) * 1.1]
|
||||
},
|
||||
showlegend: true,
|
||||
legend: {
|
||||
orientation: 'h',
|
||||
y: -0.2,
|
||||
x: 0.5,
|
||||
xanchor: 'center',
|
||||
yanchor: 'top',
|
||||
font: {
|
||||
size: 12,
|
||||
family: theme.typography.fontFamily,
|
||||
color: theme.palette.text.secondary
|
||||
},
|
||||
bgcolor: 'rgba(255, 255, 255, 0)',
|
||||
bordercolor: 'rgba(255, 255, 255, 0)'
|
||||
},
|
||||
margin: { t: 60, b: 100, l: 60, r: 20 },
|
||||
plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
paper_bgcolor: 'rgba(0,0,0,0)',
|
||||
hovermode: 'closest',
|
||||
modebar: {
|
||||
bgcolor: 'rgba(255, 255, 255, 0)',
|
||||
color: theme.palette.text.secondary,
|
||||
activecolor: theme.palette.primary.main
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, bgcolor: theme.palette.background.default }}>
|
||||
<AppBar
|
||||
position="static"
|
||||
elevation={0}
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
mb: 3
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{ px: { xs: 2, sm: 4 } }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
component="h1"
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
fontWeight: 500,
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
letterSpacing: '-0.5px'
|
||||
}}
|
||||
>
|
||||
Preventive Maintenance
|
||||
</Typography>
|
||||
{currentFlag && (
|
||||
<Chip
|
||||
label={currentFlag}
|
||||
color={currentFlag === 'Correct Estimation for PM energy' ? 'success' : 'warning'}
|
||||
size="medium"
|
||||
sx={{
|
||||
height: 32,
|
||||
'& .MuiChip-label': {
|
||||
px: 2,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600
|
||||
},
|
||||
animation: 'pulse 2s infinite',
|
||||
'@keyframes pulse': {
|
||||
'0%': {
|
||||
boxShadow: '0 0 0 0 rgba(0, 0, 0, 0.2)',
|
||||
},
|
||||
'70%': {
|
||||
boxShadow: '0 0 0 6px rgba(0, 0, 0, 0)',
|
||||
},
|
||||
'100%': {
|
||||
boxShadow: '0 0 0 0 rgba(0, 0, 0, 0)',
|
||||
},
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{ p: { xs: 2, sm: 4 } }}>
|
||||
<Fade in timeout={800}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: { xs: 2, sm: 3 },
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ height: 'calc(100vh - 200px)', minHeight: '500px' }}>
|
||||
<Plot
|
||||
data={chartData}
|
||||
layout={layout}
|
||||
config={{
|
||||
responsive: true,
|
||||
displayModeBar: true,
|
||||
displaylogo: false,
|
||||
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
|
||||
toImageButtonOptions: {
|
||||
format: 'png',
|
||||
filename: 'power_consumption_chart',
|
||||
height: 1000,
|
||||
width: 1500,
|
||||
scale: 2
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Fade>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Maintenance;
|
||||
778
src/pages/Migration.tsx
Normal file
778
src/pages/Migration.tsx
Normal file
@@ -0,0 +1,778 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Grid,
|
||||
Typography,
|
||||
Button,
|
||||
useTheme,
|
||||
Card,
|
||||
CardContent,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
|
||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import TerminalIcon from '@mui/icons-material/Terminal';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
|
||||
import SummaryStats from '../components/Migration/SummaryStats';
|
||||
import ResourceDistributionChart from '../components/Migration/ResourceDistributionChart';
|
||||
import MigrationAdviceCard from '../components/Migration/MigrationAdviceCard';
|
||||
import VerifiedMigration from '../components/Migration/VerifiedMigration';
|
||||
import { useMigrationData, useGainAfterData } from '../components/Migration/hooks';
|
||||
|
||||
// Constants
|
||||
const API_BASE_URL = 'http://141.196.83.136:8003';
|
||||
const REFRESH_INTERVAL = 30000; // 30 seconds
|
||||
|
||||
interface VMPlacementData {
|
||||
data_center: string;
|
||||
id: number;
|
||||
physical_machines: Array<{
|
||||
status: 'blocked' | 'open';
|
||||
name: string;
|
||||
power_consumption: number;
|
||||
vms: {
|
||||
active: Array<{
|
||||
status: 'blocked' | 'open';
|
||||
name: string;
|
||||
power: number;
|
||||
confg: {
|
||||
cpu: number;
|
||||
ram: number;
|
||||
disk: number;
|
||||
};
|
||||
}>;
|
||||
inactive: Array<{
|
||||
status: 'blocked' | 'open';
|
||||
name: string;
|
||||
power: number;
|
||||
confg: {
|
||||
cpu: number;
|
||||
ram: number;
|
||||
disk: number;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface VMCardProps {
|
||||
vm: {
|
||||
name: string;
|
||||
power: number;
|
||||
status: 'blocked' | 'open';
|
||||
confg: {
|
||||
cpu: number;
|
||||
ram: number;
|
||||
disk: number;
|
||||
};
|
||||
};
|
||||
vmId: string;
|
||||
isActive: boolean;
|
||||
expandedVMs: Record<string, boolean>;
|
||||
toggleVMDetails: (vmId: string) => void;
|
||||
theme: any;
|
||||
}
|
||||
|
||||
const VMCard = ({ vm, vmId, isActive, expandedVMs, toggleVMDetails, theme }: VMCardProps) => (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
bgcolor: theme.palette.grey[50],
|
||||
borderRadius: 1,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
borderColor: theme.palette.grey[200],
|
||||
opacity: isActive ? 1 : 0.7,
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 1
|
||||
}}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: isActive ? theme.palette.success.main : theme.palette.error.main,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="subtitle2" sx={{
|
||||
fontWeight: 'medium',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1
|
||||
}}>
|
||||
{vm.name}
|
||||
{vm.status === 'blocked' && (
|
||||
<LockIcon sx={{
|
||||
fontSize: '0.875rem',
|
||||
color: theme.palette.warning.main,
|
||||
opacity: 0.8
|
||||
}} />
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{vm.power.toFixed(2)}W
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleVMDetails(vmId);
|
||||
}}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Collapse in={expandedVMs[vmId]}>
|
||||
<Box sx={{
|
||||
mt: 1,
|
||||
p: 1.5,
|
||||
bgcolor: 'white',
|
||||
borderRadius: 1,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
borderColor: theme.palette.grey[200],
|
||||
}}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Status
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}>
|
||||
{vm.status === 'blocked' ? (
|
||||
<>
|
||||
<LockIcon sx={{
|
||||
fontSize: '1rem',
|
||||
color: theme.palette.warning.main
|
||||
}} />
|
||||
Blocked
|
||||
</>
|
||||
) : (
|
||||
'Open'
|
||||
)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
CPU Cores
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{vm.confg.cpu}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
RAM
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{vm.confg.ram} GB
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Disk Size
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{vm.confg.disk} GB
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Power
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{vm.power.toFixed(2)}W
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const getMessageColor = (message: string, theme: any): string => {
|
||||
if (message.includes('Error') || message.includes('BadRequestException')) {
|
||||
return theme.palette.error.light;
|
||||
} else if (message.includes('DEBUG')) {
|
||||
return theme.palette.info.light;
|
||||
} else if (message.includes('Attempting')) {
|
||||
return theme.palette.warning.light;
|
||||
} else if (message.includes('completed') || message.includes('Migration completed')) {
|
||||
return theme.palette.success.light;
|
||||
}
|
||||
return 'white';
|
||||
};
|
||||
|
||||
const MigrationProgress = ({ open, progress, onClose }: {
|
||||
open: boolean;
|
||||
progress: string[];
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 2,
|
||||
maxHeight: '80vh'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
pb: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TerminalIcon sx={{ color: theme.palette.primary.main }} />
|
||||
<Typography>Migration Progress</Typography>
|
||||
</Box>
|
||||
{progress.length > 0 && (
|
||||
<LinearProgress
|
||||
sx={{
|
||||
width: 100,
|
||||
borderRadius: 1,
|
||||
backgroundColor: theme.palette.grey[200]
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{
|
||||
mt: 1,
|
||||
bgcolor: theme.palette.grey[900],
|
||||
p: 0
|
||||
}}>
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
height: '100%',
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: theme.palette.grey[800],
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: theme.palette.grey[600],
|
||||
borderRadius: '4px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: theme.palette.grey[500],
|
||||
},
|
||||
}}>
|
||||
{progress.length > 0 ? (
|
||||
progress.map((message, index) => (
|
||||
<Typography
|
||||
key={index}
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: getMessageColor(message, theme),
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
mb: 1,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.5,
|
||||
borderLeft: `3px solid ${getMessageColor(message, theme)}`,
|
||||
pl: 2,
|
||||
py: 0.5,
|
||||
bgcolor: 'rgba(255, 255, 255, 0.03)',
|
||||
borderRadius: '0 4px 4px 0',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Typography>
|
||||
))
|
||||
) : (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
color: 'white',
|
||||
opacity: 0.7
|
||||
}}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography>Starting migration process...</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
px: 3,
|
||||
py: 2,
|
||||
bgcolor: theme.palette.grey[900]
|
||||
}}>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="contained"
|
||||
sx={{
|
||||
minWidth: 100,
|
||||
textTransform: 'none'
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const Migration = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
// Essential states
|
||||
const [vmPlacementData, setVmPlacementData] = useState<VMPlacementData | null>(null);
|
||||
const [isLoadingVmPlacement, setIsLoadingVmPlacement] = useState(false);
|
||||
const [expandedVMs, setExpandedVMs] = useState<Record<string, boolean>>({});
|
||||
const [showVerifiedSection, setShowVerifiedSection] = useState(false);
|
||||
const [isCardExpanded, setIsCardExpanded] = useState(false);
|
||||
const [migrationMode] = useState<'auto' | 'semiauto'>('auto');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [migrationProgress, setMigrationProgress] = useState<string[]>([]);
|
||||
const [showProgress, setShowProgress] = useState(false);
|
||||
const [hasProgress, setHasProgress] = useState(false);
|
||||
|
||||
// Hooks for migration functionality
|
||||
const { gainBeforeData, migrationAdviceData, isLoadingGainData, fetchMigrationData } = useMigrationData();
|
||||
const { gainAfterData, isLoading: isLoadingGainAfter, fetchGainAfterData } = useGainAfterData();
|
||||
|
||||
// Essential functions
|
||||
const toggleVMDetails = (vmId: string) => {
|
||||
setExpandedVMs(prev => ({
|
||||
...prev,
|
||||
[vmId]: !prev[vmId]
|
||||
}));
|
||||
};
|
||||
|
||||
const fetchVmPlacementData = async () => {
|
||||
try {
|
||||
setIsLoadingVmPlacement(true);
|
||||
const response = await fetch(`${API_BASE_URL}/prom/get_chart_data/vm_placement`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch VM placement data: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
console.log('Raw API response:', data); // Debug log
|
||||
setVmPlacementData(data); // Use the data directly since it already has the correct structure
|
||||
} catch (error) {
|
||||
console.error('Error fetching VM placement data:', error);
|
||||
} finally {
|
||||
setIsLoadingVmPlacement(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveMigration = async () => {
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
setMigrationProgress([]);
|
||||
setHasProgress(true);
|
||||
|
||||
// First, send the POST request for migration approval
|
||||
const approvalResponse = await fetch('http://141.196.83.136:8003/prom/migration/decisions4?run_migration=true', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!approvalResponse.ok) {
|
||||
throw new Error('Failed to approve migration');
|
||||
}
|
||||
|
||||
const reader = approvalResponse.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const text = decoder.decode(value);
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
|
||||
setMigrationProgress(prev => [...prev, ...lines]);
|
||||
}
|
||||
}
|
||||
|
||||
// If approval is successful, show verified section and fetch gain after data
|
||||
setShowVerifiedSection(true);
|
||||
await fetchGainAfterData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during migration approval:', error);
|
||||
setMigrationProgress(prev => [...prev, `Error: ${error instanceof Error ? error.message : 'Unknown error occurred'}`]);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeclineMigration = async () => {
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
|
||||
const response = await fetch('http://141.196.83.136:8003/prom/migration/decisions4?run_migration=false', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to decline migration');
|
||||
}
|
||||
|
||||
// Hide verified section if it was shown
|
||||
setShowVerifiedSection(false);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error declining migration:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Data fetching effect
|
||||
useEffect(() => {
|
||||
console.log('Initial data fetch');
|
||||
fetchVmPlacementData();
|
||||
const intervalId = setInterval(() => {
|
||||
console.log('Interval data fetch');
|
||||
fetchVmPlacementData();
|
||||
}, REFRESH_INTERVAL);
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
// Add effect to monitor vmPlacementData changes
|
||||
useEffect(() => {
|
||||
if (vmPlacementData) {
|
||||
const blockedPMs = vmPlacementData.physical_machines.filter(pm => pm.status === 'blocked').length;
|
||||
const blockedVMs = vmPlacementData.physical_machines.reduce((acc, pm) => {
|
||||
const activeBlocked = pm.vms.active.filter(vm => vm.status === 'blocked').length;
|
||||
const inactiveBlocked = pm.vms.inactive.filter(vm => vm.status === 'blocked').length;
|
||||
return acc + activeBlocked + inactiveBlocked;
|
||||
}, 0);
|
||||
|
||||
console.log('VM Placement Data updated:', {
|
||||
timestamp: new Date().toISOString(),
|
||||
pmCount: vmPlacementData.physical_machines.length,
|
||||
blockedPMs,
|
||||
totalVMs: vmPlacementData.physical_machines.reduce((acc, pm) =>
|
||||
acc + pm.vms.active.length + pm.vms.inactive.length, 0
|
||||
),
|
||||
blockedVMs
|
||||
});
|
||||
}
|
||||
}, [vmPlacementData]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', maxWidth: '100vw', minHeight: '100vh' }}>
|
||||
<Box sx={{ p: { xs: 0.5, sm: 1 }, flexGrow: 1 }}>
|
||||
<Grid container spacing={{ xs: 0.5, sm: 1 }}>
|
||||
<SummaryStats />
|
||||
|
||||
<Grid item xs={12} container spacing={{ xs: 0.5, sm: 1 }}>
|
||||
<ResourceDistributionChart
|
||||
vmPlacementData={vmPlacementData}
|
||||
isLoading={isLoadingVmPlacement}
|
||||
onRefresh={fetchVmPlacementData}
|
||||
/>
|
||||
|
||||
<MigrationAdviceCard
|
||||
isCardExpanded={isCardExpanded}
|
||||
setIsCardExpanded={setIsCardExpanded}
|
||||
gainBeforeData={gainBeforeData}
|
||||
migrationAdviceData={migrationAdviceData}
|
||||
isLoadingGainData={isLoadingGainData}
|
||||
migrationMode={migrationMode}
|
||||
onRefresh={(e) => {
|
||||
e.stopPropagation();
|
||||
fetchMigrationData();
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Migration Action Buttons */}
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 2,
|
||||
mt: 2,
|
||||
mb: 1,
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
{hasProgress && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setShowProgress(true)}
|
||||
startIcon={<VisibilityIcon />}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
borderColor: theme.palette.grey[300],
|
||||
color: theme.palette.text.secondary,
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.grey[400],
|
||||
bgcolor: 'rgba(0, 0, 0, 0.02)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Show Progress
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={handleDeclineMigration}
|
||||
disabled={isProcessing}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 4,
|
||||
borderRadius: 1,
|
||||
textTransform: 'none',
|
||||
fontSize: '1rem',
|
||||
minWidth: 200
|
||||
}}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : (
|
||||
'Decline Migration'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={!isProcessing && <PowerSettingsNewIcon />}
|
||||
onClick={handleApproveMigration}
|
||||
disabled={isProcessing}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 4,
|
||||
bgcolor: theme.palette.success.main,
|
||||
'&:hover': { bgcolor: theme.palette.success.dark },
|
||||
borderRadius: 1,
|
||||
textTransform: 'none',
|
||||
fontSize: '1rem',
|
||||
minWidth: 200
|
||||
}}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : (
|
||||
'Approve Migration'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Verified Migration Section */}
|
||||
{showVerifiedSection && (
|
||||
<Grid item xs={12}>
|
||||
<VerifiedMigration
|
||||
gainAfterData={gainAfterData}
|
||||
isLoading={isLoadingGainAfter}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* PM & VM Monitoring Section */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 2, bgcolor: 'background.paper', boxShadow: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">PMs & VMs Monitoring</Typography>
|
||||
</Box>
|
||||
|
||||
{isLoadingVmPlacement ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : vmPlacementData?.physical_machines ? (
|
||||
<Grid container spacing={2}>
|
||||
{vmPlacementData.physical_machines.map((pm) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={pm.name}>
|
||||
<Card
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
boxShadow: 2,
|
||||
height: '100%',
|
||||
minHeight: 250,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
borderColor: pm.status === 'blocked' ? theme.palette.warning.light : 'transparent',
|
||||
'&::before': pm.status === 'blocked' ? {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '2px',
|
||||
bgcolor: theme.palette.warning.main
|
||||
} : undefined
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
pb: 1,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider'
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: theme.palette.primary.main,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
{pm.name}
|
||||
{pm.status === 'blocked' && (
|
||||
<LockIcon sx={{
|
||||
fontSize: '1rem',
|
||||
color: theme.palette.warning.main,
|
||||
opacity: 0.8
|
||||
}} />
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
>
|
||||
{pm.power_consumption.toFixed(2)}W
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1
|
||||
}}>
|
||||
{/* Active VMs */}
|
||||
{pm.vms.active.map((vm, index) => (
|
||||
<VMCard
|
||||
key={`${pm.name}-${vm.name}-active-${index}`}
|
||||
vm={vm}
|
||||
vmId={`${pm.name}-${vm.name}-active-${index}`}
|
||||
isActive={true}
|
||||
expandedVMs={expandedVMs}
|
||||
toggleVMDetails={toggleVMDetails}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Inactive VMs */}
|
||||
{pm.vms.inactive.map((vm, index) => (
|
||||
<VMCard
|
||||
key={`${pm.name}-${vm.name}-inactive-${index}`}
|
||||
vm={vm}
|
||||
vmId={`${pm.name}-${vm.name}-inactive-${index}`}
|
||||
isActive={false}
|
||||
expandedVMs={expandedVMs}
|
||||
toggleVMDetails={toggleVMDetails}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
|
||||
{pm.vms.active.length === 0 && pm.vms.inactive.length === 0 && (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
color: theme.palette.text.secondary,
|
||||
}}>
|
||||
<Typography variant="body2">No VMs running</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
color: theme.palette.text.secondary
|
||||
}}>
|
||||
<Typography>No monitoring data available</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<MigrationProgress
|
||||
open={showProgress}
|
||||
progress={migrationProgress}
|
||||
onClose={() => setShowProgress(false)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Migration;
|
||||
600
src/pages/MonitoringSystem.tsx
Normal file
600
src/pages/MonitoringSystem.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
IconButton,
|
||||
Switch,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Tooltip,
|
||||
Collapse,
|
||||
Grid,
|
||||
useTheme,
|
||||
styled,
|
||||
Chip,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControlLabel,
|
||||
Alert,
|
||||
Snackbar,
|
||||
Tabs,
|
||||
Tab,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import BusinessIcon from '@mui/icons-material/Business';
|
||||
import LocationOnIcon from '@mui/icons-material/LocationOn';
|
||||
import DnsIcon from '@mui/icons-material/Dns';
|
||||
import ComputerIcon from '@mui/icons-material/Computer';
|
||||
import MemoryIcon from '@mui/icons-material/Memory';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
import { stressService } from '../services/stressService';
|
||||
|
||||
// Define the structure of our tree nodes
|
||||
interface TreeNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'organization' | 'region' | 'datacenter' | 'pm' | 'vm' | 'compute';
|
||||
children?: TreeNode[];
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
interface ComputeNode {
|
||||
host_ip: string;
|
||||
hosted_vms: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
optimization_space: Record<string, ComputeNode>;
|
||||
}
|
||||
|
||||
// Helper function to get all descendant node IDs
|
||||
const getDescendantIds = (node: TreeNode): string[] => {
|
||||
let ids: string[] = [node.id];
|
||||
if (node.children) {
|
||||
node.children.forEach(child => {
|
||||
ids = [...ids, ...getDescendantIds(child)];
|
||||
});
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
// Helper function to get all ancestor node IDs
|
||||
const getAncestorIds = (nodeId: string, node: TreeNode): string[] => {
|
||||
if (!node) return [];
|
||||
if (node.id === nodeId) return [node.id];
|
||||
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
const path = getAncestorIds(nodeId, child);
|
||||
if (path.length > 0) {
|
||||
return [node.id, ...path];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Helper function to check if all children are selected
|
||||
const areAllChildrenSelected = (node: TreeNode, selectedNodes: string[]): boolean => {
|
||||
if (!node.children) return true;
|
||||
return node.children.every(child => {
|
||||
if (child.children) {
|
||||
return areAllChildrenSelected(child, selectedNodes);
|
||||
}
|
||||
return selectedNodes.includes(child.id);
|
||||
});
|
||||
};
|
||||
|
||||
// Add new styled components for stress testing
|
||||
const StressTestingCard = styled(Paper)(({ theme }) => ({
|
||||
padding: theme.spacing(3),
|
||||
borderRadius: theme.spacing(2),
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
marginBottom: theme.spacing(3),
|
||||
}));
|
||||
|
||||
const StressLevelChip = styled(Chip)<{ level: 'low' | 'medium' | 'high' }>(({ theme, level }) => ({
|
||||
borderRadius: theme.spacing(1),
|
||||
fontWeight: 500,
|
||||
backgroundColor:
|
||||
level === 'low' ? theme.palette.success.light :
|
||||
level === 'medium' ? theme.palette.warning.light :
|
||||
theme.palette.error.light,
|
||||
color:
|
||||
level === 'low' ? theme.palette.success.dark :
|
||||
level === 'medium' ? theme.palette.warning.dark :
|
||||
theme.palette.error.dark,
|
||||
}));
|
||||
|
||||
interface MonitoringSystemProps {
|
||||
onSave?: (unselectedVMs: string[], selectedVMs: string[]) => void;
|
||||
isDialog?: boolean;
|
||||
initialBlockList?: string[];
|
||||
initialSelectedVMs?: string[];
|
||||
}
|
||||
|
||||
const MonitoringSystem: React.FC<MonitoringSystemProps> = ({
|
||||
onSave,
|
||||
isDialog = false,
|
||||
initialBlockList = [],
|
||||
initialSelectedVMs = [],
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState<string[]>(['org-main', 'region-main', 'dc-old-lab']);
|
||||
const [selectedNodes, setSelectedNodes] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [treeData, setTreeData] = useState<TreeNode[]>([]);
|
||||
const [isViewMode, setIsViewMode] = useState(false);
|
||||
const [selectedVMs, setSelectedVMs] = useState<string[]>(initialSelectedVMs);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [alert, setAlert] = useState<{ open: boolean; message: string; severity: 'success' | 'error' | 'info' }>({
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
// Fetch data and initialize state
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('http://141.196.83.136:8003/prom/monitoring');
|
||||
const result: ApiResponse = await response.json();
|
||||
|
||||
// Create hierarchical structure
|
||||
const hierarchicalData: TreeNode[] = [
|
||||
{
|
||||
id: 'org-main',
|
||||
name: 'Main Organization',
|
||||
type: 'organization',
|
||||
children: [
|
||||
{
|
||||
id: 'region-main',
|
||||
name: 'Region',
|
||||
type: 'region',
|
||||
children: [
|
||||
{
|
||||
id: 'dc-ulak',
|
||||
name: 'Ulak',
|
||||
type: 'datacenter',
|
||||
children: [] // Empty for now
|
||||
},
|
||||
{
|
||||
id: 'dc-old-lab',
|
||||
name: 'Old Lab',
|
||||
type: 'datacenter',
|
||||
children: Object.entries(result.optimization_space).map(([computeName, computeData]) => ({
|
||||
id: computeName,
|
||||
name: computeName,
|
||||
type: 'compute',
|
||||
ip: computeData.host_ip,
|
||||
children: Object.entries(computeData.hosted_vms).map(([vmName, vmIp]) => ({
|
||||
id: `${computeName}-${vmName}`,
|
||||
name: vmName,
|
||||
type: 'vm',
|
||||
ip: vmIp
|
||||
}))
|
||||
}))
|
||||
},
|
||||
{
|
||||
id: 'dc-new-lab',
|
||||
name: 'New Lab',
|
||||
type: 'datacenter',
|
||||
children: [] // Empty for now
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
setTreeData(hierarchicalData);
|
||||
|
||||
// Initialize selection based on initial values only if they exist
|
||||
if (initialBlockList.length > 0 || initialSelectedVMs.length > 0) {
|
||||
const blockList = initialBlockList;
|
||||
|
||||
// Select nodes that are not in the block list
|
||||
const nodesToSelect = new Set<string>();
|
||||
|
||||
// Helper function to process compute nodes
|
||||
const processComputeNodes = (nodes: TreeNode[]) => {
|
||||
nodes.forEach(node => {
|
||||
if (node.type === 'compute') {
|
||||
let hasSelectedVM = false;
|
||||
|
||||
// Check compute node
|
||||
if (node.ip && !blockList.includes(node.ip)) {
|
||||
nodesToSelect.add(node.id);
|
||||
}
|
||||
|
||||
// Check VM nodes
|
||||
node.children?.forEach(vm => {
|
||||
if (vm.ip && !blockList.includes(vm.ip)) {
|
||||
nodesToSelect.add(vm.id);
|
||||
hasSelectedVM = true;
|
||||
}
|
||||
});
|
||||
|
||||
// If any VM is selected, ensure the compute is selected too
|
||||
if (hasSelectedVM) {
|
||||
nodesToSelect.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively process children
|
||||
if (node.children) {
|
||||
processComputeNodes(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Process all nodes
|
||||
processComputeNodes(hierarchicalData);
|
||||
|
||||
// Set the selected nodes
|
||||
setSelectedNodes(Array.from(nodesToSelect));
|
||||
}
|
||||
|
||||
// Expand organization and region nodes by default
|
||||
setExpanded(['org-main', 'region-main', 'dc-old-lab']);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize with previous state
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [initialBlockList, initialSelectedVMs]); // Re-fetch when initial values change
|
||||
|
||||
// Get appropriate icon for each node type
|
||||
const getNodeIcon = (type: TreeNode['type']) => {
|
||||
switch (type) {
|
||||
case 'organization':
|
||||
return <BusinessIcon color="primary" />;
|
||||
case 'region':
|
||||
return <LocationOnIcon color="primary" />;
|
||||
case 'datacenter':
|
||||
return <DnsIcon color="primary" />;
|
||||
case 'pm':
|
||||
return <ComputerIcon color="primary" />;
|
||||
case 'vm':
|
||||
return <MemoryIcon color="primary" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle node expansion
|
||||
const handleNodeToggle = (nodeId: string) => {
|
||||
setExpanded(prev => {
|
||||
const isExpanded = prev.includes(nodeId);
|
||||
if (isExpanded) {
|
||||
return prev.filter(id => id !== nodeId);
|
||||
} else {
|
||||
return [...prev, nodeId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Updated node selection handler for toggle-like selection with parent-child association
|
||||
const handleNodeSelect = (nodeId: string) => {
|
||||
setSelectedNodes(prev => {
|
||||
const isSelected = prev.includes(nodeId);
|
||||
let newSelected = [...prev];
|
||||
|
||||
// Find the node in the tree
|
||||
const findNode = (nodes: TreeNode[]): TreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === nodeId) return node;
|
||||
if (node.children) {
|
||||
const found = findNode(node.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Find the parent compute node for a VM
|
||||
const findParentCompute = (nodes: TreeNode[], vmId: string): TreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'compute' && node.children?.some(vm => vm.id === vmId)) {
|
||||
return node;
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findParentCompute(node.children, vmId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const targetNode = findNode(treeData);
|
||||
if (!targetNode) return prev;
|
||||
|
||||
if (isSelected) {
|
||||
// When deselecting a node
|
||||
if (targetNode.type === 'compute') {
|
||||
// If deselecting a compute, deselect all its VMs
|
||||
const computeAndVMs = [targetNode.id, ...(targetNode.children?.map(vm => vm.id) || [])];
|
||||
newSelected = newSelected.filter(id => !computeAndVMs.includes(id));
|
||||
} else if (targetNode.type === 'vm') {
|
||||
// If deselecting a VM, just deselect it
|
||||
newSelected = newSelected.filter(id => id !== nodeId);
|
||||
|
||||
// If this was the last VM, deselect the parent compute too
|
||||
const parentCompute = findParentCompute(treeData, nodeId);
|
||||
if (parentCompute) {
|
||||
const siblingVMs = parentCompute.children?.filter(vm => vm.id !== nodeId) || [];
|
||||
const hasSelectedSiblings = siblingVMs.some(vm => newSelected.includes(vm.id));
|
||||
if (!hasSelectedSiblings) {
|
||||
newSelected = newSelected.filter(id => id !== parentCompute.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// When selecting a node
|
||||
if (targetNode.type === 'compute') {
|
||||
// If selecting a compute, select all its VMs
|
||||
newSelected.push(targetNode.id);
|
||||
targetNode.children?.forEach(vm => {
|
||||
newSelected.push(vm.id);
|
||||
});
|
||||
} else if (targetNode.type === 'vm') {
|
||||
// If selecting a VM, select it and its parent compute
|
||||
newSelected.push(nodeId);
|
||||
const parentCompute = findParentCompute(treeData, nodeId);
|
||||
if (parentCompute) {
|
||||
newSelected.push(parentCompute.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and return
|
||||
return Array.from(new Set(newSelected));
|
||||
});
|
||||
};
|
||||
|
||||
// Updated render function with disabled switches in view mode
|
||||
const renderTreeNode = (node: TreeNode, level: number = 0) => {
|
||||
const isExpanded = expanded.includes(node.id);
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isSelected = selectedNodes.includes(node.id);
|
||||
|
||||
return (
|
||||
<Box key={node.id} sx={{ ml: level * 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
p: 0.5,
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{hasChildren && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleNodeToggle(node.id)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
{isExpanded ? <ExpandMoreIcon /> : <ChevronRightIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{getNodeIcon(node.type)}
|
||||
<Typography variant="body2">{node.name}</Typography>
|
||||
<Switch
|
||||
checked={isSelected}
|
||||
onChange={() => handleNodeSelect(node.id)}
|
||||
disabled={isViewMode}
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 1,
|
||||
'& .MuiSwitch-switchBase.Mui-checked': {
|
||||
color: '#4caf50',
|
||||
},
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
|
||||
backgroundColor: '#4caf50',
|
||||
},
|
||||
'& .MuiSwitch-track': {
|
||||
backgroundColor: '#bdbdbd',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
{hasChildren && (
|
||||
<Collapse in={isExpanded}>
|
||||
<Box>
|
||||
{node.children!.map(child => renderTreeNode(child, level + 1))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Get unselected and selected VMs including compute IPs
|
||||
const getVMSelectionStatus = () => {
|
||||
const allIPs: string[] = [];
|
||||
const selectedIPs: string[] = [];
|
||||
|
||||
// Find the Old Lab datacenter node that contains the dynamic data
|
||||
const oldLabNode = treeData[0]?.children?.[0]?.children?.find(node => node.id === 'dc-old-lab');
|
||||
if (!oldLabNode) return { selectedVMs: [], unselectedVMs: [] };
|
||||
|
||||
// Process only the compute nodes in Old Lab
|
||||
oldLabNode.children?.forEach(compute => {
|
||||
// Add compute IP
|
||||
if (compute.ip) {
|
||||
allIPs.push(compute.ip);
|
||||
if (selectedNodes.includes(compute.id)) {
|
||||
selectedIPs.push(compute.ip);
|
||||
}
|
||||
}
|
||||
|
||||
// Add VM IPs
|
||||
compute.children?.forEach(vm => {
|
||||
if (vm.ip) {
|
||||
allIPs.push(vm.ip);
|
||||
if (selectedNodes.includes(vm.id)) {
|
||||
selectedIPs.push(vm.ip);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate unselected IPs for block list
|
||||
const unselectedIPs = allIPs.filter(ip => !selectedIPs.includes(ip));
|
||||
|
||||
console.log('Block list IPs:', unselectedIPs);
|
||||
|
||||
return {
|
||||
selectedVMs: selectedIPs,
|
||||
unselectedVMs: unselectedIPs
|
||||
};
|
||||
};
|
||||
|
||||
// Handle save action
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { selectedVMs, unselectedVMs } = getVMSelectionStatus();
|
||||
console.log('Selected VMs and Computes:', selectedVMs);
|
||||
console.log('Unselected VMs and Computes:', unselectedVMs);
|
||||
|
||||
// Store selected VMs in localStorage for stress testing
|
||||
const oldLabNode = treeData[0]?.children?.[0]?.children?.find(node => node.id === 'dc-old-lab');
|
||||
if (oldLabNode) {
|
||||
const selectedVMObjects = oldLabNode.children?.flatMap(compute =>
|
||||
compute.children?.filter(vm => selectedNodes.includes(vm.id))
|
||||
.map(vm => ({ id: vm.id, name: vm.name, ip: vm.ip })) || []
|
||||
) || [];
|
||||
|
||||
console.log('Storing VMs in localStorage:', selectedVMObjects);
|
||||
localStorage.setItem('stressTestVMs', JSON.stringify(selectedVMObjects));
|
||||
}
|
||||
|
||||
if (onSave) {
|
||||
onSave(unselectedVMs, selectedVMs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving selection:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
height: isDialog ? 'auto' : '100vh',
|
||||
bgcolor: 'background.default',
|
||||
p: 3
|
||||
}}>
|
||||
{/* Header */}
|
||||
<AppBar
|
||||
position="static"
|
||||
elevation={0}
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="h5" color="textPrimary" sx={{ flex: 1 }}>
|
||||
Optimization Space Selection
|
||||
</Typography>
|
||||
{isDialog && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mr: 2 }}>
|
||||
<Button
|
||||
variant={isViewMode ? "contained" : "outlined"}
|
||||
onClick={() => setIsViewMode(true)}
|
||||
size="small"
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
variant={!isViewMode ? "contained" : "outlined"}
|
||||
onClick={() => setIsViewMode(false)}
|
||||
size="small"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<Tooltip title="Save selected nodes">
|
||||
<span>
|
||||
<Button
|
||||
startIcon={<SaveIcon />}
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
disabled={loading || isViewMode}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Refresh tree">
|
||||
<IconButton onClick={fetchData} disabled={loading}>
|
||||
{loading ? <CircularProgress size={24} /> : <RefreshIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
{/* Main Content */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
height: '100%',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
mt: 2
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{treeData.map(node => renderTreeNode(node))}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Snackbar
|
||||
open={alert.open}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setAlert({ ...alert, open: false })}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={() => setAlert({ ...alert, open: false })}
|
||||
severity={alert.severity}
|
||||
variant="filled"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{alert.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonitoringSystem;
|
||||
478
src/pages/StressTesting.tsx
Normal file
478
src/pages/StressTesting.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Alert,
|
||||
Snackbar,
|
||||
Breadcrumbs,
|
||||
Link,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
StepContent,
|
||||
} from '@mui/material';
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import { stressService } from '../services/stressService';
|
||||
|
||||
// Define the structure of our VM nodes
|
||||
interface VMNode {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
// Remove the props interface since we'll get VMs from localStorage
|
||||
// interface StressTestingProps {
|
||||
// selectedVMs: VMNode[];
|
||||
// }
|
||||
|
||||
// Update component to not require props
|
||||
const StressTesting: React.FC = () => {
|
||||
// Initialize with empty array, will be populated from localStorage
|
||||
const [selectedVMs, setSelectedVMs] = useState<VMNode[]>([]);
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [stressLevel, setStressLevel] = useState<'low' | 'medium' | 'high'>('low');
|
||||
const [stressTestVMs, setStressTestVMs] = useState<string[]>([]);
|
||||
const [isStressTesting, setIsStressTesting] = useState(false);
|
||||
const [isLoadingStress, setIsLoadingStress] = useState(false);
|
||||
const [stressedVMs, setStressedVMs] = useState<string[]>([]);
|
||||
const [alert, setAlert] = useState<{ open: boolean; message: string; severity: 'success' | 'error' | 'info' }>({
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
// Load selected VMs from localStorage
|
||||
useEffect(() => {
|
||||
const loadVMsFromStorage = () => {
|
||||
const storedVMs = localStorage.getItem('stressTestVMs');
|
||||
console.log('Loading VMs from storage:', storedVMs);
|
||||
|
||||
if (storedVMs) {
|
||||
try {
|
||||
const parsedVMs = JSON.parse(storedVMs);
|
||||
console.log('Parsed VMs:', parsedVMs);
|
||||
setSelectedVMs(parsedVMs);
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored VMs:', error);
|
||||
setAlert({
|
||||
open: true,
|
||||
message: 'Error loading VMs from storage. Please select VMs in the Monitoring page first.',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('No VMs found in storage');
|
||||
setAlert({
|
||||
open: true,
|
||||
message: 'No VMs found. Please select VMs in the Monitoring page first.',
|
||||
severity: 'info',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Load VMs initially
|
||||
loadVMsFromStorage();
|
||||
|
||||
// Set up event listener for storage changes
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === 'stressTestVMs') {
|
||||
loadVMsFromStorage();
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
}, []); // Empty dependency array since we only want this to run once on mount
|
||||
|
||||
// Add status polling for stress test
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
const pollStressStatus = async () => {
|
||||
try {
|
||||
// Get the currently selected VMs from the tree view
|
||||
const selectedVMList = selectedVMs.filter(vm => stressTestVMs.includes(vm.id));
|
||||
const vmIPs = selectedVMList.map(vm => vm.ip);
|
||||
|
||||
console.log('Polling stress status for VMs:', vmIPs);
|
||||
|
||||
if (vmIPs.length > 0) {
|
||||
const status = await stressService.getStressStatus(vmIPs);
|
||||
console.log('Stress status response:', status);
|
||||
setStressedVMs(status);
|
||||
// Only update isStressTesting if we're not already in a stress testing state
|
||||
if (!isStressTesting) {
|
||||
setIsStressTesting(status.length > 0);
|
||||
}
|
||||
} else {
|
||||
setStressedVMs([]);
|
||||
setIsStressTesting(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling stress status:', error);
|
||||
setStressedVMs([]);
|
||||
// Don't automatically set isStressTesting to false on error
|
||||
// This allows the stop button to remain enabled even if there's a temporary polling error
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling immediately and then every 5 seconds
|
||||
pollStressStatus();
|
||||
interval = setInterval(pollStressStatus, 5000);
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [selectedVMs, stressTestVMs, isStressTesting]); // Added isStressTesting to dependencies
|
||||
|
||||
// Handle VM selection for stress testing
|
||||
const handleStressTestVMSelection = (vmId: string) => {
|
||||
setStressTestVMs(prev => {
|
||||
if (prev.includes(vmId)) {
|
||||
return prev.filter(id => id !== vmId);
|
||||
} else {
|
||||
return [...prev, vmId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Handle next step
|
||||
const handleNext = () => {
|
||||
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
||||
};
|
||||
|
||||
// Handle back step
|
||||
const handleBack = () => {
|
||||
setActiveStep((prevActiveStep) => prevActiveStep - 1);
|
||||
};
|
||||
|
||||
// Handle reset
|
||||
const handleReset = () => {
|
||||
setActiveStep(0);
|
||||
setStressTestVMs([]);
|
||||
};
|
||||
|
||||
// Start stress test
|
||||
const handleStartStressTest = async () => {
|
||||
try {
|
||||
setIsLoadingStress(true);
|
||||
|
||||
// Get the VMs selected for stress testing
|
||||
const selectedVMList = selectedVMs.filter(vm => stressTestVMs.includes(vm.id));
|
||||
const vmIPs = selectedVMList.map(vm => vm.ip);
|
||||
|
||||
// Log the selected VMs for debugging
|
||||
console.log('Selected VMs for stress test:', vmIPs);
|
||||
|
||||
if (vmIPs.length === 0) {
|
||||
setAlert({
|
||||
open: true,
|
||||
message: 'Please select at least one VM to stress test',
|
||||
severity: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await stressService.startStressTest({
|
||||
vms: vmIPs,
|
||||
level: stressLevel,
|
||||
force: true,
|
||||
});
|
||||
setIsStressTesting(true);
|
||||
setAlert({
|
||||
open: true,
|
||||
message: 'Stress test started successfully',
|
||||
severity: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in handleStartStressTest:', error);
|
||||
setAlert({
|
||||
open: true,
|
||||
message: error instanceof Error ? error.message : 'Failed to start stress test',
|
||||
severity: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingStress(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stop stress test
|
||||
const handleStopStressTest = async () => {
|
||||
try {
|
||||
setIsLoadingStress(true);
|
||||
|
||||
// Get the VMs selected for stress testing
|
||||
const selectedVMList = selectedVMs.filter(vm => stressTestVMs.includes(vm.id));
|
||||
const vmIPs = selectedVMList.map(vm => vm.ip);
|
||||
|
||||
console.log('Stopping stress test for VMs:', vmIPs);
|
||||
|
||||
await stressService.stopStressTest(vmIPs);
|
||||
setIsStressTesting(false);
|
||||
setStressedVMs([]);
|
||||
setAlert({
|
||||
open: true,
|
||||
message: 'Stress test stopped successfully',
|
||||
severity: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in handleStopStressTest:', error);
|
||||
setAlert({
|
||||
open: true,
|
||||
message: error instanceof Error ? error.message : 'Failed to stop stress test',
|
||||
severity: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingStress(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Steps for the stepper
|
||||
const steps = [
|
||||
{
|
||||
label: 'Select VMs',
|
||||
description: 'Select the VMs you want to include in the stress test.',
|
||||
content: (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
maxHeight: '300px',
|
||||
overflow: 'auto',
|
||||
bgcolor: 'background.default'
|
||||
}}
|
||||
>
|
||||
{selectedVMs.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" align="center">
|
||||
No VMs found. Please select VMs in the Monitoring page first.
|
||||
</Typography>
|
||||
) : (
|
||||
<Grid container spacing={1}>
|
||||
{selectedVMs.map((vm) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={vm.id}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={stressTestVMs.includes(vm.id)}
|
||||
onChange={() => handleStressTestVMSelection(vm.id)}
|
||||
disabled={isStressTesting || isLoadingStress}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="body2">{vm.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{vm.ip}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Configure Stress Level',
|
||||
description: 'Select the stress level for the test.',
|
||||
content: (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Stress Level</InputLabel>
|
||||
<Select
|
||||
value={stressLevel}
|
||||
label="Stress Level"
|
||||
onChange={(e) => setStressLevel(e.target.value as 'low' | 'medium' | 'high')}
|
||||
disabled={isStressTesting || isLoadingStress}
|
||||
>
|
||||
<MenuItem value="low">Low</MenuItem>
|
||||
<MenuItem value="medium">Medium</MenuItem>
|
||||
<MenuItem value="high">High</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Low:</strong> Minimal stress, suitable for testing basic functionality.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Medium:</strong> Moderate stress, tests system under typical load conditions.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>High:</strong> Maximum stress, tests system under extreme conditions.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Run Stress Test',
|
||||
description: 'Start or stop the stress test.',
|
||||
content: (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleStartStressTest}
|
||||
disabled={isStressTesting || isLoadingStress || stressTestVMs.length === 0}
|
||||
fullWidth
|
||||
>
|
||||
{isLoadingStress ? <CircularProgress size={24} /> : 'Start Stress Test'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={handleStopStressTest}
|
||||
disabled={(!isStressTesting && stressedVMs.length === 0) || isLoadingStress}
|
||||
fullWidth
|
||||
>
|
||||
{isLoadingStress ? <CircularProgress size={24} /> : 'Stop Stress Test'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{stressedVMs.length > 0 && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Currently Stressed VMs:</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{stressedVMs.map((vm) => (
|
||||
<Paper
|
||||
key={vm}
|
||||
sx={{
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
bgcolor:
|
||||
stressLevel === 'low' ? 'success.light' :
|
||||
stressLevel === 'medium' ? 'warning.light' :
|
||||
'error.light',
|
||||
color:
|
||||
stressLevel === 'low' ? 'success.dark' :
|
||||
stressLevel === 'medium' ? 'warning.dark' :
|
||||
'error.dark',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">{vm}</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumbs sx={{ mb: 3 }}>
|
||||
<Link href="/" color="inherit" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<HomeIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
||||
Home
|
||||
</Link>
|
||||
<Link href="/monitoring" color="inherit">
|
||||
Monitoring
|
||||
</Link>
|
||||
<Typography color="text.primary" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<SpeedIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
||||
Stress Testing
|
||||
</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 4 }}>
|
||||
<SpeedIcon color="primary" sx={{ mr: 1, fontSize: '2rem' }} />
|
||||
<Typography variant="h5">Stress Testing</Typography>
|
||||
<Button
|
||||
sx={{ ml: 'auto' }}
|
||||
onClick={() => {
|
||||
const storedVMs = localStorage.getItem('stressTestVMs');
|
||||
console.log('Current localStorage contents:', storedVMs);
|
||||
setAlert({
|
||||
open: true,
|
||||
message: storedVMs ? `Found VMs in storage: ${storedVMs}` : 'No VMs in storage',
|
||||
severity: 'info'
|
||||
});
|
||||
}}
|
||||
>
|
||||
Debug Storage
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Stepper */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Stepper activeStep={activeStep} orientation="vertical">
|
||||
{steps.map((step, index) => (
|
||||
<Step key={step.label}>
|
||||
<StepLabel>{step.label}</StepLabel>
|
||||
<StepContent>
|
||||
<Typography>{step.description}</Typography>
|
||||
{step.content}
|
||||
<Box sx={{ mb: 2, mt: 2 }}>
|
||||
<div>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={index === steps.length - 1 ? handleReset : handleNext}
|
||||
sx={{ mt: 1, mr: 1 }}
|
||||
disabled={index === 0 && stressTestVMs.length === 0}
|
||||
>
|
||||
{index === steps.length - 1 ? 'Reset' : 'Continue'}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={index === 0}
|
||||
onClick={handleBack}
|
||||
sx={{ mt: 1, mr: 1 }}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</StepContent>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Paper>
|
||||
|
||||
{/* Alert Snackbar */}
|
||||
<Snackbar
|
||||
open={alert.open}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setAlert({ ...alert, open: false })}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={() => setAlert({ ...alert, open: false })}
|
||||
severity={alert.severity}
|
||||
variant="filled"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{alert.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StressTesting;
|
||||
667
src/pages/Temperature.tsx
Normal file
667
src/pages/Temperature.tsx
Normal file
@@ -0,0 +1,667 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Box, Paper, Typography, Fade, useTheme, Grid, AppBar, Toolbar, CircularProgress, IconButton, Tooltip, Chip, Button, Snackbar, Alert } from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import Plot from 'react-plotly.js';
|
||||
import { Layout, PlotData, Config } from 'plotly.js';
|
||||
|
||||
// Extend the Window interface to include Google Charts
|
||||
declare global {
|
||||
interface Window {
|
||||
google?: {
|
||||
charts?: any;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Define the structure of our data
|
||||
interface ChartData {
|
||||
power: string;
|
||||
flag: string;
|
||||
env_temp_cur: string;
|
||||
now_timestamp: string;
|
||||
future_timestamp: string;
|
||||
env_temp_min: string;
|
||||
power_future_min: string;
|
||||
}
|
||||
|
||||
const Temperature = () => {
|
||||
const theme = useTheme();
|
||||
const [data, setData] = useState<ChartData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [decisionLoading, setDecisionLoading] = useState(false);
|
||||
const [powerZoom, setPowerZoom] = useState<{xRange?: [number, number]; yRange?: [number, number]}>({});
|
||||
const [tempZoom, setTempZoom] = useState<{xRange?: [number, number]; yRange?: [number, number]}>({});
|
||||
const [alert, setAlert] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'success'
|
||||
});
|
||||
|
||||
// Use refs to keep track of the interval
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const updateIntervalMs = 5000; // 5 seconds refresh rate
|
||||
|
||||
// Create a memoized fetchData function with useCallback
|
||||
const fetchData = useCallback(async (showLoadingIndicator = false) => {
|
||||
try {
|
||||
if (showLoadingIndicator) {
|
||||
setRefreshing(true);
|
||||
}
|
||||
|
||||
const response = await fetch('http://141.196.83.136:8003/prom/get_chart_data/temperature/20');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.data && result.data.length > 0) {
|
||||
// Sort by timestamp first to ensure we get the latest data
|
||||
const sortedData = [...result.data].sort((a, b) =>
|
||||
new Date(b.now_timestamp).getTime() - new Date(a.now_timestamp).getTime()
|
||||
);
|
||||
|
||||
// Log the most recent flag
|
||||
console.log('Most recent flag:', sortedData[0].flag);
|
||||
|
||||
// Filter valid data points
|
||||
const validData = sortedData.filter((item: any) =>
|
||||
item.now_timestamp &&
|
||||
item.future_timestamp &&
|
||||
item.power &&
|
||||
item.power_future_min &&
|
||||
item.env_temp_cur &&
|
||||
item.env_temp_min
|
||||
);
|
||||
|
||||
// Limit to last 20 records but maintain chronological order
|
||||
const last20Data = validData.slice(-20).sort((a, b) =>
|
||||
new Date(a.now_timestamp).getTime() - new Date(b.now_timestamp).getTime()
|
||||
);
|
||||
|
||||
console.log(`Data updated at ${new Date().toLocaleTimeString()}:`, {
|
||||
totalRecords: result.data.length,
|
||||
validRecords: validData.length,
|
||||
displayedRecords: last20Data.length,
|
||||
latestFlag: last20Data[last20Data.length - 1]?.flag
|
||||
});
|
||||
|
||||
setData(last20Data);
|
||||
setLastUpdated(new Date());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Manual refresh handler
|
||||
const handleRefresh = () => {
|
||||
fetchData(true);
|
||||
};
|
||||
|
||||
// Set up the interval for real-time updates
|
||||
useEffect(() => {
|
||||
// Initial fetch
|
||||
fetchData(true);
|
||||
|
||||
// Set up interval for auto-refresh
|
||||
intervalRef.current = setInterval(() => {
|
||||
console.log(`Auto-refreshing data at ${new Date().toLocaleTimeString()}`);
|
||||
fetchData(false);
|
||||
}, updateIntervalMs);
|
||||
|
||||
// Cleanup function to clear the interval when component unmounts
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
// Process data for Plotly charts
|
||||
const preparePlotlyData = () => {
|
||||
if (!data || data.length === 0) return { powerData: [], tempData: [] };
|
||||
|
||||
const currentTimestamps = data.map(item => new Date(item.now_timestamp));
|
||||
const futureTimestamps = data.map(item => new Date(item.future_timestamp));
|
||||
const currentPower = data.map(item => parseFloat(item.power));
|
||||
const predictedPower = data.map(item => parseFloat(item.power_future_min));
|
||||
const currentTemp = data.map(item => parseFloat(item.env_temp_cur));
|
||||
const predictedTemp = data.map(item => parseFloat(item.env_temp_min));
|
||||
|
||||
// Calculate min and max values for range sliders
|
||||
const powerMin = Math.min(...currentPower, ...predictedPower);
|
||||
const powerMax = Math.max(...currentPower, ...predictedPower);
|
||||
const tempMin = Math.min(...currentTemp, ...predictedTemp);
|
||||
const tempMax = Math.max(...currentTemp, ...predictedTemp);
|
||||
|
||||
const powerData: Partial<PlotData>[] = [
|
||||
{
|
||||
x: currentTimestamps,
|
||||
y: currentPower,
|
||||
type: 'scatter',
|
||||
mode: 'lines+markers',
|
||||
name: 'Current Power',
|
||||
line: { color: theme.palette.primary.main, width: 2 },
|
||||
marker: { size: 6 }
|
||||
},
|
||||
{
|
||||
x: futureTimestamps,
|
||||
y: predictedPower,
|
||||
type: 'scatter',
|
||||
mode: 'lines+markers',
|
||||
name: 'Predicted Power',
|
||||
line: { color: theme.palette.success.main, width: 2, dash: 'dash' },
|
||||
marker: { size: 6 }
|
||||
},
|
||||
// Range slider trace
|
||||
{
|
||||
x: [...currentTimestamps, ...futureTimestamps],
|
||||
y: Array(currentTimestamps.length + futureTimestamps.length).fill(0.5),
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
name: 'Y Range',
|
||||
yaxis: 'y2',
|
||||
line: { color: 'transparent' },
|
||||
showlegend: false,
|
||||
hoverinfo: 'skip' as const
|
||||
}
|
||||
];
|
||||
|
||||
const tempData: Partial<PlotData>[] = [
|
||||
{
|
||||
x: currentTimestamps,
|
||||
y: currentTemp,
|
||||
type: 'scatter',
|
||||
mode: 'lines+markers',
|
||||
name: 'Current Temperature',
|
||||
line: { color: theme.palette.primary.main, width: 2 },
|
||||
marker: { size: 6 }
|
||||
},
|
||||
{
|
||||
x: futureTimestamps,
|
||||
y: predictedTemp,
|
||||
type: 'scatter',
|
||||
mode: 'lines+markers',
|
||||
name: 'Predicted Temperature',
|
||||
line: { color: theme.palette.success.main, width: 2, dash: 'dash' },
|
||||
marker: { size: 6 }
|
||||
},
|
||||
// Range slider trace
|
||||
{
|
||||
x: [...currentTimestamps, ...futureTimestamps],
|
||||
y: Array(currentTimestamps.length + futureTimestamps.length).fill(0.5),
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
name: 'Y Range',
|
||||
yaxis: 'y2',
|
||||
line: { color: 'transparent' },
|
||||
showlegend: false,
|
||||
hoverinfo: 'skip' as const
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
powerData,
|
||||
tempData,
|
||||
ranges: {
|
||||
power: { min: powerMin, max: powerMax },
|
||||
temp: { min: tempMin, max: tempMax }
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const { powerData, tempData, ranges } = preparePlotlyData();
|
||||
|
||||
// Common layout settings for both charts
|
||||
const commonLayoutSettings: Partial<Layout> = {
|
||||
showlegend: true,
|
||||
legend: {
|
||||
orientation: 'h',
|
||||
y: -0.2,
|
||||
x: 0.5,
|
||||
xanchor: 'center',
|
||||
yanchor: 'top',
|
||||
font: {
|
||||
size: 12,
|
||||
family: theme.typography.fontFamily,
|
||||
color: theme.palette.text.secondary
|
||||
},
|
||||
bgcolor: 'rgba(255, 255, 255, 0)',
|
||||
bordercolor: 'rgba(255, 255, 255, 0)'
|
||||
},
|
||||
margin: { t: 60, b: 100, l: 60, r: 60 }, // Increased right margin for Y-axis range slider
|
||||
plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
paper_bgcolor: 'rgba(0,0,0,0)',
|
||||
hovermode: 'closest',
|
||||
xaxis: {
|
||||
type: 'date',
|
||||
gridcolor: theme.palette.divider,
|
||||
tickfont: { size: 12, color: theme.palette.text.secondary },
|
||||
showgrid: true,
|
||||
rangeslider: { visible: true }
|
||||
},
|
||||
yaxis2: {
|
||||
overlaying: 'y',
|
||||
side: 'right',
|
||||
showgrid: false,
|
||||
zeroline: false,
|
||||
showticklabels: false,
|
||||
range: [0, 1],
|
||||
rangeslider: {
|
||||
visible: true,
|
||||
thickness: 0.1,
|
||||
bgcolor: 'rgba(0,0,0,0)',
|
||||
bordercolor: theme.palette.divider
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add handlers for zoom events
|
||||
const handlePowerZoom = (event: any) => {
|
||||
if (event['xaxis.range[0]']) {
|
||||
setPowerZoom({
|
||||
xRange: [new Date(event['xaxis.range[0]']).getTime(), new Date(event['xaxis.range[1]']).getTime()],
|
||||
yRange: [event['yaxis.range[0]'], event['yaxis.range[1]']]
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTempZoom = (event: any) => {
|
||||
if (event['xaxis.range[0]']) {
|
||||
setTempZoom({
|
||||
xRange: [new Date(event['xaxis.range[0]']).getTime(), new Date(event['xaxis.range[1]']).getTime()],
|
||||
yRange: [event['yaxis.range[0]'], event['yaxis.range[1]']]
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Modify the power layout to use preserved zoom
|
||||
const powerLayout: Partial<Layout> = {
|
||||
...commonLayoutSettings,
|
||||
yaxis: {
|
||||
title: 'Power (W)',
|
||||
gridcolor: theme.palette.divider,
|
||||
tickfont: { size: 12, color: theme.palette.text.secondary },
|
||||
titlefont: { size: 14, color: theme.palette.text.primary },
|
||||
showgrid: true,
|
||||
rangemode: 'tozero',
|
||||
fixedrange: false,
|
||||
range: powerZoom.yRange || (ranges ? [ranges.power.min * 0.9, ranges.power.max * 1.1] : undefined)
|
||||
},
|
||||
xaxis: {
|
||||
...commonLayoutSettings.xaxis,
|
||||
range: powerZoom.xRange ? [new Date(powerZoom.xRange[0]), new Date(powerZoom.xRange[1])] : undefined
|
||||
}
|
||||
};
|
||||
|
||||
// Modify the temperature layout to use preserved zoom
|
||||
const tempLayout: Partial<Layout> = {
|
||||
...commonLayoutSettings,
|
||||
yaxis: {
|
||||
title: 'Temperature (°C)',
|
||||
gridcolor: theme.palette.divider,
|
||||
tickfont: { size: 12, color: theme.palette.text.secondary },
|
||||
titlefont: { size: 14, color: theme.palette.text.primary },
|
||||
showgrid: true,
|
||||
rangemode: 'tozero',
|
||||
fixedrange: false,
|
||||
range: tempZoom.yRange || (ranges ? [ranges.temp.min * 0.9, ranges.temp.max * 1.1] : undefined)
|
||||
},
|
||||
xaxis: {
|
||||
...commonLayoutSettings.xaxis,
|
||||
range: tempZoom.xRange ? [new Date(tempZoom.xRange[0]), new Date(tempZoom.xRange[1])] : undefined
|
||||
}
|
||||
};
|
||||
|
||||
// Common Plotly config with additional modebar buttons
|
||||
const plotConfig: Partial<Config> = {
|
||||
responsive: true,
|
||||
displayModeBar: true,
|
||||
displaylogo: false,
|
||||
modeBarButtonsToRemove: ['lasso2d', 'select2d'] as ('lasso2d' | 'select2d')[],
|
||||
toImageButtonOptions: {
|
||||
format: 'png' as const,
|
||||
filename: 'temperature_monitoring',
|
||||
height: 1000,
|
||||
width: 1500,
|
||||
scale: 2
|
||||
}
|
||||
};
|
||||
|
||||
// Handle temperature decision
|
||||
const handleTemperatureDecision = async (approval: boolean) => {
|
||||
try {
|
||||
setDecisionLoading(true);
|
||||
const response = await fetch('http://141.196.83.136:8003/prom/temperature/decisions?approval=' + approval, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to send temperature decision: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setAlert({
|
||||
open: true,
|
||||
message: result.message || `Temperature change ${approval ? 'approved' : 'declined'} successfully`,
|
||||
severity: 'success'
|
||||
});
|
||||
|
||||
// Refresh data after decision
|
||||
await fetchData(true);
|
||||
} catch (error) {
|
||||
console.error('Error sending temperature decision:', error);
|
||||
setAlert({
|
||||
open: true,
|
||||
message: error instanceof Error ? error.message : 'Failed to send temperature decision',
|
||||
severity: 'error'
|
||||
});
|
||||
} finally {
|
||||
setDecisionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseAlert = () => {
|
||||
setAlert(prev => ({ ...prev, open: false }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, bgcolor: theme.palette.background.default }}>
|
||||
<AppBar
|
||||
position="static"
|
||||
elevation={0}
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
mb: 3
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{ px: { xs: 2, sm: 4 } }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
component="h1"
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
fontWeight: 500,
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
letterSpacing: '-0.5px'
|
||||
}}
|
||||
>
|
||||
Environmental Temperature & Power Monitoring (Last 20 Records)
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{lastUpdated && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mr: 2, display: { xs: 'none', sm: 'block' } }}
|
||||
>
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</Typography>
|
||||
)}
|
||||
<Tooltip title="Refresh data">
|
||||
<IconButton
|
||||
onClick={handleRefresh}
|
||||
color="primary"
|
||||
disabled={loading || refreshing}
|
||||
sx={{
|
||||
animation: refreshing ? 'spin 1s linear infinite' : 'none',
|
||||
'@keyframes spin': {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(360deg)' }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{ p: { xs: 2, sm: 4 } }}>
|
||||
{/* Temperature Decision Panel */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6" sx={{ color: 'text.primary' }}>
|
||||
Temperature Change Decision
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<CheckCircleIcon />}
|
||||
onClick={() => handleTemperatureDecision(true)}
|
||||
disabled={decisionLoading}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
minWidth: 120,
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
startIcon={<CancelIcon />}
|
||||
onClick={() => handleTemperatureDecision(false)}
|
||||
disabled={decisionLoading}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
minWidth: 120,
|
||||
}}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Fade in timeout={800}>
|
||||
<Grid container spacing={3}>
|
||||
{/* Power Chart */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: { xs: 2, sm: 3 },
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
height: '100%',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: theme.palette.text.primary,
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
Power Consumption
|
||||
</Typography>
|
||||
{data.length > 0 && (
|
||||
<Chip
|
||||
label={data[data.length - 1]?.flag || 'N/A'}
|
||||
color={data[data.length - 1]?.flag === '25' ? 'success' : 'warning'}
|
||||
size="medium"
|
||||
sx={{
|
||||
height: 32,
|
||||
'& .MuiChip-label': {
|
||||
px: 2,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{refreshing && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
zIndex: 10,
|
||||
bgcolor: 'rgba(255,255,255,0.8)',
|
||||
borderRadius: '50%',
|
||||
p: 0.5
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={24} thickness={5} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '400px' }}>
|
||||
<CircularProgress size={40} thickness={4} />
|
||||
<Typography variant="h6" sx={{ ml: 2 }} color="text.secondary">
|
||||
Loading power data...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : data.length === 0 ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '400px' }}>
|
||||
<Typography variant="h6" color="text.secondary">No power data available</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ height: 400 }}>
|
||||
<Plot
|
||||
data={powerData}
|
||||
layout={powerLayout}
|
||||
config={plotConfig}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
onRelayout={handlePowerZoom}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Temperature Chart */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: { xs: 2, sm: 3 },
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
height: '100%',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: theme.palette.text.primary,
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
Environmental Temperature
|
||||
</Typography>
|
||||
{data.length > 0 && (
|
||||
<Chip
|
||||
label={data[data.length - 1]?.flag || 'N/A'}
|
||||
color={data[data.length - 1]?.flag === '25' ? 'success' : 'warning'}
|
||||
size="medium"
|
||||
sx={{
|
||||
height: 32,
|
||||
'& .MuiChip-label': {
|
||||
px: 2,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{refreshing && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
zIndex: 10,
|
||||
bgcolor: 'rgba(255,255,255,0.8)',
|
||||
borderRadius: '50%',
|
||||
p: 0.5
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={24} thickness={5} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '400px' }}>
|
||||
<CircularProgress size={40} thickness={4} />
|
||||
<Typography variant="h6" sx={{ ml: 2 }} color="text.secondary">
|
||||
Loading temperature data...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : data.length === 0 ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '400px' }}>
|
||||
<Typography variant="h6" color="text.secondary">No temperature data available</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ height: 400 }}>
|
||||
<Plot
|
||||
data={tempData}
|
||||
layout={tempLayout}
|
||||
config={plotConfig}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
onRelayout={handleTempZoom}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Fade>
|
||||
</Box>
|
||||
|
||||
{/* Snackbar for alerts */}
|
||||
<Snackbar
|
||||
open={alert.open}
|
||||
autoHideDuration={6000}
|
||||
onClose={handleCloseAlert}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleCloseAlert}
|
||||
severity={alert.severity}
|
||||
variant="filled"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{alert.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Temperature;
|
||||
25
src/plotly.d.ts
vendored
Normal file
25
src/plotly.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
declare module 'react-plotly.js' {
|
||||
import * as Plotly from 'plotly.js';
|
||||
import * as React from 'react';
|
||||
|
||||
interface PlotParams {
|
||||
data: Plotly.Data[];
|
||||
layout?: Partial<Plotly.Layout>;
|
||||
frames?: Plotly.Frame[];
|
||||
config?: Partial<Plotly.Config>;
|
||||
onClick?: (event: Plotly.PlotMouseEvent) => void;
|
||||
onHover?: (event: Plotly.PlotMouseEvent) => void;
|
||||
onUnHover?: (event: Plotly.PlotMouseEvent) => void;
|
||||
onSelected?: (event: Plotly.PlotSelectionEvent) => void;
|
||||
onDeselect?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
debug?: boolean;
|
||||
useResizeHandler?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
class Plot extends React.Component<PlotParams> {}
|
||||
export default Plot;
|
||||
}
|
||||
302
src/services/monitoringService.ts
Normal file
302
src/services/monitoringService.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const BASE_URL = 'http://141.196.83.136:8003';
|
||||
|
||||
export interface MonitoringConfig {
|
||||
migration: {
|
||||
script_time_unit: string;
|
||||
virtual_machine_estimation: {
|
||||
estimation_method: string;
|
||||
model_type: string;
|
||||
};
|
||||
migration_advices: {
|
||||
migration_method: string;
|
||||
migration_weights: {
|
||||
power: string;
|
||||
balance: string;
|
||||
overload: string;
|
||||
allocation: string;
|
||||
};
|
||||
};
|
||||
block_list: string[];
|
||||
};
|
||||
environmental: {
|
||||
number_of_steps: string;
|
||||
script_time_unit: string;
|
||||
model_type: string;
|
||||
};
|
||||
preventive: {
|
||||
number_of_steps: string;
|
||||
script_time_unit: string;
|
||||
model_type: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MonitoringStatus {
|
||||
statuses: {
|
||||
environmental: {
|
||||
is_running: boolean;
|
||||
pid: number;
|
||||
};
|
||||
migration: {
|
||||
is_running: boolean;
|
||||
pid: number;
|
||||
};
|
||||
preventive: {
|
||||
is_running: boolean;
|
||||
pid: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
class MonitoringService {
|
||||
private static instance: MonitoringService;
|
||||
private controller: AbortController | null = null;
|
||||
private statusPollingInterval: NodeJS.Timeout | null = null;
|
||||
private previousStatus: MonitoringStatus | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): MonitoringService {
|
||||
if (!MonitoringService.instance) {
|
||||
MonitoringService.instance = new MonitoringService();
|
||||
}
|
||||
return MonitoringService.instance;
|
||||
}
|
||||
|
||||
public async startMonitoring(config: MonitoringConfig): Promise<void> {
|
||||
try {
|
||||
// Validate required fields based on API documentation
|
||||
if (!config.migration || !config.environmental || !config.preventive) {
|
||||
throw new Error('Invalid configuration: Missing required sections');
|
||||
}
|
||||
|
||||
// Ensure script_time_unit has valid values
|
||||
if (!config.migration.script_time_unit ||
|
||||
!config.environmental.script_time_unit ||
|
||||
!config.preventive.script_time_unit) {
|
||||
throw new Error('Invalid configuration: Missing script_time_unit values');
|
||||
}
|
||||
|
||||
// Log the configuration being sent
|
||||
console.log('Sending monitoring configuration:', JSON.stringify(config, null, 2));
|
||||
|
||||
// Always create a new AbortController for this request
|
||||
if (this.controller) {
|
||||
this.controller.abort();
|
||||
}
|
||||
this.controller = new AbortController();
|
||||
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/prom/start_monitoring`,
|
||||
config,
|
||||
{
|
||||
signal: this.controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 15000, // 15 second timeout to prevent hanging
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to start monitoring: Status ${response.status}`);
|
||||
}
|
||||
|
||||
console.log('Monitoring started successfully:', response.data);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (axios.isCancel(error)) {
|
||||
console.log('Start monitoring request cancelled');
|
||||
} else {
|
||||
console.error('Error starting monitoring:', error);
|
||||
throw this.handleError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async stopMonitoring(): Promise<void> {
|
||||
try {
|
||||
console.log('Attempting to stop monitoring...');
|
||||
|
||||
// Cancel any ongoing monitoring request
|
||||
if (this.controller) {
|
||||
this.controller.abort();
|
||||
this.controller = null;
|
||||
}
|
||||
|
||||
// Stop status polling if it's running
|
||||
this.stopStatusPolling();
|
||||
|
||||
// According to API docs, this needs an empty body
|
||||
const response = await axios.post(`${BASE_URL}/prom/stop_monitoring`, {}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 10000, // 10 second timeout
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to stop monitoring: Status ${response.status}`);
|
||||
}
|
||||
|
||||
console.log('Monitoring stopped successfully:', response.data);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Error stopping monitoring:', error);
|
||||
throw this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
public async getMonitoringStatus(): Promise<MonitoringStatus> {
|
||||
try {
|
||||
const response = await axios.get(`${BASE_URL}/prom/monitoring_status`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 5000, // Shorter timeout for faster feedback
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error('Failed to get monitoring status');
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
|
||||
// Ensure the response always has the expected structure
|
||||
const normalizedData = {
|
||||
statuses: {
|
||||
environmental: {
|
||||
is_running: data?.statuses?.environmental?.is_running || false,
|
||||
pid: data?.statuses?.environmental?.pid || 0
|
||||
},
|
||||
migration: {
|
||||
is_running: data?.statuses?.migration?.is_running || false,
|
||||
pid: data?.statuses?.migration?.pid || 0
|
||||
},
|
||||
preventive: {
|
||||
is_running: data?.statuses?.preventive?.is_running || false,
|
||||
pid: data?.statuses?.preventive?.pid || 0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Only log when status actually changes
|
||||
if (!this.previousStatus ||
|
||||
this.hasStatusChanged(normalizedData, this.previousStatus)) {
|
||||
console.log('Monitoring status changed:', JSON.stringify(normalizedData, null, 2));
|
||||
this.previousStatus = JSON.parse(JSON.stringify(normalizedData));
|
||||
}
|
||||
|
||||
return normalizedData;
|
||||
} catch (error) {
|
||||
// Only log errors once per minute to reduce console noise
|
||||
const now = new Date();
|
||||
if (!this._lastErrorLog || now.getTime() - this._lastErrorLog.getTime() > 60000) {
|
||||
console.error('Error getting monitoring status:', error);
|
||||
this._lastErrorLog = now;
|
||||
}
|
||||
|
||||
// Return a default structure that indicates no services are running
|
||||
return {
|
||||
statuses: {
|
||||
environmental: {
|
||||
is_running: false,
|
||||
pid: 0
|
||||
},
|
||||
migration: {
|
||||
is_running: false,
|
||||
pid: 0
|
||||
},
|
||||
preventive: {
|
||||
is_running: false,
|
||||
pid: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private hasStatusChanged(current: MonitoringStatus, previous: MonitoringStatus): boolean {
|
||||
return current.statuses.migration.is_running !== previous.statuses.migration.is_running ||
|
||||
current.statuses.environmental.is_running !== previous.statuses.environmental.is_running ||
|
||||
current.statuses.preventive.is_running !== previous.statuses.preventive.is_running;
|
||||
}
|
||||
|
||||
private _lastErrorLog: Date | null = null;
|
||||
|
||||
public startStatusPolling(callback: (status: MonitoringStatus) => void, interval: number = 5000): void {
|
||||
this.stopStatusPolling();
|
||||
|
||||
this.statusPollingInterval = setInterval(async () => {
|
||||
try {
|
||||
const status = await this.getMonitoringStatus();
|
||||
callback(status);
|
||||
} catch (error) {
|
||||
console.error('Error polling monitoring status:', error);
|
||||
callback({
|
||||
statuses: {
|
||||
environmental: {
|
||||
is_running: false,
|
||||
pid: 0
|
||||
},
|
||||
migration: {
|
||||
is_running: false,
|
||||
pid: 0
|
||||
},
|
||||
preventive: {
|
||||
is_running: false,
|
||||
pid: 0
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
public stopStatusPolling(): void {
|
||||
if (this.statusPollingInterval) {
|
||||
clearInterval(this.statusPollingInterval);
|
||||
this.statusPollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(error: any): Error {
|
||||
if (error.response) {
|
||||
// Server responded with error status
|
||||
return new Error(
|
||||
`Server error: ${error.response.status} - ${error.response.data?.message || 'Unknown error'}`
|
||||
);
|
||||
} else if (error.request) {
|
||||
// Request made but no response received
|
||||
return new Error('No response received from server');
|
||||
} else {
|
||||
// Error in request setup
|
||||
return new Error(`Request error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async checkMonitoringActive(): Promise<boolean> {
|
||||
try {
|
||||
console.log('Checking if monitoring is active...');
|
||||
|
||||
const status = await this.getMonitoringStatus();
|
||||
|
||||
// Check if any of the monitoring services are running
|
||||
const migrationRunning = status?.statuses?.migration?.is_running;
|
||||
const environmentalRunning = status?.statuses?.environmental?.is_running;
|
||||
const preventiveRunning = status?.statuses?.preventive?.is_running;
|
||||
|
||||
const isActive = migrationRunning || environmentalRunning || preventiveRunning;
|
||||
console.log(`Monitoring active status: ${isActive} (Migration: ${migrationRunning}, Environmental: ${environmentalRunning}, Preventive: ${preventiveRunning})`);
|
||||
|
||||
return isActive;
|
||||
} catch (error) {
|
||||
console.error('Error checking monitoring status:', error);
|
||||
// Assume not active if we can't check
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const monitoringService = MonitoringService.getInstance();
|
||||
196
src/services/stressService.ts
Normal file
196
src/services/stressService.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const BASE_URL = 'http://141.196.83.136:8003';
|
||||
|
||||
export interface StressConfig {
|
||||
vms: string[];
|
||||
level: 'low' | 'medium' | 'high';
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
export interface StressStatus {
|
||||
status: string[];
|
||||
}
|
||||
|
||||
class StressService {
|
||||
private static instance: StressService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): StressService {
|
||||
if (!StressService.instance) {
|
||||
StressService.instance = new StressService();
|
||||
}
|
||||
return StressService.instance;
|
||||
}
|
||||
|
||||
public async startStressTest(config: StressConfig): Promise<void> {
|
||||
try {
|
||||
// Log the request configuration
|
||||
console.log('Starting stress test with config:', {
|
||||
url: `${BASE_URL}/stress/start`,
|
||||
data: {
|
||||
vms: config.vms,
|
||||
level: config.level,
|
||||
force: true
|
||||
}
|
||||
});
|
||||
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/stress/start`,
|
||||
{
|
||||
vms: config.vms,
|
||||
level: config.level,
|
||||
force: true
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to start stress test: Status ${response.status}`);
|
||||
}
|
||||
|
||||
console.log('Stress test started successfully:', response.data);
|
||||
return;
|
||||
} catch (error) {
|
||||
// Enhanced error logging
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error('Axios error details:', {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
config: {
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
data: error.config?.data,
|
||||
headers: error.config?.headers
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('Non-axios error:', error);
|
||||
}
|
||||
throw this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
public async stopStressTest(vms: string[]): Promise<void> {
|
||||
try {
|
||||
console.log('Stopping stress test for VMs:', vms);
|
||||
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/stress/stop`,
|
||||
vms,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to stop stress test: Status ${response.status}`);
|
||||
}
|
||||
|
||||
console.log('Stress test stopped successfully:', response.data);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error('Axios error details:', {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
config: {
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
data: error.config?.data,
|
||||
headers: error.config?.headers
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('Non-axios error:', error);
|
||||
}
|
||||
throw this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
public async getStressStatus(vms: string[]): Promise<string[]> {
|
||||
try {
|
||||
console.log('Fetching stress status for VMs:', vms);
|
||||
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/stress/status`,
|
||||
vms,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 5000,
|
||||
}
|
||||
);
|
||||
|
||||
// Log the raw response for debugging
|
||||
console.log('Raw stress status response:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
headers: response.headers
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to get stress test status: ${response.status}`);
|
||||
}
|
||||
|
||||
// Handle the new response format where data is an object with VM IPs as keys
|
||||
if (response.data && typeof response.data === 'object') {
|
||||
// Extract VMs that are running
|
||||
const runningVMs = Object.entries(response.data)
|
||||
.filter(([_, status]: [string, any]) => status.is_running)
|
||||
.map(([ip]) => ip);
|
||||
|
||||
console.log('Running VMs:', runningVMs);
|
||||
return runningVMs;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error('Axios error details:', {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
config: {
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
data: error.config?.data,
|
||||
headers: error.config?.headers
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('Non-axios error:', error);
|
||||
}
|
||||
throw this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(error: any): Error {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const response = error.response;
|
||||
if (response) {
|
||||
return new Error(
|
||||
`Server error: ${response.status} - ${JSON.stringify(response.data)}`
|
||||
);
|
||||
} else if (error.request) {
|
||||
return new Error('No response received from server');
|
||||
}
|
||||
}
|
||||
return new Error(`Request error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const stressService = StressService.getInstance();
|
||||
95
src/theme.ts
Normal file
95
src/theme.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { createTheme } from '@mui/material';
|
||||
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#0B1A33', // Deep navy blue from BLC
|
||||
light: '#1e3a6b',
|
||||
dark: '#060d19',
|
||||
},
|
||||
secondary: {
|
||||
main: '#FF5722', // Orange accent from icons
|
||||
light: '#ff784e',
|
||||
dark: '#c41c00',
|
||||
},
|
||||
background: {
|
||||
default: '#f5f5f5',
|
||||
paper: '#ffffff',
|
||||
},
|
||||
text: {
|
||||
primary: '#0B1A33',
|
||||
secondary: '#546e7a',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Segoe UI", "Roboto", "Helvetica", sans-serif',
|
||||
h1: {
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: 600,
|
||||
color: '#0B1A33',
|
||||
},
|
||||
h5: {
|
||||
fontWeight: 500,
|
||||
color: '#0B1A33',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 4,
|
||||
textTransform: 'none',
|
||||
padding: '8px 24px',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(11, 26, 51, 0.15)',
|
||||
},
|
||||
},
|
||||
contained: {
|
||||
background: '#0B1A33',
|
||||
color: '#ffffff',
|
||||
'&:hover': {
|
||||
background: '#1e3a6b',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 12px rgba(11, 26, 51, 0.08)',
|
||||
border: '1px solid rgba(11, 26, 51, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 12px rgba(11, 26, 51, 0.08)',
|
||||
border: '1px solid rgba(11, 26, 51, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
'& fieldset': {
|
||||
borderColor: 'rgba(11, 26, 51, 0.2)',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'rgba(11, 26, 51, 0.3)',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: '#0B1A33',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
14
src/types/images.d.ts
vendored
Normal file
14
src/types/images.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
declare module '*.png' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user