forked from BLC/AyposWeb
tempretur approve decline control added
This commit is contained in:
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=*
|
||||||
@@ -1,441 +0,0 @@
|
|||||||
import React, { useMemo } from 'react';
|
|
||||||
import ReactECharts from 'echarts-for-react';
|
|
||||||
import { useTheme } from '@mui/material/styles';
|
|
||||||
import { Box, Typography } from '@mui/material';
|
|
||||||
fontWeight: 500,
|
|
||||||
color: theme.palette.text.secondary
|
|
||||||
},
|
|
||||||
icon: 'circle',
|
|
||||||
itemHeight: 10,
|
|
||||||
itemWidth: 10,
|
|
||||||
selectedMode: false
|
|
||||||
},t { 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: 'series',
|
|
||||||
itemStyle: {
|
|
||||||
borderWidth: 3,
|
|
||||||
shadowBlur: 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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',
|
|
||||||
title: {
|
|
||||||
text: 'Preventive Maintenance Monitoring',
|
|
||||||
subtext: 'Power consumption with predictive thresholds',
|
|
||||||
textStyle: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
fontFamily: 'Montserrat, sans-serif'
|
|
||||||
},
|
|
||||||
subtextStyle: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
fontFamily: 'Montserrat, sans-serif'
|
|
||||||
},
|
|
||||||
left: '5%',
|
|
||||||
top: 0,
|
|
||||||
padding: [0, 0, 12, 0]
|
|
||||||
},
|
|
||||||
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', '-3% Threshold', '+7% Threshold', '-7% Threshold'],
|
|
||||||
top: 40,
|
|
||||||
textStyle: {
|
|
||||||
fontFamily: 'Montserrat, sans-serif',
|
|
||||||
fontSize: 12,
|
|
||||||
color: theme.palette.text.secondary
|
|
||||||
}
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
left: '5%',
|
|
||||||
right: '5%',
|
|
||||||
bottom: '12%',
|
|
||||||
top: '15%',
|
|
||||||
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: 'Power (W)',
|
|
||||||
nameTextStyle: {
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
fontFamily: 'Montserrat, sans-serif',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
},
|
|
||||||
min: Math.max(0, minValue - valuePadding),
|
|
||||||
max: maxValue + valuePadding,
|
|
||||||
axisLabel: {
|
|
||||||
formatter: '{value}W',
|
|
||||||
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 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={{
|
|
||||||
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) => {
|
|
||||||
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;
|
|
||||||
@@ -1,446 +0,0 @@
|
|||||||
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: 'series',
|
|
||||||
itemStyle: {
|
|
||||||
borderWidth: 3,
|
|
||||||
shadowBlur: 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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',
|
|
||||||
title: {
|
|
||||||
text: 'Preventive Maintenance Monitoring',
|
|
||||||
subtext: 'Power consumption with predictive thresholds',
|
|
||||||
textStyle: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
fontFamily: 'Montserrat, sans-serif'
|
|
||||||
},
|
|
||||||
subtextStyle: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
fontFamily: 'Montserrat, sans-serif'
|
|
||||||
},
|
|
||||||
left: '5%',
|
|
||||||
top: 0,
|
|
||||||
padding: [0, 0, 12, 0]
|
|
||||||
},
|
|
||||||
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', '\u00b13% Threshold', '\u00b17% Threshold'],
|
|
||||||
top: 'top',
|
|
||||||
right: '5%',
|
|
||||||
padding: [8, 16],
|
|
||||||
itemGap: 24,
|
|
||||||
textStyle: {
|
|
||||||
fontFamily: 'Montserrat, sans-serif',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: theme.palette.text.secondary
|
|
||||||
},
|
|
||||||
icon: 'circle',
|
|
||||||
itemHeight: 10,
|
|
||||||
itemWidth: 10,
|
|
||||||
selectedMode: false
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
left: '5%',
|
|
||||||
right: '5%',
|
|
||||||
bottom: '12%',
|
|
||||||
top: '15%',
|
|
||||||
containLabel: true
|
|
||||||
},
|
|
||||||
toolbox: {
|
|
||||||
right: 90,
|
|
||||||
top: 0,
|
|
||||||
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: 20,
|
|
||||||
bottom: 10,
|
|
||||||
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: '\u00b13% 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: '\u00b17% 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={{
|
|
||||||
bgcolor: theme.palette.background.paper,
|
|
||||||
borderRadius: 2,
|
|
||||||
boxShadow: 3,
|
|
||||||
overflow: 'hidden',
|
|
||||||
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;
|
|
||||||
279
src/components/Charts/TemperatureControl.tsx
Normal file
279
src/components/Charts/TemperatureControl.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
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>
|
||||||
|
<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;
|
||||||
@@ -4,10 +4,24 @@ const getApiUrl = (): string => {
|
|||||||
if (import.meta.env.PROD) {
|
if (import.meta.env.PROD) {
|
||||||
return '/api';
|
return '/api';
|
||||||
}
|
}
|
||||||
// In development, use the direct URL
|
// In development, use the direct URL from environment variable
|
||||||
return import.meta.env.VITE_API_URL || 'http://aypos-api.blc-css.com';
|
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 = {
|
export const config = {
|
||||||
apiUrl: getApiUrl(),
|
apiUrl: getApiUrl(),
|
||||||
|
vercelUrl: getVercelUrl(),
|
||||||
|
allowedHosts: getAllowedHosts(),
|
||||||
|
isDevelopment: import.meta.env.DEV,
|
||||||
|
isProduction: import.meta.env.PROD,
|
||||||
|
corsOrigin: import.meta.env.CORS_ORIGIN || '*'
|
||||||
} as const;
|
} as const;
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||||
import { Box, Paper, Grid } from '@mui/material';
|
import { Box, Paper, Grid, Alert, Snackbar } from '@mui/material';
|
||||||
import { config } from '../config/env';
|
import { config } from '../config/env';
|
||||||
import MonitoringChart from '../components/Charts/MonitoringChart';
|
import MonitoringChart from '../components/Charts/MonitoringChart';
|
||||||
|
import TemperatureControl from '../components/Charts/TemperatureControl';
|
||||||
|
import { monitoringService } from '../services/monitoringService';
|
||||||
|
import { MonitoringStatus } from '../types/monitoring';
|
||||||
|
|
||||||
interface ChartData {
|
interface ChartData {
|
||||||
power: string;
|
power: string;
|
||||||
@@ -20,7 +23,13 @@ const Temperature = () => {
|
|||||||
const [timeRange, setTimeRange] = useState<TimeRange>(20);
|
const [timeRange, setTimeRange] = useState<TimeRange>(20);
|
||||||
const [powerZoom, setPowerZoom] = useState<[number, number]>([0, 100]);
|
const [powerZoom, setPowerZoom] = useState<[number, number]>([0, 100]);
|
||||||
const [tempZoom, setTempZoom] = 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 [alert, setAlert] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
severity: 'success'
|
||||||
|
});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const lastFetchRef = useRef<number>(0);
|
const lastFetchRef = useRef<number>(0);
|
||||||
const THROTTLE_INTERVAL = 2000; // Minimum 2 seconds between updates
|
const THROTTLE_INTERVAL = 2000; // Minimum 2 seconds between updates
|
||||||
@@ -70,9 +79,50 @@ const Temperature = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
const interval = setInterval(fetchData, 5000);
|
const interval = setInterval(fetchData, 5000);
|
||||||
return () => clearInterval(interval);
|
|
||||||
|
// Start monitoring status polling
|
||||||
|
monitoringService.startStatusPolling(setMonitoringStatus, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
monitoringService.stopStatusPolling();
|
||||||
|
};
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleTemperatureDecision = async (approval: boolean) => {
|
||||||
|
try {
|
||||||
|
setDecisionLoading(true);
|
||||||
|
const response = await fetch(`${config.apiUrl}/prom/temperature/decisions?approval=${approval}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to ${approval ? 'approve' : 'decline'} temperature change: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
setAlert({
|
||||||
|
open: true,
|
||||||
|
message: result.message || `Temperature change ${approval ? 'approved' : 'declined'} successfully`,
|
||||||
|
severity: 'success'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error with temperature decision:', error);
|
||||||
|
setAlert({
|
||||||
|
open: true,
|
||||||
|
message: error instanceof Error ? error.message : 'Failed to process temperature change',
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseAlert = () => {
|
||||||
|
setAlert(prev => ({ ...prev, open: false }));
|
||||||
|
};
|
||||||
|
|
||||||
// Memoize the transformed data to prevent unnecessary recalculations
|
// Memoize the transformed data to prevent unnecessary recalculations
|
||||||
const { temperatureData, powerData } = useMemo(() => ({
|
const { temperatureData, powerData } = useMemo(() => ({
|
||||||
temperatureData: data.map(item => ({
|
temperatureData: data.map(item => ({
|
||||||
@@ -90,66 +140,97 @@ const Temperature = () => {
|
|||||||
}), [data]);
|
}), [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 2 }}>
|
<Box>
|
||||||
<Box sx={{ mb: 2, display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
<Box sx={{ p: 2 }}>
|
||||||
{[20, 50, 100].map((range) => (
|
<Box sx={{ mb: 2, display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||||
<Paper
|
{[20, 50, 100].map((range) => (
|
||||||
key={range}
|
<Paper
|
||||||
elevation={0}
|
key={range}
|
||||||
onClick={() => setTimeRange(range as TimeRange)}
|
elevation={0}
|
||||||
sx={{
|
onClick={() => setTimeRange(range as TimeRange)}
|
||||||
px: 2,
|
sx={{
|
||||||
py: 1,
|
px: 2,
|
||||||
border: '1px solid rgba(0, 0, 0, 0.08)',
|
py: 1,
|
||||||
cursor: 'pointer',
|
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||||
bgcolor: timeRange === range ? 'primary.main' : 'background.paper',
|
cursor: 'pointer',
|
||||||
color: timeRange === range ? 'primary.contrastText' : 'text.primary',
|
bgcolor: timeRange === range ? 'primary.main' : 'background.paper',
|
||||||
'&:hover': {
|
color: timeRange === range ? 'primary.contrastText' : 'text.primary',
|
||||||
bgcolor: timeRange === range ? 'primary.dark' : 'action.hover'
|
'&:hover': {
|
||||||
},
|
bgcolor: timeRange === range ? 'primary.dark' : 'action.hover'
|
||||||
transition: 'all 0.2s'
|
},
|
||||||
}}
|
transition: 'all 0.2s'
|
||||||
>
|
}}
|
||||||
{range} points
|
>
|
||||||
</Paper>
|
{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'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Paper elevation={0} sx={{ p: 2, border: '1px solid rgba(0, 0, 0, 0.08)' }}>
|
||||||
|
<MonitoringChart
|
||||||
|
data={temperatureData}
|
||||||
|
height={400}
|
||||||
|
zoomRange={tempZoom}
|
||||||
|
onZoomChange={setTempZoom}
|
||||||
|
chartTitle="Environmental Temperature"
|
||||||
|
unit="°C"
|
||||||
|
colors={{
|
||||||
|
current: '#028a4a',
|
||||||
|
predicted: '#ed6c02'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
<Grid 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>
|
||||||
|
temperatureFlag={data[data.length - 1]?.flag}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
<Grid container spacing={2}>
|
<Snackbar
|
||||||
<Grid item xs={12}>
|
open={alert.open}
|
||||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid rgba(0, 0, 0, 0.08)' }}>
|
autoHideDuration={6000}
|
||||||
<MonitoringChart
|
onClose={handleCloseAlert}
|
||||||
data={powerData}
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
height={400}
|
>
|
||||||
zoomRange={powerZoom}
|
<Alert
|
||||||
onZoomChange={setPowerZoom}
|
onClose={handleCloseAlert}
|
||||||
chartTitle="Power Consumption"
|
severity={alert.severity}
|
||||||
unit="W"
|
sx={{ width: '100%' }}
|
||||||
colors={{
|
>
|
||||||
current: '#2196f3',
|
{alert.message}
|
||||||
predicted: '#ff9800'
|
</Alert>
|
||||||
}}
|
</Snackbar>
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid rgba(0, 0, 0, 0.08)' }}>
|
|
||||||
<MonitoringChart
|
|
||||||
data={temperatureData}
|
|
||||||
height={400}
|
|
||||||
zoomRange={tempZoom}
|
|
||||||
onZoomChange={setTempZoom}
|
|
||||||
chartTitle="Environmental Temperature"
|
|
||||||
unit="°C"
|
|
||||||
colors={{
|
|
||||||
current: '#028a4a',
|
|
||||||
predicted: '#ed6c02'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Temperature;
|
export default Temperature;
|
||||||
Reference in New Issue
Block a user