Compare commits

4 Commits

13 changed files with 2502 additions and 1072 deletions

10
.env.example Normal file
View 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
View File

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

70
package-lock.json generated
View File

@@ -15,11 +15,14 @@
"@mui/material": "^5.15.3",
"@mui/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"
}
}
}

View File

@@ -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",

View 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;

View File

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

View File

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

View File

@@ -0,0 +1,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;

View File

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

View File

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;