forked from BLC/AyposWeb
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec5eec7b64 | |||
| dc11fb1641 | |||
| 25b47c9f86 | |||
| 531dfd8715 |
10
.env.example
Normal file
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# API Configuration
|
||||
VITE_API_URL=http://your-api-url:8003
|
||||
|
||||
# Vercel Deployment Configuration
|
||||
NEXT_PUBLIC_ALLOWED_HOSTS=your-allowed-hosts
|
||||
NEXT_PUBLIC_VERCEL_URL=${VERCEL_URL}
|
||||
NODE_ENV=development
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGIN=*
|
||||
125
.github/copilot-instructions.md
vendored
Normal file
125
.github/copilot-instructions.md
vendored
Normal 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
70
package-lock.json
generated
@@ -15,11 +15,14 @@
|
||||
"@mui/material": "^5.15.3",
|
||||
"@mui/x-charts": "^8.9.0",
|
||||
"@mui/x-tree-view": "^7.26.0",
|
||||
"@types/echarts": "^4.9.22",
|
||||
"@types/plotly.js": "^2.35.2",
|
||||
"axios": "^1.8.4",
|
||||
"chart.js": "^4.5.0",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"lucide-react": "^0.525.0",
|
||||
"plotly.js": "^3.0.1",
|
||||
"plotly.js-dist": "^3.0.1",
|
||||
@@ -2717,6 +2720,15 @@
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2842,6 +2854,12 @@
|
||||
"@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": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
||||
@@ -4068,6 +4086,36 @@
|
||||
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
|
||||
"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": {
|
||||
"version": "1.5.190",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.190.tgz",
|
||||
@@ -4584,7 +4632,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
@@ -7182,6 +7229,12 @@
|
||||
"integrity": "sha512-yodFGwcyt59XRh7w5W3jPcIQb3Bwi21suEfT7MAWnBX3iCdklJpgDgvGT9o04UonglZN5SNMfJFkHIR/jO8GHw==",
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
||||
@@ -7879,6 +7932,21 @@
|
||||
"funding": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,14 @@
|
||||
"@mui/material": "^5.15.3",
|
||||
"@mui/x-charts": "^8.9.0",
|
||||
"@mui/x-tree-view": "^7.26.0",
|
||||
"@types/echarts": "^4.9.22",
|
||||
"@types/plotly.js": "^2.35.2",
|
||||
"axios": "^1.8.4",
|
||||
"chart.js": "^4.5.0",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"lucide-react": "^0.525.0",
|
||||
"plotly.js": "^3.0.1",
|
||||
"plotly.js-dist": "^3.0.1",
|
||||
|
||||
440
src/components/Charts/MaintenanceChart.tsx
Normal file
440
src/components/Charts/MaintenanceChart.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
interface MaintenanceDataPoint {
|
||||
currentTimestamp: Date;
|
||||
futureTimestamp: Date;
|
||||
currentPower: number;
|
||||
predictedPower: number;
|
||||
positive3p: number;
|
||||
negative3p: number;
|
||||
positive7p: number;
|
||||
negative7p: number;
|
||||
}
|
||||
|
||||
interface MaintenanceChartProps {
|
||||
data: MaintenanceDataPoint[];
|
||||
height?: number;
|
||||
zoomRange?: [number, number];
|
||||
onZoomChange?: (range: [number, number]) => void;
|
||||
}
|
||||
|
||||
const MaintenanceChart: React.FC<MaintenanceChartProps> = React.memo(({
|
||||
data,
|
||||
height = 400,
|
||||
zoomRange,
|
||||
onZoomChange
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const commonSeriesSettings = {
|
||||
sampling: 'lttb',
|
||||
animation: false,
|
||||
emphasis: {
|
||||
focus: 'self',
|
||||
scale: 1,
|
||||
itemStyle: {
|
||||
borderWidth: 3,
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(0,0,0,0.2)'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 4
|
||||
}
|
||||
},
|
||||
blur: {
|
||||
lineStyle: {
|
||||
opacity: 0.8
|
||||
},
|
||||
itemStyle: {
|
||||
opacity: 0.8
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const option = useMemo(() => {
|
||||
if (!data || data.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const currentData = data.map(item => [
|
||||
item.currentTimestamp.getTime(),
|
||||
Number(item.currentPower.toFixed(2))
|
||||
]);
|
||||
|
||||
const predictedData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.predictedPower.toFixed(2))
|
||||
]);
|
||||
|
||||
const positive3pData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.positive3p.toFixed(2))
|
||||
]);
|
||||
|
||||
const negative3pData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.negative3p.toFixed(2))
|
||||
]);
|
||||
|
||||
const positive7pData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.positive7p.toFixed(2))
|
||||
]);
|
||||
|
||||
const negative7pData = data.map(item => [
|
||||
item.futureTimestamp.getTime(),
|
||||
Number(item.negative7p.toFixed(2))
|
||||
]);
|
||||
|
||||
const allValues = [
|
||||
...data.map(d => d.currentPower),
|
||||
...data.map(d => d.predictedPower),
|
||||
...data.map(d => d.positive3p),
|
||||
...data.map(d => d.negative3p),
|
||||
...data.map(d => d.positive7p),
|
||||
...data.map(d => d.negative7p)
|
||||
];
|
||||
|
||||
const minValue = Math.min(...allValues);
|
||||
const maxValue = Math.max(...allValues);
|
||||
const valuePadding = (maxValue - minValue) * 0.1;
|
||||
|
||||
return {
|
||||
animation: false,
|
||||
progressive: 500,
|
||||
progressiveThreshold: 1000,
|
||||
renderer: 'canvas',
|
||||
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]}W</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['Current Power', 'Predicted Power', '±3% Threshold', '±7% Threshold'],
|
||||
bottom: '60px',
|
||||
left: 'center',
|
||||
padding: [8, 16],
|
||||
itemGap: 32,
|
||||
textStyle: {
|
||||
fontFamily: 'Montserrat, sans-serif',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: theme.palette.text.secondary
|
||||
},
|
||||
icon: 'circle',
|
||||
itemHeight: 10,
|
||||
itemWidth: 10,
|
||||
selectedMode: false,
|
||||
backgroundColor: 'transparent'
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '3%',
|
||||
bottom: '100px',
|
||||
top: '20px',
|
||||
containLabel: true
|
||||
},
|
||||
toolbox: {
|
||||
right: '20px',
|
||||
top: '10px',
|
||||
feature: {
|
||||
dataZoom: {
|
||||
yAxisIndex: 'none',
|
||||
title: {
|
||||
zoom: 'Zoom',
|
||||
back: 'Reset Zoom'
|
||||
}
|
||||
},
|
||||
restore: {
|
||||
title: 'Reset'
|
||||
},
|
||||
saveAsImage: {
|
||||
title: 'Save'
|
||||
}
|
||||
}
|
||||
},
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'slider',
|
||||
show: true,
|
||||
xAxisIndex: [0],
|
||||
start: zoomRange?.[0] ?? 0,
|
||||
end: zoomRange?.[1] ?? 100,
|
||||
height: 30,
|
||||
bottom: 20,
|
||||
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',
|
||||
fontSize: 12
|
||||
},
|
||||
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)',
|
||||
nameTextStyle: {
|
||||
color: theme.palette.text.secondary,
|
||||
fontFamily: 'Montserrat, sans-serif',
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold',
|
||||
padding: [0, 0, 8, 0]
|
||||
},
|
||||
min: Math.max(0, minValue - valuePadding),
|
||||
max: maxValue + valuePadding,
|
||||
axisLabel: {
|
||||
formatter: '{value}W',
|
||||
color: theme.palette.text.secondary,
|
||||
fontFamily: 'Montserrat, sans-serif',
|
||||
fontSize: 12
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: theme.palette.divider
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: theme.palette.divider,
|
||||
type: 'dashed',
|
||||
opacity: 0.3
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
...commonSeriesSettings,
|
||||
name: 'Current Power',
|
||||
type: 'line',
|
||||
data: currentData,
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.primary.main,
|
||||
width: 3
|
||||
},
|
||||
itemStyle: {
|
||||
color: theme.palette.primary.main,
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [{
|
||||
offset: 0,
|
||||
color: `rgba(2, 138, 74, 0.3)`
|
||||
}, {
|
||||
offset: 1,
|
||||
color: `rgba(2, 138, 74, 0.05)`
|
||||
}]
|
||||
}
|
||||
},
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
showSymbol: true
|
||||
},
|
||||
{
|
||||
...commonSeriesSettings,
|
||||
name: 'Predicted Power',
|
||||
type: 'line',
|
||||
data: predictedData,
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.warning.main,
|
||||
width: 3,
|
||||
type: 'dashed'
|
||||
},
|
||||
itemStyle: {
|
||||
color: theme.palette.warning.main,
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff'
|
||||
},
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
showSymbol: true
|
||||
},
|
||||
{
|
||||
...commonSeriesSettings,
|
||||
name: '±3% Threshold',
|
||||
type: 'line',
|
||||
data: positive3pData.concat(negative3pData.reverse()),
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.warning.light,
|
||||
width: 1,
|
||||
type: 'dashed'
|
||||
},
|
||||
areaStyle: {
|
||||
color: theme.palette.warning.light,
|
||||
opacity: 0.1
|
||||
},
|
||||
symbol: 'none'
|
||||
},
|
||||
{
|
||||
...commonSeriesSettings,
|
||||
name: '±7% Threshold',
|
||||
type: 'line',
|
||||
data: positive7pData.concat(negative7pData.reverse()),
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: theme.palette.error.light,
|
||||
width: 1,
|
||||
type: 'dashed'
|
||||
},
|
||||
areaStyle: {
|
||||
color: theme.palette.error.light,
|
||||
opacity: 0.1
|
||||
},
|
||||
symbol: 'none'
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data, theme, 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 maintenance data available
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<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) => {
|
||||
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;
|
||||
|
||||
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.currentPower !== nextItem.currentPower ||
|
||||
prevItem.predictedPower !== nextItem.predictedPower) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
MaintenanceChart.displayName = 'MaintenanceChart';
|
||||
|
||||
export default MaintenanceChart;
|
||||
367
src/components/Charts/MonitoringChart.tsx
Normal file
367
src/components/Charts/MonitoringChart.tsx
Normal 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;
|
||||
347
src/components/Charts/PowerEChart.tsx
Normal file
347
src/components/Charts/PowerEChart.tsx
Normal 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;
|
||||
280
src/components/Charts/TemperatureControl.tsx
Normal file
280
src/components/Charts/TemperatureControl.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
Chip,
|
||||
Tooltip,
|
||||
alpha
|
||||
} from '@mui/material';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { MonitoringStatus } from '../../types/monitoring';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
interface TemperatureControlProps {
|
||||
onApprove: () => Promise<void>;
|
||||
onDecline: () => Promise<void>;
|
||||
monitoringStatus: MonitoringStatus | null;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
hasData: boolean;
|
||||
temperatureFlag?: string;
|
||||
}
|
||||
|
||||
const TemperatureControl: React.FC<TemperatureControlProps> = ({
|
||||
onApprove,
|
||||
onDecline,
|
||||
monitoringStatus,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
hasData,
|
||||
temperatureFlag
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [decisionLoading, setDecisionLoading] = useState(false);
|
||||
|
||||
const handleApprove = async () => {
|
||||
try {
|
||||
setDecisionLoading(true);
|
||||
await onApprove();
|
||||
} finally {
|
||||
setDecisionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecline = async () => {
|
||||
try {
|
||||
setDecisionLoading(true);
|
||||
await onDecline();
|
||||
} finally {
|
||||
setDecisionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const servicesRunning = monitoringStatus?.statuses?.environmental?.is_running &&
|
||||
monitoringStatus?.statuses?.preventive?.is_running;
|
||||
|
||||
const getButtonTooltip = () => {
|
||||
if (!servicesRunning) return 'Waiting for monitoring services to start';
|
||||
if (!hasData) return 'No temperature data available';
|
||||
if (isLoading) return 'Loading data...';
|
||||
if (decisionLoading) return 'Processing request...';
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
mt: 3,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
boxShadow: `0 2px 12px 0 ${alpha(theme.palette.primary.main, 0.08)}`
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
fontWeight: 600,
|
||||
fontSize: '1.1rem'
|
||||
}}
|
||||
>
|
||||
Temperature Control
|
||||
</Typography>
|
||||
{temperatureFlag && (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
py: 0.5,
|
||||
px: 1.5,
|
||||
borderRadius: 1,
|
||||
bgcolor: theme.palette.background.default
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: 'text.secondary' }}>Flag:</Typography>
|
||||
<Chip
|
||||
label={temperatureFlag}
|
||||
color={temperatureFlag === '25' ? 'success' : 'warning'}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 24,
|
||||
minWidth: 32,
|
||||
'& .MuiChip-label': {
|
||||
px: 2,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
>
|
||||
Review and respond to temperature change proposals
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip
|
||||
label="Environmental"
|
||||
size="small"
|
||||
color={monitoringStatus?.statuses?.environmental?.is_running ? "success" : "error"}
|
||||
sx={{
|
||||
height: 24,
|
||||
fontWeight: 500,
|
||||
bgcolor: monitoringStatus?.statuses?.environmental?.is_running
|
||||
? alpha(theme.palette.success.main, 0.1)
|
||||
: alpha(theme.palette.error.main, 0.1),
|
||||
'& .MuiChip-label': {
|
||||
px: 1.5
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="Preventive"
|
||||
size="small"
|
||||
color={monitoringStatus?.statuses?.preventive?.is_running ? "success" : "error"}
|
||||
sx={{
|
||||
height: 24,
|
||||
fontWeight: 500,
|
||||
bgcolor: monitoringStatus?.statuses?.preventive?.is_running
|
||||
? alpha(theme.palette.success.main, 0.1)
|
||||
: alpha(theme.palette.error.main, 0.1),
|
||||
'& .MuiChip-label': {
|
||||
px: 1.5
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
p: 2.5,
|
||||
borderRadius: 1,
|
||||
bgcolor: alpha(theme.palette.background.default, 0.6),
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.4)}`
|
||||
}}
|
||||
>
|
||||
{!servicesRunning && (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
p: 1.5,
|
||||
borderRadius: 1,
|
||||
bgcolor: alpha(theme.palette.warning.main, 0.1),
|
||||
color: theme.palette.warning.main,
|
||||
border: `1px solid ${alpha(theme.palette.warning.main, 0.2)}`
|
||||
}}>
|
||||
<CircularProgress size={16} thickness={5} color="inherit" />
|
||||
<Typography sx={{ fontSize: '0.875rem', fontWeight: 500 }}>
|
||||
Waiting for services to initialize...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!hasData && (
|
||||
<Typography
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.875rem',
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic'
|
||||
}}
|
||||
>
|
||||
No temperature data available for review
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Tooltip title={getButtonTooltip()} arrow>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
startIcon={<CheckCircleIcon />}
|
||||
onClick={handleApprove}
|
||||
disabled={disabled || decisionLoading || isLoading || !servicesRunning || !hasData}
|
||||
sx={{
|
||||
py: 1,
|
||||
bgcolor: theme.palette.success.main,
|
||||
borderRadius: 1.5,
|
||||
textTransform: 'none',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 500,
|
||||
boxShadow: `0 2px 8px 0 ${alpha(theme.palette.success.main, 0.25)}`,
|
||||
opacity: (!servicesRunning || !hasData) ? 0.7 : 1,
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette.success.dark,
|
||||
boxShadow: `0 4px 12px 0 ${alpha(theme.palette.success.main, 0.35)}`,
|
||||
}
|
||||
}}
|
||||
>
|
||||
{decisionLoading ? (
|
||||
<CircularProgress size={24} color="inherit" thickness={5} />
|
||||
) : (
|
||||
'Approve Changes'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={getButtonTooltip()} arrow>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
startIcon={<CancelIcon />}
|
||||
onClick={handleDecline}
|
||||
disabled={disabled || decisionLoading || isLoading || !servicesRunning || !hasData}
|
||||
sx={{
|
||||
py: 1,
|
||||
bgcolor: theme.palette.error.main,
|
||||
borderRadius: 1.5,
|
||||
textTransform: 'none',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 500,
|
||||
boxShadow: `0 2px 8px 0 ${alpha(theme.palette.error.main, 0.25)}`,
|
||||
opacity: (!servicesRunning || !hasData) ? 0.7 : 1,
|
||||
'&:hover': {
|
||||
bgcolor: theme.palette.error.dark,
|
||||
boxShadow: `0 4px 12px 0 ${alpha(theme.palette.error.main, 0.35)}`,
|
||||
}
|
||||
}}
|
||||
>
|
||||
{decisionLoading ? (
|
||||
<CircularProgress size={24} color="inherit" thickness={5} />
|
||||
) : (
|
||||
'Decline Changes'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemperatureControl;
|
||||
202
src/components/Charts/TemperatureDashboard.tsx
Normal file
202
src/components/Charts/TemperatureDashboard.tsx
Normal 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;
|
||||
344
src/components/Charts/TemperatureEChart.tsx
Normal file
344
src/components/Charts/TemperatureEChart.tsx
Normal 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;
|
||||
@@ -4,10 +4,24 @@ const getApiUrl = (): string => {
|
||||
if (import.meta.env.PROD) {
|
||||
return '/api';
|
||||
}
|
||||
// In development, use the direct URL
|
||||
return import.meta.env.VITE_API_URL || 'http://aypos-api.blc-css.com';
|
||||
// In development, use the direct URL from environment variable
|
||||
return import.meta.env.VITE_API_URL || 'http://141.196.166.241:8003';
|
||||
};
|
||||
|
||||
const getVercelUrl = (): string => {
|
||||
return import.meta.env.NEXT_PUBLIC_VERCEL_URL || '';
|
||||
};
|
||||
|
||||
const getAllowedHosts = (): string[] => {
|
||||
const hosts = import.meta.env.NEXT_PUBLIC_ALLOWED_HOSTS;
|
||||
return hosts ? hosts.split(',') : ['141.196.166.241'];
|
||||
};
|
||||
|
||||
export const config = {
|
||||
apiUrl: getApiUrl(),
|
||||
vercelUrl: getVercelUrl(),
|
||||
allowedHosts: getAllowedHosts(),
|
||||
isDevelopment: import.meta.env.DEV,
|
||||
isProduction: import.meta.env.PROD,
|
||||
corsOrigin: import.meta.env.CORS_ORIGIN || '*'
|
||||
} as const;
|
||||
@@ -1,31 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Paper, Typography, Fade, useTheme, AppBar, Toolbar, Chip, Slider} from '@mui/material';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
TimeScale
|
||||
} from 'chart.js';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { Box, Paper, Typography, AppBar, Toolbar, Chip } from '@mui/material';
|
||||
import { config } from '../config/env';
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
TimeScale
|
||||
);
|
||||
import MaintenanceChart from '../components/Charts/MaintenanceChart';
|
||||
|
||||
interface DataItem {
|
||||
now_timestamp: string;
|
||||
@@ -39,380 +15,183 @@ interface DataItem {
|
||||
flag: string;
|
||||
}
|
||||
|
||||
const API_BASE_URL = config.apiUrl;
|
||||
|
||||
const Maintenance = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const [data, setData] = useState<DataItem[]>([]);
|
||||
const [currentFlag, setCurrentFlag] = useState<string>('');
|
||||
const [, setLoading] = useState(true);
|
||||
const [windowSize, setWindowSize] = useState(20);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [windowSize, setWindowSize] = useState<20 | 50 | 100>(20);
|
||||
const [zoomRange, setZoomRange] = useState<[number, number]>([0, 100]);
|
||||
|
||||
const lastFetchRef = useRef<number>(0);
|
||||
const THROTTLE_INTERVAL = 2000; // Minimum 2 seconds between updates
|
||||
|
||||
// Reset zoom when window size changes
|
||||
useEffect(() => {
|
||||
setZoomRange([0, 100]);
|
||||
setData([]); // Reset data when window size changes
|
||||
}, [windowSize]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const now = Date.now();
|
||||
if (now - lastFetchRef.current < THROTTLE_INTERVAL || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
lastFetchRef.current = now;
|
||||
|
||||
const response = await fetch(`${config.apiUrl}/prom/get_chart_data/maintenance/${windowSize}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.data && result.data.length > 0) {
|
||||
const newData = [...result.data]
|
||||
.filter((item: DataItem) =>
|
||||
item.now_timestamp &&
|
||||
item.future_timestamp &&
|
||||
item.power &&
|
||||
item.power_future_min &&
|
||||
item.positive_3p &&
|
||||
item.negative_3p &&
|
||||
item.positive_7p &&
|
||||
item.negative_7p
|
||||
)
|
||||
.sort((a, b) => new Date(a.now_timestamp).getTime() - new Date(b.now_timestamp).getTime());
|
||||
|
||||
setCurrentFlag(newData[newData.length - 1].flag);
|
||||
setData(newData.slice(-windowSize));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [windowSize, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_BASE_URL}/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);
|
||||
setData(last20Data);
|
||||
console.log('Fetched data:', last20Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [fetchData]);
|
||||
|
||||
// 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,
|
||||
positive3p: 118 + Math.random() * 5,
|
||||
negative3p: 112 + Math.random() * 5,
|
||||
positive7p: 123 + Math.random() * 5,
|
||||
negative7p: 107 + Math.random() * 5,
|
||||
}));
|
||||
return fallbackData;
|
||||
}
|
||||
|
||||
const processedData = data.map(item => {
|
||||
const currentPower = parseFloat(item.power) || 0;
|
||||
const predictedPower = parseFloat(item.power_future_min) || currentPower * 1.1; // Fallback to 10% higher than current
|
||||
|
||||
return {
|
||||
currentTimestamp: new Date(item.now_timestamp),
|
||||
futureTimestamp: new Date(item.future_timestamp),
|
||||
currentPower: currentPower,
|
||||
predictedPower: predictedPower,
|
||||
positive3p: parseFloat(item.positive_3p) || predictedPower * 1.03,
|
||||
negative3p: parseFloat(item.negative_3p) || predictedPower * 0.97,
|
||||
positive7p: parseFloat(item.positive_7p) || predictedPower * 1.07,
|
||||
negative7p: parseFloat(item.negative_7p) || predictedPower * 0.93,
|
||||
};
|
||||
});
|
||||
|
||||
// Apply sliding window - show only last N records
|
||||
const slidingData = processedData.slice(-windowSize);
|
||||
|
||||
console.log('Processed chart data:', {
|
||||
totalRecords: processedData.length,
|
||||
showingRecords: slidingData.length,
|
||||
timeRange: {
|
||||
start: slidingData[0]?.currentTimestamp,
|
||||
end: slidingData[slidingData.length - 1]?.currentTimestamp
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Data validation:', {
|
||||
hasCurrentPower: slidingData.some(d => d.currentPower > 0),
|
||||
hasPredictedPower: slidingData.some(d => d.predictedPower > 0),
|
||||
currentPowerRange: [Math.min(...slidingData.map(d => d.currentPower)), Math.max(...slidingData.map(d => d.currentPower))],
|
||||
predictedPowerRange: [Math.min(...slidingData.map(d => d.predictedPower)), Math.max(...slidingData.map(d => d.predictedPower))],
|
||||
rawDataSample: data.slice(-2).map(item => ({
|
||||
power: item.power,
|
||||
power_future_min: item.power_future_min,
|
||||
parsedCurrent: parseFloat(item.power),
|
||||
parsedPredicted: parseFloat(item.power_future_min)
|
||||
}))
|
||||
});
|
||||
|
||||
return slidingData;
|
||||
};
|
||||
|
||||
const chartData = prepareChartData();
|
||||
|
||||
// Prepare Chart.js data structure
|
||||
const chartJsData = {
|
||||
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 (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
|
||||
},
|
||||
{
|
||||
label: '+3% Positive',
|
||||
data: chartData.map(item => ({
|
||||
x: item.futureTimestamp,
|
||||
y: item.positive3p
|
||||
})),
|
||||
borderColor: '#ffb400',
|
||||
backgroundColor: 'rgba(255, 180, 0, 0.1)',
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: '-3% Negative',
|
||||
data: chartData.map(item => ({
|
||||
x: item.futureTimestamp,
|
||||
y: item.negative3p
|
||||
})),
|
||||
borderColor: '#ffb400',
|
||||
backgroundColor: 'rgba(255, 180, 0, 0.1)',
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: '+7% Positive',
|
||||
data: chartData.map(item => ({
|
||||
x: item.futureTimestamp,
|
||||
y: item.positive7p
|
||||
})),
|
||||
borderColor: '#FF1744',
|
||||
backgroundColor: 'rgba(255, 23, 68, 0.1)',
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: '-7% Negative',
|
||||
data: chartData.map(item => ({
|
||||
x: item.futureTimestamp,
|
||||
y: item.negative7p
|
||||
})),
|
||||
borderColor: '#FF1744',
|
||||
backgroundColor: 'rgba(255, 23, 68, 0.1)',
|
||||
pointRadius: 0,
|
||||
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)} W`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time' as const,
|
||||
time: {
|
||||
displayFormats: {
|
||||
minute: 'HH:mm:ss'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Time'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Power (W)'
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value: any) {
|
||||
return `${value} W`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest' as const,
|
||||
axis: 'x' as const,
|
||||
intersect: false
|
||||
}
|
||||
};
|
||||
|
||||
// Debug logging
|
||||
console.log('Chart.js data structure:', chartJsData);
|
||||
// Transform data for the maintenance chart
|
||||
const chartData = useMemo(() => {
|
||||
return data.map(item => ({
|
||||
currentTimestamp: new Date(item.now_timestamp),
|
||||
futureTimestamp: new Date(item.future_timestamp),
|
||||
currentPower: parseFloat(item.power),
|
||||
predictedPower: parseFloat(item.power_future_min),
|
||||
positive3p: parseFloat(item.positive_3p),
|
||||
negative3p: parseFloat(item.negative_3p),
|
||||
positive7p: parseFloat(item.positive_7p),
|
||||
negative7p: parseFloat(item.negative_7p)
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, bgcolor: theme.palette.background.default }}>
|
||||
<AppBar
|
||||
position="static"
|
||||
elevation={0}
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
mb: 3
|
||||
position: 'relative',
|
||||
minHeight: '500px',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
{currentFlag && (
|
||||
<Chip
|
||||
label={currentFlag}
|
||||
color={currentFlag === 'Correct Estimation for PM energy' ? 'success' : 'warning'}
|
||||
size="medium"
|
||||
<AppBar
|
||||
position="static"
|
||||
color="transparent"
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: theme => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.02)'
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{ px: 3 }}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: 'text.primary',
|
||||
lineHeight: 1.3
|
||||
}}
|
||||
>
|
||||
Preventive Maintenance Monitoring
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Real-time power consumption with predictive indicators
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ ml: 'auto', display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={`Status: ${currentFlag}`}
|
||||
color={currentFlag === 'normal' ? 'success' : 'warning'}
|
||||
sx={{
|
||||
minWidth: 120,
|
||||
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)',
|
||||
},
|
||||
}
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box sx={{ p: { xs: 2, sm: 4 } }}>
|
||||
<Fade in timeout={800}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: { xs: 2, sm: 3 },
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
justifyContent: 'flex-end'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ height: 'calc(100vh - 280px)', minHeight: '500px' }}>
|
||||
<Line data={chartJsData} options={chartOptions} />
|
||||
</Box>
|
||||
|
||||
{/* Chart Controls */}
|
||||
<Box sx={{
|
||||
mt: 2,
|
||||
p: 2,
|
||||
bgcolor: 'background.default',
|
||||
borderRadius: 1,
|
||||
border: `1px solid ${theme.palette.divider}`
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
|
||||
Chart Settings
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ minWidth: 120 }}>
|
||||
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 }}>
|
||||
{windowSize}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Fade>
|
||||
</Box>
|
||||
{[20, 50, 100].map((size) => (
|
||||
<Paper
|
||||
key={size}
|
||||
elevation={0}
|
||||
onClick={() => setWindowSize(size as 20 | 50 | 100)}
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: 1.5,
|
||||
border: 1,
|
||||
borderColor: windowSize === size ? 'primary.main' : 'divider',
|
||||
cursor: 'pointer',
|
||||
bgcolor: windowSize === size ? 'primary.main' : 'transparent',
|
||||
color: windowSize === size ? 'primary.contrastText' : 'text.primary',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
bgcolor: windowSize === size ? 'primary.dark' : 'action.hover',
|
||||
borderColor: windowSize === size ? 'primary.dark' : 'primary.main'
|
||||
},
|
||||
transition: theme => theme.transitions.create(['all'], {
|
||||
duration: 200
|
||||
})
|
||||
}}
|
||||
>
|
||||
{size} points
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
<MaintenanceChart
|
||||
data={chartData}
|
||||
height={500}
|
||||
zoomRange={zoomRange}
|
||||
onZoomChange={setZoomRange}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Maintenance;
|
||||
export default Maintenance;
|
||||
@@ -1,38 +1,11 @@
|
||||
import { useEffect, useState, useCallback, 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 RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { Box, Paper, Grid, Alert, Snackbar } from '@mui/material';
|
||||
import { config } from '../config/env';
|
||||
import MonitoringChart from '../components/Charts/MonitoringChart';
|
||||
import TemperatureControl from '../components/Charts/TemperatureControl';
|
||||
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';
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
ChartTooltip,
|
||||
Legend,
|
||||
TimeScale
|
||||
);
|
||||
|
||||
// Define the structure of our data
|
||||
interface ChartData {
|
||||
power: string;
|
||||
flag: string;
|
||||
@@ -43,307 +16,91 @@ interface ChartData {
|
||||
power_future_min: string;
|
||||
}
|
||||
|
||||
const API_BASE_URL = config.apiUrl;
|
||||
type TimeRange = 20 | 50 | 100;
|
||||
|
||||
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 [timeRange, setTimeRange] = useState<TimeRange>(20);
|
||||
const [powerZoom, setPowerZoom] = useState<[number, number]>([0, 100]);
|
||||
const [tempZoom, setTempZoom] = useState<[number, number]>([0, 100]);
|
||||
const [monitoringStatus, setMonitoringStatus] = useState<MonitoringStatus | null>(null);
|
||||
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 [monitoringStatus, setMonitoringStatus] = useState<MonitoringStatus | null>(null);
|
||||
|
||||
// Use refs to keep track of the interval
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const updateIntervalMs = 5000; // 5 seconds refresh rate
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const lastFetchRef = useRef<number>(0);
|
||||
const THROTTLE_INTERVAL = 2000; // Minimum 2 seconds between updates
|
||||
|
||||
// Reset zoom when timeRange changes
|
||||
useEffect(() => {
|
||||
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 {
|
||||
if (showLoadingIndicator) {
|
||||
setRefreshing(true);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/prom/get_chart_data/temperature/20`);
|
||||
setIsLoading(true);
|
||||
lastFetchRef.current = now;
|
||||
|
||||
const response = await fetch(`${config.apiUrl}/prom/get_chart_data/temperature/${timeRange}`);
|
||||
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()
|
||||
);
|
||||
if (result.data?.length > 0) {
|
||||
const newData = [...result.data]
|
||||
.filter((item: ChartData) =>
|
||||
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
|
||||
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());
|
||||
// Replace data completely when timeRange changes
|
||||
setData(newData.slice(-timeRange));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [timeRange, isLoading]);
|
||||
|
||||
// Manual refresh handler
|
||||
const handleRefresh = () => {
|
||||
fetchData(true);
|
||||
};
|
||||
|
||||
// Set up the interval for real-time updates
|
||||
useEffect(() => {
|
||||
// Initial fetch
|
||||
fetchData(true);
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 5000);
|
||||
|
||||
// Set up interval for auto-refresh
|
||||
intervalRef.current = setInterval(() => {
|
||||
console.log(`Auto-refreshing data at ${new Date().toLocaleTimeString()}`);
|
||||
fetchData(false);
|
||||
}, updateIntervalMs);
|
||||
// Start monitoring status polling
|
||||
monitoringService.startStatusPolling(setMonitoringStatus, 5000);
|
||||
|
||||
// Cleanup function to clear the interval when component unmounts
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
clearInterval(interval);
|
||||
monitoringService.stopStatusPolling();
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
// Set up monitoring status polling
|
||||
useEffect(() => {
|
||||
// Start polling for monitoring status
|
||||
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),
|
||||
futureTimestamp: new Date(item.future_timestamp),
|
||||
currentPower: parseFloat(item.power),
|
||||
predictedPower: parseFloat(item.power_future_min),
|
||||
currentTemp: parseFloat(item.env_temp_cur),
|
||||
predictedTemp: parseFloat(item.env_temp_min),
|
||||
}));
|
||||
|
||||
// Apply sliding window - show only last N records
|
||||
const slidingData = processedData.slice(-windowSize);
|
||||
|
||||
console.log('Processed chart 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) => {
|
||||
const handleTemperatureDecision = async (approval: boolean) => {
|
||||
try {
|
||||
setDecisionLoading(true);
|
||||
const response = await fetch(`${API_BASE_URL}/prom/temperature/decisions?approval=${approval}`, {
|
||||
const response = await fetch(`${config.apiUrl}/prom/temperature/decisions?approval=${approval}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to send temperature decision: ${response.statusText}`);
|
||||
throw new Error(`Failed to ${approval ? 'approve' : 'decline'} temperature change: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -352,18 +109,13 @@ const Temperature = () => {
|
||||
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);
|
||||
console.error('Error with temperature decision:', error);
|
||||
setAlert({
|
||||
open: true,
|
||||
message: error instanceof Error ? error.message : 'Failed to send temperature decision',
|
||||
message: error instanceof Error ? error.message : 'Failed to process temperature change',
|
||||
severity: 'error'
|
||||
});
|
||||
} finally {
|
||||
setDecisionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -371,397 +123,97 @@ const Temperature = () => {
|
||||
setAlert(prev => ({ ...prev, open: false }));
|
||||
};
|
||||
|
||||
// Memoize the transformed data to prevent unnecessary recalculations
|
||||
const { temperatureData, powerData } = useMemo(() => ({
|
||||
temperatureData: data.map(item => ({
|
||||
currentTimestamp: new Date(item.now_timestamp),
|
||||
futureTimestamp: new Date(item.future_timestamp),
|
||||
currentValue: parseFloat(item.env_temp_cur),
|
||||
predictedValue: parseFloat(item.env_temp_min),
|
||||
})),
|
||||
powerData: data.map(item => ({
|
||||
currentTimestamp: new Date(item.now_timestamp),
|
||||
futureTimestamp: new Date(item.future_timestamp),
|
||||
currentValue: parseFloat(item.power),
|
||||
predictedValue: parseFloat(item.power_future_min),
|
||||
})),
|
||||
}), [data]);
|
||||
|
||||
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 } }}>
|
||||
<Box sx={{ flex: 1, textAlign: "center" }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
component="h1"
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '-0.5px',
|
||||
mb: 0.5
|
||||
<Box>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Box sx={{ mb: 2, display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
{[20, 50, 100].map((range) => (
|
||||
<Paper
|
||||
key={range}
|
||||
elevation={0}
|
||||
onClick={() => setTimeRange(range as TimeRange)}
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||
cursor: 'pointer',
|
||||
bgcolor: timeRange === range ? 'primary.main' : 'background.paper',
|
||||
color: timeRange === range ? 'primary.contrastText' : 'text.primary',
|
||||
'&:hover': {
|
||||
bgcolor: timeRange === range ? 'primary.dark' : 'action.hover'
|
||||
},
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
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)' }
|
||||
}
|
||||
{range} points
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid rgba(0, 0, 0, 0.08)' }}>
|
||||
<MonitoringChart
|
||||
data={powerData}
|
||||
height={400}
|
||||
zoomRange={powerZoom}
|
||||
onZoomChange={setPowerZoom}
|
||||
chartTitle="Power Consumption"
|
||||
unit="W"
|
||||
colors={{
|
||||
current: '#2196f3',
|
||||
predicted: '#ff9800'
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
/>
|
||||
</Paper>
|
||||
</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>
|
||||
<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 item xs={12}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<TemperatureControl
|
||||
onApprove={() => handleTemperatureDecision(true)}
|
||||
onDecline={() => handleTemperatureDecision(false)}
|
||||
monitoringStatus={monitoringStatus}
|
||||
disabled={decisionLoading}
|
||||
isLoading={isLoading}
|
||||
hasData={data.length > 0}
|
||||
temperatureFlag={data[data.length - 1]?.flag}
|
||||
/>
|
||||
</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>
|
||||
temperatureFlag={data[data.length - 1]?.flag}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Snackbar for alerts */}
|
||||
<Snackbar
|
||||
open={alert.open}
|
||||
autoHideDuration={6000}
|
||||
@@ -771,7 +223,6 @@ const Temperature = () => {
|
||||
<Alert
|
||||
onClose={handleCloseAlert}
|
||||
severity={alert.severity}
|
||||
variant="filled"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{alert.message}
|
||||
@@ -781,4 +232,4 @@ const Temperature = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Temperature;
|
||||
export default Temperature;
|
||||
Reference in New Issue
Block a user