UpGraded tempreture charts version 2.0, using Echarts library, optomized rendering and added proper data selection options

This commit is contained in:
2025-09-21 03:26:48 +03:00
parent e052afde3d
commit 531dfd8715
8 changed files with 1573 additions and 746 deletions

125
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,125 @@
# Copilot Instructions for AyposWeb
## Project Overview
AyposWeb is a React-based monitoring system for virtual machine infrastructure with real-time data visualization. It provides VM migration advice, environmental monitoring, stress testing, and preventive maintenance features through a Material-UI interface.
## Tech Stack & Architecture
- **Frontend**: React 18 + TypeScript + Vite
- **UI Framework**: Material-UI (MUI) v5 with custom B'GREEN branding
- **Charts**: Multiple libraries (Plotly.js, Recharts, Chart.js, MUI X-Charts)
- **Routing**: React Router v6 with declarative routes in `App.tsx`
- **State**: React hooks with custom polling patterns (no external state management)
- **Build**: Vite with standard TypeScript config
- **Deployment**: Docker + nginx or Vercel with API proxy
## Core Architecture Patterns
### Service Layer Pattern
Services use singleton pattern with axios HTTP client:
```typescript
// All services follow this pattern in src/services/
class ServiceName {
private static instance: ServiceName;
public static getInstance(): ServiceName { /* singleton */ }
}
```
### Smart Polling Hook Pattern
Custom hooks in `src/components/Migration/hooks.ts` implement adaptive polling:
- Starts with fast intervals (5s) when data changes
- Gradually increases to slower intervals (30s) when stable
- Always use `useSmartPolling` for real-time data fetching
### Environment Configuration
```typescript
// src/config/env.ts handles dev/prod API routing
const getApiUrl = (): string => {
if (import.meta.env.PROD) return '/api'; // Vercel proxy
return import.meta.env.VITE_API_URL || 'fallback-url';
};
```
## Component Organization
### Layout Structure
- `MainLayout` provides responsive sidebar with mobile menu toggle
- `Sidebar` uses custom styled MUI components with B'GREEN theming
- Pages in `src/pages/` are route components that compose feature components
### Material-UI Customization
```typescript
// src/theme.ts defines B'GREEN brand colors
primary: { main: '#028a4a' } // B'GREEN green
```
- Use `styled()` for component-specific styling
- Custom `StyledListItemButton` pattern for navigation
- Responsive breakpoints with `useMediaQuery(theme.breakpoints.down('md'))`
### Chart Component Patterns
Multiple charting libraries used for different purposes:
- **Plotly.js**: Complex scientific visualizations in migration analysis
- **Recharts**: Simple bar/line charts for resource distribution
- **MUI X-Charts**: Native MUI integration for basic charts
- **Chart.js**: Time-series data with date-fns adapter
## Data Flow Patterns
### API Integration
```typescript
// Services handle all external API calls
const response = await axios.post(`${BASE_URL}/endpoint`, data, {
headers: { 'Content-Type': 'application/json' }
});
```
### Type Safety
- Strict TypeScript with comprehensive interfaces in `src/types/`
- Migration-specific types in `src/components/Migration/types.ts`
- Monitoring types shared across services in `src/types/monitoring.ts`
### Error Handling
- Services log requests for debugging: `console.log('Starting operation with config:', config)`
- Use try/catch with proper error propagation to UI components
## Development Workflows
### Build Commands
```bash
npm run dev # Vite dev server on :5173
npm run build # TypeScript compilation + Vite build
npm run preview # Preview production build
```
### Environment Setup
- Development: Uses `VITE_API_URL` environment variable
- Production: API calls routed through `/api` proxy (see `vercel.json`)
- No `.env.example` - check README for environment variables
### Key Files to Understand
- `src/App.tsx`: Route definitions and global providers
- `src/components/Layout/MainLayout.tsx`: Responsive layout logic
- `src/services/monitoringService.ts`: Main API service patterns
- `src/components/Migration/hooks.ts`: Custom polling and data fetching
- `src/theme.ts`: B'GREEN brand implementation
## Project-Specific Conventions
### Import Organization
1. React/third-party imports
2. MUI component imports
3. Local component imports
4. Service/utility imports
5. Asset imports (images/icons)
### Component Naming
- Page components: PascalCase in `src/pages/`
- Feature components: PascalCase in feature folders under `src/components/`
- Service files: camelCase with "Service" suffix
### Styling Approach
- Use MUI's `styled()` over CSS-in-JS or external CSS
- Custom styled components follow `Styled{ComponentName}` naming
- Responsive design with MUI breakpoint system
- B'GREEN brand colors defined in theme, not hardcoded
When adding new features, follow the established service layer pattern, use TypeScript interfaces for all data structures, and implement smart polling for real-time data when appropriate.

70
package-lock.json generated
View File

@@ -15,11 +15,14 @@
"@mui/material": "^5.15.3", "@mui/material": "^5.15.3",
"@mui/x-charts": "^8.9.0", "@mui/x-charts": "^8.9.0",
"@mui/x-tree-view": "^7.26.0", "@mui/x-tree-view": "^7.26.0",
"@types/echarts": "^4.9.22",
"@types/plotly.js": "^2.35.2", "@types/plotly.js": "^2.35.2",
"axios": "^1.8.4", "axios": "^1.8.4",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"chartjs-adapter-date-fns": "^3.0.0", "chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"echarts": "^5.6.0",
"echarts-for-react": "^3.0.2",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"plotly.js": "^3.0.1", "plotly.js": "^3.0.1",
"plotly.js-dist": "^3.0.1", "plotly.js-dist": "^3.0.1",
@@ -2717,6 +2720,15 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/echarts": {
"version": "4.9.22",
"resolved": "https://registry.npmjs.org/@types/echarts/-/echarts-4.9.22.tgz",
"integrity": "sha512-7Fo6XdWpoi8jxkwP7BARUOM7riq8bMhmsCtSG8gzUcJmFhLo387tihoBYS/y5j7jl3PENT5RxeWZdN9RiwO7HQ==",
"license": "MIT",
"dependencies": {
"@types/zrender": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2842,6 +2854,12 @@
"@types/geojson": "*" "@types/geojson": "*"
} }
}, },
"node_modules/@types/zrender": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@types/zrender/-/zrender-4.0.6.tgz",
"integrity": "sha512-1jZ9bJn2BsfmYFPBHtl5o3uV+ILejAtGrDcYSpT4qaVKEI/0YY+arw3XHU04Ebd8Nca3SQ7uNcLaqiL+tTFVMg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
@@ -4068,6 +4086,36 @@
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/echarts": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
}
},
"node_modules/echarts-for-react": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.2.tgz",
"integrity": "sha512-DRwIiTzx8JfwPOVgGttDytBqdp5VzCSyMRIxubgU/g2n9y3VLUmF2FK7Icmg/sNVkv4+rktmrLN9w22U2yy3fA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"size-sensor": "^1.0.1"
},
"peerDependencies": {
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0",
"react": "^15.0.0 || >=16.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.190", "version": "1.5.190",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.190.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.190.tgz",
@@ -4584,7 +4632,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-equals": { "node_modules/fast-equals": {
@@ -7182,6 +7229,12 @@
"integrity": "sha512-yodFGwcyt59XRh7w5W3jPcIQb3Bwi21suEfT7MAWnBX3iCdklJpgDgvGT9o04UonglZN5SNMfJFkHIR/jO8GHw==", "integrity": "sha512-yodFGwcyt59XRh7w5W3jPcIQb3Bwi21suEfT7MAWnBX3iCdklJpgDgvGT9o04UonglZN5SNMfJFkHIR/jO8GHw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/size-sensor": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.2.tgz",
"integrity": "sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw==",
"license": "ISC"
},
"node_modules/slash": { "node_modules/slash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -7879,6 +7932,21 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
} }
} }
} }

View File

@@ -17,11 +17,14 @@
"@mui/material": "^5.15.3", "@mui/material": "^5.15.3",
"@mui/x-charts": "^8.9.0", "@mui/x-charts": "^8.9.0",
"@mui/x-tree-view": "^7.26.0", "@mui/x-tree-view": "^7.26.0",
"@types/echarts": "^4.9.22",
"@types/plotly.js": "^2.35.2", "@types/plotly.js": "^2.35.2",
"axios": "^1.8.4", "axios": "^1.8.4",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"chartjs-adapter-date-fns": "^3.0.0", "chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"echarts": "^5.6.0",
"echarts-for-react": "^3.0.2",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"plotly.js": "^3.0.1", "plotly.js": "^3.0.1",
"plotly.js-dist": "^3.0.1", "plotly.js-dist": "^3.0.1",

View File

@@ -0,0 +1,367 @@
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import { useTheme } from '@mui/material/styles';
import { Box, Typography } from '@mui/material';
interface DataPoint {
currentTimestamp: Date;
futureTimestamp: Date;
currentValue: number;
predictedValue: number;
}
interface MonitoringChartProps {
data: DataPoint[];
height?: number;
zoomRange?: [number, number];
onZoomChange?: (range: [number, number]) => void;
chartTitle: string;
unit: string;
colors?: {
current: string;
predicted: string;
};
}
const MonitoringChart: React.FC<MonitoringChartProps> = React.memo(({
data,
height = 400,
zoomRange,
onZoomChange,
chartTitle,
unit,
colors
}) => {
const theme = useTheme();
const commonSeriesSettings = {
sampling: 'lttb',
showSymbol: false,
smooth: 0.3,
animation: false,
emphasis: {
disabled: true
}
};
const option = useMemo(() => {
if (!data || data.length === 0) {
return {};
}
const currentData = data.map(item => [
item.currentTimestamp.getTime(),
Number(item.currentValue.toFixed(2))
]);
const predictedData = data.map(item => [
item.futureTimestamp.getTime(),
Number(item.predictedValue.toFixed(2))
]);
const allValues = [...data.map(d => d.currentValue), ...data.map(d => d.predictedValue)];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const valuePadding = (maxValue - minValue) * 0.1;
const currentColor = colors?.current || theme.palette.primary.main;
const predictedColor = colors?.predicted || theme.palette.warning.main;
return {
animation: false,
progressive: 500,
progressiveThreshold: 1000,
renderer: 'canvas',
title: {
text: chartTitle,
textStyle: {
fontSize: 18,
fontWeight: 'bold',
color: theme.palette.text.primary,
fontFamily: 'Montserrat, sans-serif'
},
left: 'center',
top: 10
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: currentColor,
borderWidth: 1,
textStyle: {
color: theme.palette.text.primary,
fontFamily: 'Montserrat, sans-serif'
},
formatter: function (params: any) {
const time = new Date(params[0].value[0]).toLocaleString();
let html = `<div style="margin-bottom: 8px; font-weight: bold;">${time}</div>`;
params.forEach((param: any) => {
html += `
<div style="display: flex; align-items: center; margin-bottom: 4px;">
<span style="display: inline-block; margin-right: 8px; border-radius: 50%; width: 10px; height: 10px; background-color: ${param.color};"></span>
<span style="font-weight: 500;">${param.seriesName}:</span>
<span style="margin-left: 8px; color: ${param.color}; font-weight: bold;">${param.value[1]}${unit}</span>
</div>
`;
});
return html;
}
},
legend: {
data: ['Current Value', 'Predicted Value'],
top: 40,
textStyle: {
fontFamily: 'Montserrat, sans-serif',
fontSize: 12,
color: theme.palette.text.secondary
}
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '25%',
containLabel: true
},
toolbox: {
feature: {
dataZoom: {
yAxisIndex: 'none',
title: {
zoom: 'Zoom',
back: 'Reset Zoom'
}
},
restore: {
title: 'Reset'
},
saveAsImage: {
title: 'Save'
}
},
right: 15,
top: 5
},
dataZoom: [
{
type: 'slider',
show: true,
xAxisIndex: [0],
start: zoomRange?.[0] ?? 0,
end: zoomRange?.[1] ?? 100,
height: 20,
bottom: 0,
borderColor: theme.palette.divider,
fillerColor: `${currentColor}1A`, // 10% opacity
textStyle: {
color: theme.palette.text.secondary,
fontFamily: 'Montserrat, sans-serif'
},
handleStyle: {
color: currentColor
}
},
{
type: 'inside',
xAxisIndex: [0],
start: 0,
end: 100,
zoomOnMouseWheel: 'shift'
}
],
xAxis: {
type: 'time',
boundaryGap: false,
axisLabel: {
formatter: function (value: number) {
const date = new Date(value);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
},
color: theme.palette.text.secondary,
fontFamily: 'Montserrat, sans-serif'
},
axisLine: {
lineStyle: {
color: theme.palette.divider
}
},
splitLine: {
show: true,
lineStyle: {
color: theme.palette.divider,
type: 'dashed',
opacity: 0.5
}
}
},
yAxis: {
type: 'value',
name: chartTitle,
nameTextStyle: {
color: theme.palette.text.secondary,
fontFamily: 'Montserrat, sans-serif',
fontWeight: 'bold'
},
min: Math.max(0, minValue - valuePadding),
max: maxValue + valuePadding,
axisLabel: {
formatter: `{value}${unit}`,
color: theme.palette.text.secondary,
fontFamily: 'Montserrat, sans-serif'
},
axisLine: {
lineStyle: {
color: theme.palette.divider
}
},
splitLine: {
lineStyle: {
color: theme.palette.divider,
type: 'dashed',
opacity: 0.3
}
}
},
series: [
{
...commonSeriesSettings,
name: 'Current Value',
type: 'line',
data: currentData,
lineStyle: {
color: currentColor,
width: 2,
join: 'round'
},
itemStyle: {
color: currentColor,
opacity: 0.8
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0,
color: `${currentColor}33` // 20% opacity
}, {
offset: 1,
color: `${currentColor}05` // 2% opacity
}]
}
}
},
{
...commonSeriesSettings,
name: 'Predicted Value',
type: 'line',
data: predictedData,
lineStyle: {
color: predictedColor,
width: 2,
type: 'dashed'
},
itemStyle: {
color: predictedColor,
opacity: 0.8
}
}
]
};
}, [data, theme, chartTitle, unit, colors, zoomRange]);
if (!data || data.length === 0) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height={height}
bgcolor={theme.palette.background.paper}
borderRadius={2}
boxShadow={2}
>
<Typography
variant="h6"
color="text.secondary"
fontFamily="Montserrat, sans-serif"
>
No data available
</Typography>
</Box>
);
}
return (
<Box
sx={{
bgcolor: theme.palette.background.paper,
borderRadius: 2,
boxShadow: 3,
overflow: 'hidden'
}}
>
<ReactECharts
option={option}
style={{ height: `${height}px`, width: '100%' }}
opts={{
renderer: 'canvas',
width: 'auto',
height: 'auto',
}}
notMerge={true}
lazyUpdate={true}
theme={theme.palette.mode}
loadingOption={{
color: theme.palette.primary.main,
maskColor: 'rgba(255, 255, 255, 0.8)',
text: 'Loading...'
}}
onEvents={{
datazoom: (params: any) => {
if (onZoomChange && params.batch?.[0]) {
onZoomChange([params.batch[0].start, params.batch[0].end]);
}
}
}}
/>
</Box>
);
}, (prevProps, nextProps) => {
// Always re-render if data length changes
if (prevProps.data.length !== nextProps.data.length) return false;
// Always re-render if essential props change
if (prevProps.height !== nextProps.height) return false;
if (prevProps.chartTitle !== nextProps.chartTitle) return false;
if (prevProps.unit !== nextProps.unit) return false;
if (prevProps.zoomRange?.[0] !== nextProps.zoomRange?.[0] ||
prevProps.zoomRange?.[1] !== nextProps.zoomRange?.[1]) return false;
// Deep compare the last few data points
const compareCount = 5; // Compare last 5 points for smooth transitions
for (let i = 1; i <= compareCount; i++) {
const prevItem = prevProps.data[prevProps.data.length - i];
const nextItem = nextProps.data[nextProps.data.length - i];
if (!prevItem || !nextItem) return false;
if (prevItem.currentTimestamp.getTime() !== nextItem.currentTimestamp.getTime() ||
prevItem.currentValue !== nextItem.currentValue ||
prevItem.predictedValue !== nextItem.predictedValue) {
return false;
}
}
return true;
});
MonitoringChart.displayName = 'MonitoringChart';
export default MonitoringChart;

View File

@@ -0,0 +1,347 @@
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import { Box, Typography } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import type { Theme } from '@mui/material/styles';
import type { EChartsOption } from 'echarts';
interface PowerDataPoint {
currentTimestamp: Date;
futureTimestamp: Date;
currentPower: number;
predictedPower: number;
}
interface PowerEChartProps {
data: PowerDataPoint[];
height?: number;
zoomRange?: [number, number];
onZoomChange?: (range: [number, number]) => void;
}
const PowerEChart = ({ data, height = 400, zoomRange, onZoomChange }: PowerEChartProps): JSX.Element => {
const theme = useTheme<Theme>();
const option = useMemo<EChartsOption>(() => {
if (!data || data.length === 0) {
return {};
}
const currentPowerData = data.map(item => [
item.currentTimestamp.getTime(),
Number(item.currentPower.toFixed(2))
]);
const predictedPowerData = data.map(item => [
item.futureTimestamp.getTime(),
Number(item.predictedPower.toFixed(2))
]);
const allPowers = [...data.map(d => d.currentPower), ...data.map(d => d.predictedPower)];
const minPower = Math.min(...allPowers);
const maxPower = Math.max(...allPowers);
const powerPadding = (maxPower - minPower) * 0.1;
const avgCurrentPower = data.reduce((sum, d) => sum + d.currentPower, 0) / data.length;
const avgPredictedPower = data.reduce((sum, d) => sum + d.predictedPower, 0) / data.length;
const efficiencyGain = ((avgCurrentPower - avgPredictedPower) / avgCurrentPower * 100).toFixed(1);
return {
backgroundColor: theme.palette.background.paper,
title: {
text: 'Power Consumption Analysis',
subtext: `Efficiency Optimization: ${efficiencyGain}% reduction predicted`,
textStyle: {
fontSize: 16,
color: theme.palette.text.primary,
fontFamily: theme.typography.fontFamily
},
subtextStyle: {
fontSize: 12,
color: theme.palette.success.main,
fontFamily: theme.typography.fontFamily
},
left: 'center',
top: 10
},
grid: {
left: '4%',
right: '4%',
bottom: '12%',
top: '15%',
containLabel: true
},
legend: {
data: ['Current Power', 'Predicted Power'],
bottom: '2%',
left: 'center',
textStyle: {
color: theme.palette.text.secondary,
fontSize: 12,
fontFamily: theme.typography.fontFamily
},
icon: 'circle',
itemWidth: 10,
itemHeight: 10,
itemGap: 35,
selectedMode: false,
z: 100
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: theme.palette.primary.main,
borderWidth: 1,
textStyle: {
color: theme.palette.text.primary,
fontFamily: theme.typography.fontFamily
},
formatter: (params: any) => {
const time = new Date(params[0].value[0]).toLocaleTimeString();
let html = `<div style="margin-bottom: 8px; font-weight: bold;">${time}</div>`;
params.forEach((param: any) => {
html += `
<div style="display: flex; align-items: center; margin-bottom: 4px;">
<span style="display: inline-block; margin-right: 8px; border-radius: 50%; width: 10px; height: 10px; background-color: ${param.color};"></span>
<span style="font-weight: 500;">${param.seriesName}:</span>
<span style="margin-left: 8px; color: ${param.color}; font-weight: bold;">${param.value[1]} W</span>
</div>
`;
});
return html;
}
},
dataZoom: [
{
type: 'slider',
show: true,
xAxisIndex: [0],
start: zoomRange?.[0] ?? 0,
end: zoomRange?.[1] ?? 100,
height: 20,
bottom: 40,
left: '10%',
right: '10%',
z: 90,
backgroundColor: 'transparent',
borderColor: theme.palette.divider,
fillerColor: 'rgba(0, 136, 255, 0.05)',
textStyle: {
color: theme.palette.text.secondary,
fontFamily: theme.typography.fontFamily,
fontSize: 11
},
handleStyle: {
color: theme.palette.primary.main,
borderColor: theme.palette.background.paper,
borderWidth: 1,
shadowBlur: 2,
shadowColor: 'rgba(0,0,0,0.2)',
opacity: 0.8
},
selectedDataBackground: {
lineStyle: {
color: '#0088ff',
width: 1
},
areaStyle: {
color: 'rgba(0, 136, 255, 0.2)'
}
},
emphasis: {
handleLabel: {
show: true
},
handleStyle: {
borderWidth: 3,
shadowBlur: 6,
shadowColor: 'rgba(0,0,0,0.2)'
}
}
},
{
type: 'inside',
xAxisIndex: [0],
start: 0,
end: 100,
zoomOnMouseWheel: 'shift',
moveOnMouseMove: true
}
],
xAxis: {
type: 'time',
boundaryGap: false,
axisTick: { show: false },
axisLabel: {
formatter: (value: number) => {
const date = new Date(value);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
}).replace(/^24/, '00');
},
color: theme.palette.text.secondary,
fontFamily: theme.typography.fontFamily
},
axisLine: {
lineStyle: {
color: theme.palette.divider
}
},
splitLine: {
show: true,
lineStyle: {
color: theme.palette.divider,
type: 'dashed',
opacity: 0.5
}
}
},
yAxis: {
type: 'value',
name: 'Power (W)',
nameLocation: 'middle',
nameGap: 50,
min: Math.max(0, minPower - powerPadding),
max: maxPower + powerPadding,
axisLabel: {
color: theme.palette.text.secondary,
fontFamily: theme.typography.fontFamily,
formatter: '{value} W'
},
splitLine: {
lineStyle: {
color: theme.palette.divider,
opacity: 0.3
}
}
},
series: [
{
name: 'Current Power',
type: 'line',
data: currentPowerData,
smooth: true,
symbol: 'circle',
symbolSize: 4,
showSymbol: false,
lineStyle: {
width: 2,
color: '#0088ff',
shadowColor: 'rgba(0,0,0,0.2)',
shadowBlur: 3,
cap: 'round'
},
itemStyle: {
color: '#0088ff',
borderWidth: 2,
borderColor: '#fff'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0,
color: 'rgba(0, 136, 255, 0.15)'
}, {
offset: 1,
color: 'rgba(0, 136, 255, 0.02)'
}]
}
}
},
{
name: 'Predicted Power',
type: 'line',
data: predictedPowerData,
smooth: true,
symbol: 'circle',
symbolSize: 4,
showSymbol: false,
lineStyle: {
width: 2,
type: 'dashed',
color: '#00cc88',
shadowColor: 'rgba(0,0,0,0.1)',
shadowBlur: 3,
cap: 'round'
},
itemStyle: {
color: '#00cc88',
borderWidth: 2,
borderColor: '#fff'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0,
color: 'rgba(0, 204, 136, 0.1)'
}, {
offset: 1,
color: 'rgba(0, 204, 136, 0.02)'
}]
}
}
}
]
};
}, [data, theme]);
if (!data || data.length === 0) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height={height}
bgcolor={theme.palette.background.paper}
borderRadius={2}
boxShadow={2}
>
<Typography
variant="h6"
color="text.secondary"
>
No power consumption data available
</Typography>
</Box>
);
}
return (
<Box
sx={{
bgcolor: theme.palette.background.paper,
borderRadius: 2,
boxShadow: 3,
overflow: 'hidden'
}}
>
<ReactECharts
option={option}
style={{ height: `${height}px`, width: '100%' }}
opts={{ renderer: 'canvas' }}
notMerge={false}
lazyUpdate={true}
onEvents={{
datazoom: (params: any) => {
if (onZoomChange && params.batch?.[0]) {
onZoomChange([params.batch[0].start, params.batch[0].end]);
}
}
}}
/>
</Box>
);
};
export default PowerEChart;

View File

@@ -0,0 +1,202 @@
import React from 'react';
import { Box, Grid, Card, CardContent, Typography, Chip, LinearProgress } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import TrendingDownIcon from '@mui/icons-material/TrendingDown';
import PowerIcon from '@mui/icons-material/Power';
import ThermostatIcon from '@mui/icons-material/Thermostat';
import SavingsIcon from '@mui/icons-material/Savings';
import InsightsIcon from '@mui/icons-material/Insights';
interface DashboardData {
currentTimestamp: Date;
futureTimestamp: Date;
currentPower: number;
predictedPower: number;
currentTemp: number;
predictedTemp: number;
}
interface TemperatureDashboardProps {
data: DashboardData[];
}
const TemperatureDashboard: React.FC<TemperatureDashboardProps> = ({ data }) => {
const theme = useTheme();
if (!data || data.length === 0) {
return null;
}
const latest = data[data.length - 1];
const avgCurrentPower = data.reduce((sum, d) => sum + d.currentPower, 0) / data.length;
const avgPredictedPower = data.reduce((sum, d) => sum + d.predictedPower, 0) / data.length;
const avgCurrentTemp = data.reduce((sum, d) => sum + d.currentTemp, 0) / data.length;
const avgPredictedTemp = data.reduce((sum, d) => sum + d.predictedTemp, 0) / data.length;
const powerEfficiency = ((avgCurrentPower - avgPredictedPower) / avgCurrentPower * 100);
const tempOptimization = avgCurrentTemp - avgPredictedTemp;
const costSavings = powerEfficiency * 0.12; // Assuming $0.12/kWh
const metrics = [
{
title: 'Power Optimization',
value: `${powerEfficiency.toFixed(1)}%`,
subtitle: 'Efficiency Gain',
icon: <PowerIcon />,
color: theme.palette.success.main,
trend: powerEfficiency > 0 ? 'up' : 'down',
progress: Math.min(Math.abs(powerEfficiency), 100)
},
{
title: 'Temperature Control',
value: `${Math.abs(tempOptimization).toFixed(2)}°C`,
subtitle: tempOptimization > 0 ? 'Reduction' : 'Increase',
icon: <ThermostatIcon />,
color: tempOptimization > 0 ? theme.palette.success.main : theme.palette.warning.main,
trend: tempOptimization > 0 ? 'down' : 'up',
progress: Math.min(Math.abs(tempOptimization) * 20, 100)
},
{
title: 'Cost Savings',
value: `$${Math.abs(costSavings).toFixed(2)}`,
subtitle: 'Per Hour',
icon: <SavingsIcon />,
color: theme.palette.success.main,
trend: costSavings > 0 ? 'up' : 'down',
progress: Math.min(Math.abs(costSavings) * 10, 100)
},
{
title: 'Current Status',
value: `${latest.currentPower.toFixed(0)}W`,
subtitle: `${latest.currentTemp.toFixed(1)}°C`,
icon: <InsightsIcon />,
color: theme.palette.primary.main,
trend: 'neutral',
progress: 85
}
];
return (
<Box sx={{ mb: 3 }}>
<Typography
variant="h5"
sx={{
mb: 2,
fontWeight: 600,
color: theme.palette.text.primary,
fontFamily: 'Montserrat, sans-serif'
}}
>
Real-time Performance Analytics
</Typography>
<Grid container spacing={2}>
{metrics.map((metric, index) => (
<Grid item xs={12} sm={6} md={3} key={index}>
<Card
sx={{
height: '100%',
background: `linear-gradient(135deg, ${theme.palette.background.paper} 0%, ${theme.palette.grey[50]} 100%)`,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 2,
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: theme.shadows[8],
borderColor: metric.color
}
}}
>
<CardContent sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1.5 }}>
<Box
sx={{
p: 1,
borderRadius: 1.5,
bgcolor: `${metric.color}15`,
color: metric.color,
mr: 1.5
}}
>
{metric.icon}
</Box>
<Box sx={{ flex: 1 }}>
<Typography
variant="h4"
sx={{
fontWeight: 700,
color: metric.color,
fontFamily: 'Montserrat, sans-serif',
lineHeight: 1
}}
>
{metric.value}
</Typography>
</Box>
{metric.trend !== 'neutral' && (
<Chip
icon={metric.trend === 'up' ? <TrendingUpIcon /> : <TrendingDownIcon />}
label={metric.trend === 'up' ? '+' : '-'}
size="small"
sx={{
bgcolor: metric.trend === 'up' ?
`${theme.palette.success.main}20` :
`${theme.palette.error.main}20`,
color: metric.trend === 'up' ?
theme.palette.success.main :
theme.palette.error.main,
'& .MuiChip-icon': {
color: 'inherit'
}
}}
/>
)}
</Box>
<Typography
variant="body2"
sx={{
color: theme.palette.text.secondary,
fontWeight: 500,
mb: 1.5,
fontFamily: 'Montserrat, sans-serif'
}}
>
{metric.title}
</Typography>
<Typography
variant="caption"
sx={{
color: theme.palette.text.disabled,
display: 'block',
mb: 1
}}
>
{metric.subtitle}
</Typography>
<LinearProgress
variant="determinate"
value={metric.progress}
sx={{
height: 6,
borderRadius: 3,
bgcolor: `${metric.color}20`,
'& .MuiLinearProgress-bar': {
bgcolor: metric.color,
borderRadius: 3
}
}}
/>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
);
};
export default TemperatureDashboard;

View File

@@ -0,0 +1,344 @@
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import { useTheme } from '@mui/material/styles';
import { Box, Typography } from '@mui/material';
interface TemperatureDataPoint {
currentTimestamp: Date;
futureTimestamp: Date;
currentTemp: number;
predictedTemp: number;
}
interface TemperatureEChartProps {
data: TemperatureDataPoint[];
height?: number;
zoomRange?: [number, number];
onZoomChange?: (range: [number, number]) => void;
}
const TemperatureEChart: React.FC<TemperatureEChartProps> = React.memo(({ data, height = 400, zoomRange, onZoomChange }) => {
const theme = useTheme();
const option = useMemo(() => {
if (!data || data.length === 0) {
return {};
}
// Prepare data for ECharts
const currentTempData = data.map(item => [
item.currentTimestamp.getTime(),
Number(item.currentTemp.toFixed(2))
]);
const predictedTempData = data.map(item => [
item.futureTimestamp.getTime(),
Number(item.predictedTemp.toFixed(2))
]);
// Calculate temperature range for better axis scaling
const allTemps = [...data.map(d => d.currentTemp), ...data.map(d => d.predictedTemp)];
const minTemp = Math.min(...allTemps);
const maxTemp = Math.max(...allTemps);
const tempPadding = (maxTemp - minTemp) * 0.1;
const commonSeriesSettings = {
sampling: 'lttb',
showSymbol: false,
smooth: 0.3,
animation: false,
emphasis: {
disabled: true
}
};
return {
animation: false,
progressive: 500,
progressiveThreshold: 1000,
renderer: 'canvas',
title: {
text: 'Environmental Temperature Monitoring',
textStyle: {
fontSize: 18,
fontWeight: 'bold',
color: theme.palette.text.primary,
fontFamily: 'Montserrat, sans-serif'
},
left: 'center',
top: 10
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: theme.palette.primary.main,
borderWidth: 1,
textStyle: {
color: theme.palette.text.primary,
fontFamily: 'Montserrat, sans-serif'
},
formatter: function (params: any) {
const time = new Date(params[0].value[0]).toLocaleString();
let html = `<div style="margin-bottom: 8px; font-weight: bold;">${time}</div>`;
params.forEach((param: any) => {
html += `
<div style="display: flex; align-items: center; margin-bottom: 4px;">
<span style="display: inline-block; margin-right: 8px; border-radius: 50%; width: 10px; height: 10px; background-color: ${param.color};"></span>
<span style="font-weight: 500;">${param.seriesName}:</span>
<span style="margin-left: 8px; color: ${param.color}; font-weight: bold;">${param.value[1]}°C</span>
</div>
`;
});
return html;
}
},
legend: {
data: ['Current Temperature', 'Predicted Temperature'],
top: 40,
textStyle: {
fontFamily: 'Montserrat, sans-serif',
fontSize: 12,
color: theme.palette.text.secondary
}
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '25%',
containLabel: true
},
toolbox: {
feature: {
dataZoom: {
yAxisIndex: 'none',
title: {
zoom: 'Zoom',
back: 'Reset Zoom'
}
},
restore: {
title: 'Reset'
},
saveAsImage: {
title: 'Save'
}
},
right: 15,
top: 5
},
dataZoom: [
{
type: 'slider',
show: true,
xAxisIndex: [0],
start: zoomRange?.[0] ?? 0,
end: zoomRange?.[1] ?? 100,
height: 20,
bottom: 0,
borderColor: theme.palette.divider,
fillerColor: 'rgba(2, 138, 74, 0.1)',
textStyle: {
color: theme.palette.text.secondary,
fontFamily: 'Montserrat, sans-serif'
},
handleStyle: {
color: theme.palette.primary.main
}
},
{
type: 'inside',
xAxisIndex: [0],
start: 0,
end: 100,
zoomOnMouseWheel: 'shift'
}
],
xAxis: {
type: 'time',
boundaryGap: false,
axisLabel: {
formatter: function (value: number) {
const date = new Date(value);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
},
color: theme.palette.text.secondary,
fontFamily: 'Montserrat, sans-serif'
},
axisLine: {
lineStyle: {
color: theme.palette.divider
}
},
splitLine: {
show: true,
lineStyle: {
color: theme.palette.divider,
type: 'dashed',
opacity: 0.5
}
}
},
yAxis: {
type: 'value',
name: 'Temperature (°C)',
nameTextStyle: {
color: theme.palette.text.secondary,
fontFamily: 'Montserrat, sans-serif',
fontWeight: 'bold'
},
min: Math.max(0, minTemp - tempPadding),
max: maxTemp + tempPadding,
axisLabel: {
formatter: '{value}°C',
color: theme.palette.text.secondary,
fontFamily: 'Montserrat, sans-serif'
},
axisLine: {
lineStyle: {
color: theme.palette.divider
}
},
splitLine: {
lineStyle: {
color: theme.palette.divider,
type: 'dashed',
opacity: 0.3
}
}
},
// Optimize throttling and rendering
throttle: 70,
silent: true, // Reduce event overhead when not needed
series: [
{
...commonSeriesSettings,
name: 'Current Temperature',
type: 'line',
data: currentTempData,
lineStyle: {
color: theme.palette.primary.main,
width: 2,
join: 'round'
},
itemStyle: {
color: theme.palette.primary.main,
opacity: 0.8
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0,
color: `rgba(2, 138, 74, 0.2)`
}, {
offset: 1,
color: `rgba(2, 138, 74, 0.02)`
}]
}
}
},
{
...commonSeriesSettings,
name: 'Predicted Temperature',
type: 'line',
data: predictedTempData,
lineStyle: {
color: theme.palette.warning.main,
width: 2,
type: 'dashed'
},
itemStyle: {
color: theme.palette.warning.main,
opacity: 0.8
}
}
]
};
}, [data, theme]);
if (!data || data.length === 0) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height={height}
bgcolor={theme.palette.background.paper}
borderRadius={2}
boxShadow={2}
>
<Typography
variant="h6"
color="text.secondary"
fontFamily="Montserrat, sans-serif"
>
No temperature data available
</Typography>
</Box>
);
}
return (
<Box
sx={{
bgcolor: theme.palette.background.paper,
borderRadius: 2,
boxShadow: 3,
overflow: 'hidden'
}}
>
<ReactECharts
option={option}
style={{ height: `${height}px`, width: '100%' }}
opts={{
renderer: 'canvas',
width: 'auto',
height: 'auto',
}}
notMerge={true}
lazyUpdate={true}
theme={theme.palette.mode}
loadingOption={{
color: theme.palette.primary.main,
maskColor: 'rgba(255, 255, 255, 0.8)',
text: 'Loading...'
}}
onEvents={{
datazoom: (params: any) => {
if (onZoomChange && params.batch?.[0]) {
onZoomChange([params.batch[0].start, params.batch[0].end]);
}
}
}}
/>
</Box>
);
}, (prevProps, nextProps) => {
// Custom comparison to prevent unnecessary rerenders
if (prevProps.height !== nextProps.height) return false;
if (prevProps.zoomRange?.[0] !== nextProps.zoomRange?.[0] ||
prevProps.zoomRange?.[1] !== nextProps.zoomRange?.[1]) return false;
if (prevProps.data.length !== nextProps.data.length) return false;
// Only update if the last data point has changed
const prevLast = prevProps.data[prevProps.data.length - 1];
const nextLast = nextProps.data[nextProps.data.length - 1];
if (prevLast && nextLast) {
return prevLast.currentTimestamp.getTime() === nextLast.currentTimestamp.getTime() &&
prevLast.currentTemp === nextLast.currentTemp &&
prevLast.predictedTemp === nextLast.predictedTemp;
}
return false;
});
TemperatureEChart.displayName = 'TemperatureEChart';
export default TemperatureEChart;

View File

@@ -1,38 +1,8 @@
import { useEffect, useState, useCallback, useRef } from 'react'; import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { Box, Paper, Typography, Fade, useTheme, Grid, AppBar, Toolbar, CircularProgress, IconButton, Tooltip as MuiTooltip, Chip, Button, Snackbar, Alert, Slider } from '@mui/material'; import { Box, Paper, Grid } 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 { monitoringService } from '../services/monitoringService';
import { MonitoringStatus } from '../types/monitoring';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip as ChartTooltip,
Legend,
TimeScale
} from 'chart.js';
import 'chartjs-adapter-date-fns';
import { Line } from 'react-chartjs-2';
import { config } from '../config/env'; import { config } from '../config/env';
import MonitoringChart from '../components/Charts/MonitoringChart';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
ChartTooltip,
Legend,
TimeScale
);
// Define the structure of our data
interface ChartData { interface ChartData {
power: string; power: string;
flag: string; flag: string;
@@ -43,740 +13,141 @@ interface ChartData {
power_future_min: string; power_future_min: string;
} }
const API_BASE_URL = config.apiUrl; type TimeRange = 20 | 50 | 100;
const Temperature = () => { const Temperature = () => {
const theme = useTheme();
const [data, setData] = useState<ChartData[]>([]); const [data, setData] = useState<ChartData[]>([]);
const [loading, setLoading] = useState(true); const [timeRange, setTimeRange] = useState<TimeRange>(20);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null); const [powerZoom, setPowerZoom] = useState<[number, number]>([0, 100]);
const [refreshing, setRefreshing] = useState(false); const [tempZoom, setTempZoom] = useState<[number, number]>([0, 100]);
const [decisionLoading, setDecisionLoading] = useState(false);
const [windowSize, setWindowSize] = useState(20);
const [alert, setAlert] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
open: false,
message: '',
severity: 'success'
});
// Monitoring status state const [isLoading, setIsLoading] = useState(false);
const [monitoringStatus, setMonitoringStatus] = useState<MonitoringStatus | null>(null); const lastFetchRef = useRef<number>(0);
const THROTTLE_INTERVAL = 2000; // Minimum 2 seconds between updates
// Use refs to keep track of the interval // Reset zoom when timeRange changes
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); useEffect(() => {
const updateIntervalMs = 5000; // 5 seconds refresh rate setPowerZoom([0, 100]);
setTempZoom([0, 100]);
setData([]); // Reset data when timeRange changes
}, [timeRange]);
const fetchData = useCallback(async () => {
const now = Date.now();
if (now - lastFetchRef.current < THROTTLE_INTERVAL || isLoading) {
return;
}
// Create a memoized fetchData function with useCallback
const fetchData = useCallback(async (showLoadingIndicator = false) => {
try { try {
if (showLoadingIndicator) { setIsLoading(true);
setRefreshing(true); lastFetchRef.current = now;
}
const response = await fetch(`${API_BASE_URL}/prom/get_chart_data/temperature/20`); const response = await fetch(`${config.apiUrl}/prom/get_chart_data/temperature/${timeRange}`);
const result = await response.json(); const result = await response.json();
if (result.data && result.data.length > 0) { if (result.data?.length > 0) {
// Sort by timestamp first to ensure we get the latest data const newData = [...result.data]
const sortedData = [...result.data].sort((a, b) => .filter((item: ChartData) =>
new Date(b.now_timestamp).getTime() - new Date(a.now_timestamp).getTime() item.now_timestamp &&
); item.future_timestamp &&
item.power &&
item.power_future_min &&
item.env_temp_cur &&
item.env_temp_min
)
.sort((a, b) => new Date(a.now_timestamp).getTime() - new Date(b.now_timestamp).getTime());
// Log the most recent flag // Replace data completely when timeRange changes
console.log('Most recent flag:', sortedData[0].flag); setData(newData.slice(-timeRange));
// 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) { } catch (error) {
console.error('Error fetching data:', error); console.error('Error fetching data:', error);
} finally { } finally {
setLoading(false); setIsLoading(false);
setRefreshing(false);
} }
}, []); }, [timeRange, isLoading]);
// Manual refresh handler
const handleRefresh = () => {
fetchData(true);
};
// Set up the interval for real-time updates
useEffect(() => { useEffect(() => {
// Initial fetch fetchData();
fetchData(true); const interval = setInterval(fetchData, 5000);
return () => clearInterval(interval);
// 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]); }, [fetchData]);
// Set up monitoring status polling // Memoize the transformed data to prevent unnecessary recalculations
useEffect(() => { const { temperatureData, powerData } = useMemo(() => ({
// Start polling for monitoring status temperatureData: data.map(item => ({
monitoringService.startStatusPolling((status) => {
setMonitoringStatus(status);
}, 5000); // Poll every 5 seconds
// Cleanup function to stop polling when component unmounts
return () => {
monitoringService.stopStatusPolling();
};
}, []);
// Process data for charts with sliding window
const prepareChartData = () => {
if (!data || data.length === 0) {
console.log('No data available, using fallback data');
// Fallback data for testing
const now = new Date();
const fallbackData = Array.from({ length: 10 }, (_, i) => ({
currentTimestamp: new Date(now.getTime() - (9 - i) * 60000), // 1 minute intervals
futureTimestamp: new Date(now.getTime() - (9 - i) * 60000 + 3 * 60000), // 3 minutes in the future
currentPower: 95 + Math.random() * 10,
predictedPower: 115 + Math.random() * 10,
currentTemp: 25 + Math.random() * 5,
predictedTemp: 28 + Math.random() * 5,
}));
return fallbackData;
}
const processedData = data.map(item => ({
currentTimestamp: new Date(item.now_timestamp), currentTimestamp: new Date(item.now_timestamp),
futureTimestamp: new Date(item.future_timestamp), futureTimestamp: new Date(item.future_timestamp),
currentPower: parseFloat(item.power), currentValue: parseFloat(item.env_temp_cur),
predictedPower: parseFloat(item.power_future_min), predictedValue: parseFloat(item.env_temp_min),
currentTemp: parseFloat(item.env_temp_cur), })),
predictedTemp: parseFloat(item.env_temp_min), powerData: data.map(item => ({
})); currentTimestamp: new Date(item.now_timestamp),
futureTimestamp: new Date(item.future_timestamp),
// Apply sliding window - show only last N records currentValue: parseFloat(item.power),
const slidingData = processedData.slice(-windowSize); predictedValue: parseFloat(item.power_future_min),
})),
console.log('Processed chart data:', { }), [data]);
totalRecords: processedData.length,
showingRecords: slidingData.length,
timeRange: {
start: slidingData[0]?.currentTimestamp,
end: slidingData[slidingData.length - 1]?.currentTimestamp
}
});
return slidingData;
};
const chartData = prepareChartData();
// Prepare Chart.js data structures
const powerChartData = {
datasets: [
{
label: 'Current Power',
data: chartData.map(item => ({
x: item.currentTimestamp,
y: item.currentPower
})),
borderColor: '#028a4a',
backgroundColor: '#028a4a',
pointBackgroundColor: '#028a4a',
pointBorderColor: '#028a4a',
pointRadius: 0,
pointHoverRadius: 6,
tension: 0.4,
fill: false
},
{
label: 'Predicted Power (Dynamic)',
data: chartData.map(item => ({
x: item.futureTimestamp,
y: item.predictedPower
})),
borderColor: '#ff9800',
backgroundColor: '#ff9800',
pointBackgroundColor: '#ff9800',
pointBorderColor: '#ff9800',
pointRadius: 0,
pointHoverRadius: 6,
tension: 0.4,
fill: false
}
]
};
const temperatureChartData = {
datasets: [
{
label: 'Current Temperature',
data: chartData.map(item => ({
x: item.currentTimestamp,
y: item.currentTemp
})),
borderColor: '#028a4a',
backgroundColor: '#028a4a',
pointBackgroundColor: '#028a4a',
pointBorderColor: '#028a4a',
pointRadius: 0,
pointHoverRadius: 6,
tension: 0.4,
fill: false
},
{
label: 'Predicted Temperature (Dynamic)',
data: chartData.map(item => ({
x: item.futureTimestamp,
y: item.predictedTemp
})),
borderColor: '#ff9800',
backgroundColor: '#ff9800',
pointBackgroundColor: '#ff9800',
pointBorderColor: '#ff9800',
pointRadius: 0,
pointHoverRadius: 6,
tension: 0.4,
fill: false
}
]
};
// Chart.js options
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top' as const,
labels: {
usePointStyle: true,
padding: 20,
font: {
size: 12,
weight: 'normal' as const
}
}
},
tooltip: {
mode: 'index' as const,
intersect: false,
callbacks: {
title: function(context: any) {
const date = new Date(context[0].parsed.x);
return date.toLocaleTimeString();
},
label: function(context: any) {
return `${context.dataset.label}: ${context.parsed.y.toFixed(2)}`;
}
}
}
},
scales: {
x: {
type: 'time' as const,
time: {
displayFormats: {
minute: 'HH:mm:ss'
}
},
title: {
display: true,
text: 'Time'
}
},
y: {
title: {
display: true,
text: 'Value'
}
}
},
interaction: {
mode: 'nearest' as const,
axis: 'x' as const,
intersect: false
}
};
// Debug logging
console.log('Chart.js data structures:', { powerChartData, temperatureChartData });
console.log('Raw data length:', data.length);
// Handle temperature decision
const handleTemperatureDecision = async (approval: boolean) => {
try {
setDecisionLoading(true);
const response = await fetch(`${API_BASE_URL}/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 ( return (
<Box sx={{ flexGrow: 1, bgcolor: theme.palette.background.default }}> <Box sx={{ p: 2 }}>
<AppBar <Box sx={{ mb: 2, display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
position="static" {[20, 50, 100].map((range) => (
elevation={0} <Paper
sx={{ key={range}
bgcolor: 'background.paper', elevation={0}
borderBottom: `1px solid ${theme.palette.divider}`, onClick={() => setTimeRange(range as TimeRange)}
mb: 3 sx={{
}} px: 2,
> py: 1,
<Toolbar sx={{ px: { xs: 2, sm: 4 } }}> border: '1px solid rgba(0, 0, 0, 0.08)',
<Box sx={{ flex: 1, textAlign: "center" }}> cursor: 'pointer',
<Typography bgcolor: timeRange === range ? 'primary.main' : 'background.paper',
variant="h5" color: timeRange === range ? 'primary.contrastText' : 'text.primary',
component="h1" '&:hover': {
sx={{ bgcolor: timeRange === range ? 'primary.dark' : 'action.hover'
color: 'text.primary', },
fontWeight: 500, transition: 'all 0.2s'
letterSpacing: '-0.5px', }}
mb: 0.5 >
}} {range} points
> </Paper>
Environmental Temperature & Power Monitoring ))}
</Typography>
<Chip
label={`Showing last ${windowSize} records`}
variant="outlined"
size="small"
sx={{
height: 24,
'& .MuiChip-label': {
px: 1.5,
fontSize: '0.75rem',
fontWeight: 500
},
borderColor: 'rgba(0, 0, 0, 0.2)',
color: 'rgba(0, 0, 0, 0.7)'
}}
/>
</Box>
<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>
)}
<MuiTooltip 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>
</MuiTooltip>
</Box>
</Toolbar>
</AppBar>
<Box sx={{ p: { xs: 2, sm: 4 } }}>
<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>
) : (
<Box sx={{ height: 400 }}>
<Line
data={powerChartData}
options={{
...chartOptions,
scales: {
...chartOptions.scales,
y: {
...chartOptions.scales.y,
title: {
display: true,
text: 'Power (W)'
},
ticks: {
callback: function(value: any) {
return `${value} W`;
}
}
}
}
}}
/>
</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>
) : (
<Box sx={{ height: 400 }}>
<Line
data={temperatureChartData}
options={{
...chartOptions,
scales: {
...chartOptions.scales,
y: {
...chartOptions.scales.y,
title: {
display: true,
text: 'Temperature (°C)'
},
ticks: {
callback: function(value: any) {
return `${value} °C`;
}
}
}
}
}}
/>
</Box>
)}
</Paper>
</Grid>
</Grid>
</Fade>
{/* Chart Controls */}
<Paper
elevation={0}
sx={{
p: 3,
mt: 3,
bgcolor: 'background.paper',
borderRadius: 2,
border: `1px solid ${theme.palette.divider}`,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 3 }}>
<Box>
<Typography variant="h6" sx={{ color: 'text.primary', mb: 1, fontWeight: 500 }}>
Chart Settings
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Configure chart display and temperature change proposals
</Typography>
</Box>
{/* Temperature Change Proposal Section */}
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 1,
minWidth: 200
}}>
<Typography variant="subtitle2" sx={{ color: 'text.primary', fontWeight: 600 }}>
Temperature Change Proposal
</Typography>
{(!monitoringStatus?.statuses?.environmental?.is_running || !monitoringStatus?.statuses?.preventive?.is_running) ? (
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 1,
borderRadius: 1,
bgcolor: 'warning.light',
color: 'warning.contrastText'
}}>
<CircularProgress size={16} thickness={4} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
Waiting for services...
</Typography>
</Box>
) : data.length > 0 && chartData.some(item => !isNaN(item.currentTemp) && item.currentTemp > 0) ? (
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
color="success"
size="small"
startIcon={<CheckCircleIcon />}
onClick={() => handleTemperatureDecision(true)}
disabled={decisionLoading}
sx={{
borderRadius: 1.5,
textTransform: 'none',
fontSize: '0.75rem',
px: 2,
py: 0.5,
minWidth: 80,
boxShadow: 1,
'&:hover': {
boxShadow: 2,
transform: 'translateY(-1px)'
},
transition: 'all 0.2s ease-in-out'
}}
>
Approve
</Button>
<Button
variant="contained"
color="error"
size="small"
startIcon={<CancelIcon />}
onClick={() => handleTemperatureDecision(false)}
disabled={decisionLoading}
sx={{
borderRadius: 1.5,
textTransform: 'none',
fontSize: '0.75rem',
px: 2,
py: 0.5,
minWidth: 80,
boxShadow: 1,
'&:hover': {
boxShadow: 2,
transform: 'translateY(-1px)'
},
transition: 'all 0.2s ease-in-out'
}}
>
Decline
</Button>
</Box>
) : (
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
No temperature data available
</Typography>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body2" sx={{ minWidth: 120, fontWeight: 500 }}>
Records to show:
</Typography>
<Slider
value={windowSize}
onChange={(_, value) => setWindowSize(value as number)}
min={5}
max={50}
step={5}
marks={[
{ value: 5, label: '5' },
{ value: 20, label: '20' },
{ value: 50, label: '50' }
]}
sx={{ flex: 1, maxWidth: 200 }}
/>
<Typography variant="body2" sx={{ minWidth: 40, fontWeight: 500 }}>
{windowSize}
</Typography>
</Box>
</Paper>
</Box> </Box>
<Grid container spacing={2}>
{/* Snackbar for alerts */} <Grid item xs={12}>
<Snackbar <Paper elevation={0} sx={{ p: 2, border: '1px solid rgba(0, 0, 0, 0.08)' }}>
open={alert.open} <MonitoringChart
autoHideDuration={6000} data={powerData}
onClose={handleCloseAlert} height={400}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} zoomRange={powerZoom}
> onZoomChange={setPowerZoom}
<Alert chartTitle="Power Consumption"
onClose={handleCloseAlert} unit="W"
severity={alert.severity} colors={{
variant="filled" current: '#2196f3',
sx={{ width: '100%' }} predicted: '#ff9800'
> }}
{alert.message} />
</Alert> </Paper>
</Snackbar> </Grid>
<Grid item xs={12}>
<Paper elevation={0} sx={{ p: 2, border: '1px solid rgba(0, 0, 0, 0.08)' }}>
<MonitoringChart
data={temperatureData}
height={400}
zoomRange={tempZoom}
onZoomChange={setTempZoom}
chartTitle="Environmental Temperature"
unit="°C"
colors={{
current: '#028a4a',
predicted: '#ed6c02'
}}
/>
</Paper>
</Grid>
</Grid>
</Box> </Box>
); );
}; };