feat: Introduce a React-based frontend dashboard with Webpack and Babel for improved UI and development.
This commit is contained in:
37
src/client/App.js
Normal file
37
src/client/App.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { ThemeProvider, createTheme, CssBaseline, AppBar, Toolbar, Typography, Container, Box } from '@mui/material';
|
||||
import Dashboard from './Dashboard';
|
||||
|
||||
const darkTheme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#2c3e50',
|
||||
},
|
||||
background: {
|
||||
default: '#f4f4f9',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider theme={darkTheme}>
|
||||
<CssBaseline />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
AC Infinity Dashboard
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
<Dashboard />
|
||||
</Container>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
92
src/client/ControllerCard.js
Normal file
92
src/client/ControllerCard.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardContent, Divider, Grid, Box, Typography } from '@mui/material';
|
||||
import EnvChart from './EnvChart';
|
||||
import LevelChart from './LevelChart';
|
||||
|
||||
export default function ControllerCard({ controllerName, ports, range }) {
|
||||
const [envData, setEnvData] = useState([]);
|
||||
const [portData, setPortData] = useState({});
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (ports.length === 0) return;
|
||||
|
||||
// Fetch all ports concurrently
|
||||
const promises = ports.map(port =>
|
||||
fetch(`api/history?devName=${encodeURIComponent(controllerName)}&port=${port.port}&range=${range}`)
|
||||
.then(res => res.json())
|
||||
.then(data => ({ port: port.port, data }))
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
const newPortData = {};
|
||||
results.forEach(item => {
|
||||
newPortData[item.port] = item.data;
|
||||
});
|
||||
|
||||
setPortData(newPortData);
|
||||
|
||||
// Use the data from the first port for the Environment Chart
|
||||
// This avoids a redundant network request
|
||||
if (results.length > 0) {
|
||||
setEnvData(results[0].data);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("Fetch error", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial Fetch & Auto-Refresh
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [controllerName, range]); // Depend on range, controllerName changes rarely
|
||||
|
||||
return (
|
||||
<Card sx={{ mb: 4, borderRadius: 2, boxShadow: 3 }}>
|
||||
<CardHeader
|
||||
title={controllerName}
|
||||
titleTypographyProps={{ variant: 'h5', fontWeight: 'bold', color: 'primary.main' }}
|
||||
sx={{ bgcolor: '#e9ecef', borderLeft: '6px solid #2c3e50' }}
|
||||
/>
|
||||
<CardContent>
|
||||
{/* Environment Chart */}
|
||||
<Box sx={{ height: 350, mb: 4 }}>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
Environment (Temp / Humidity)
|
||||
</Typography>
|
||||
<EnvChart data={envData} range={range} />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
{/* Port Grid */}
|
||||
<Grid container spacing={3}>
|
||||
{ports.map((port) => {
|
||||
const isLight = port.port_name && port.port_name.toLowerCase().includes('light');
|
||||
const levelTitle = isLight ? 'Brightness' : 'Fan Speed';
|
||||
const pData = portData[port.port] || [];
|
||||
|
||||
return (
|
||||
<Grid item xs={12} md={6} lg={4} key={port.port}>
|
||||
<Card variant="outlined" sx={{ bgcolor: '#f8f9fa' }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{port.port_name || `Port ${port.port}`}
|
||||
</Typography>
|
||||
<Box sx={{ height: 250 }}>
|
||||
<LevelChart data={pData} isLight={isLight} range={range} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
85
src/client/Dashboard.js
Normal file
85
src/client/Dashboard.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Grid, Typography, Button, ButtonGroup, Box, Alert } from '@mui/material';
|
||||
import ControllerCard from './ControllerCard';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [groupedDevices, setGroupedDevices] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [range, setRange] = useState('day'); // 'day', 'week', 'month'
|
||||
|
||||
const fetchDevices = useCallback(async () => {
|
||||
try {
|
||||
// Robust API Base detection
|
||||
const baseUrl = window.location.pathname.endsWith('/') ? 'api/' : 'api/';
|
||||
// Actually, since we are serving from root or subpath, relative 'api/' is tricky if URL depth changes.
|
||||
// Better to use a relative path that works from the page root.
|
||||
// If page is /ac-dashboard/, fetch is /ac-dashboard/api/devices.
|
||||
|
||||
const res = await fetch('api/devices');
|
||||
if (!res.ok) throw new Error('Failed to fetch devices');
|
||||
|
||||
const devices = await res.json();
|
||||
|
||||
// Group by dev_name
|
||||
const grouped = devices.reduce((acc, dev) => {
|
||||
if (!acc[dev.dev_name]) acc[dev.dev_name] = [];
|
||||
acc[dev.dev_name].push(dev);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
setGroupedDevices(grouped);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices();
|
||||
}, [fetchDevices]);
|
||||
|
||||
// Auto-refresh logic (basic rerender trigger could be added here,
|
||||
// but simpler to let ControllerCard handle data fetching internally based on props)
|
||||
|
||||
if (loading) return <Typography>Loading devices...</Typography>;
|
||||
if (error) return <Alert severity="error">{error}</Alert>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="flex-end" mb={3}>
|
||||
<ButtonGroup variant="contained" aria-label="outlined primary button group">
|
||||
<Button
|
||||
onClick={() => setRange('day')}
|
||||
color={range === 'day' ? 'primary' : 'inherit'}
|
||||
>
|
||||
24 Hours
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setRange('week')}
|
||||
color={range === 'week' ? 'primary' : 'inherit'}
|
||||
>
|
||||
7 Days
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setRange('month')}
|
||||
color={range === 'month' ? 'primary' : 'inherit'}
|
||||
>
|
||||
30 Days
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
|
||||
{Object.entries(groupedDevices).map(([controllerName, ports]) => (
|
||||
<ControllerCard
|
||||
key={controllerName}
|
||||
controllerName={controllerName}
|
||||
ports={ports}
|
||||
range={range}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
98
src/client/EnvChart.js
Normal file
98
src/client/EnvChart.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
export default function EnvChart({ data, range }) {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
const formatDateLabel = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
if (range === 'day') {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }) + ' ' +
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
};
|
||||
|
||||
const chartData = {
|
||||
labels: data.map(d => formatDateLabel(d.timestamp)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Temperature (°C)',
|
||||
data: data.map(d => d.temp_c),
|
||||
borderColor: '#ff6384',
|
||||
backgroundColor: '#ff6384',
|
||||
yAxisID: 'y',
|
||||
tension: 0.3,
|
||||
pointRadius: 1,
|
||||
borderWidth: 2
|
||||
},
|
||||
{
|
||||
label: 'Humidity (%)',
|
||||
data: data.map(d => d.humidity),
|
||||
borderColor: '#36a2eb',
|
||||
backgroundColor: '#36a2eb',
|
||||
yAxisID: 'y1',
|
||||
tension: 0.3,
|
||||
pointRadius: 1,
|
||||
borderWidth: 2
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 12
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: { display: true, text: 'Temp (°C)' },
|
||||
suggestedMin: 15,
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
title: { display: true, text: 'Humidity (%)' },
|
||||
suggestedMin: 30,
|
||||
suggestedMax: 80,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return <Line data={chartData} options={options} />;
|
||||
}
|
||||
79
src/client/LevelChart.js
Normal file
79
src/client/LevelChart.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
export default function LevelChart({ data, isLight, range }) {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
const formatDateLabel = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
if (range === 'day') {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }) + ' ' +
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
};
|
||||
|
||||
const levelLabel = isLight ? 'Brightness' : 'Fan Speed';
|
||||
const levelColor = isLight ? '#ffcd56' : '#9966ff';
|
||||
|
||||
const chartData = {
|
||||
labels: data.map(d => formatDateLabel(d.timestamp)),
|
||||
datasets: [
|
||||
{
|
||||
label: levelLabel,
|
||||
data: data.map(d => d.fan_speed),
|
||||
borderColor: levelColor,
|
||||
backgroundColor: levelColor,
|
||||
stepped: true,
|
||||
borderWidth: 2,
|
||||
pointRadius: 1
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 8
|
||||
}
|
||||
},
|
||||
y: {
|
||||
suggestedMin: 0,
|
||||
suggestedMax: 10,
|
||||
ticks: { stepSize: 1 }
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return <Line data={chartData} options={options} />;
|
||||
}
|
||||
14
src/client/index.html
Normal file
14
src/client/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AC Infinity Dashboard</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
7
src/client/index.js
Normal file
7
src/client/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
root.render(<App />);
|
||||
Reference in New Issue
Block a user