feat: Introduce a React-based frontend dashboard with Webpack and Babel for improved UI and development.

This commit is contained in:
sebseb7
2025-12-20 21:43:17 +01:00
parent 12be2c7bf9
commit bef70e4709
13 changed files with 5336 additions and 20 deletions

6
.babelrc Normal file
View File

@@ -0,0 +1,6 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
]
}

4777
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,25 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.28.5",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/material": "^7.3.6",
"babel-loader": "^10.0.0",
"better-sqlite3": "^12.5.0", "better-sqlite3": "^12.5.0",
"chart.js": "^4.5.1",
"css-loader": "^7.1.2",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^5.2.1" "express": "^5.2.1",
"html-webpack-plugin": "^5.6.5",
"react": "^19.2.3",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.3",
"style-loader": "^4.0.0",
"webpack": "^5.104.1",
"webpack-cli": "^6.0.1",
"webpack-dev-middleware": "^7.4.5"
} }
} }

View File

@@ -11,6 +11,9 @@ let chartInstances = {};
async function init() { async function init() {
setupControls(); setupControls();
await loadDevices(); await loadDevices();
// Auto-refresh data every 60 seconds
setInterval(loadData, 60000);
} }
function setupControls() { function setupControls() {
@@ -58,8 +61,6 @@ async function loadDevices() {
let portsHtml = ''; let portsHtml = '';
ports.forEach(port => { ports.forEach(port => {
const safePortId = `${controllerName}_${port.port}`.replace(/\s+/g, '_'); const safePortId = `${controllerName}_${port.port}`.replace(/\s+/g, '_');
const isLight = port.port_name && port.port_name.toLowerCase().includes('light');
const levelTitle = isLight ? 'Brightness' : 'Fan Speed';
portsHtml += ` portsHtml += `
<div class="port-card"> <div class="port-card">
@@ -135,8 +136,18 @@ async function loadData() {
} }
} }
function formatDateLabel(timestamp) {
const date = new Date(timestamp);
if (currentRange === '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' });
}
}
function renderEnvChart(safeName, data) { function renderEnvChart(safeName, data) {
const labels = data.map(d => new Date(d.timestamp).toLocaleString()); const labels = data.map(d => formatDateLabel(d.timestamp));
const ctx = document.getElementById(`env_${safeName}`).getContext('2d'); const ctx = document.getElementById(`env_${safeName}`).getContext('2d');
updateChart(`env_${safeName}`, ctx, labels, [ updateChart(`env_${safeName}`, ctx, labels, [
@@ -175,7 +186,7 @@ function renderEnvChart(safeName, data) {
} }
function renderLevelChart(safeId, data, isLight) { function renderLevelChart(safeId, data, isLight) {
const labels = data.map(d => new Date(d.timestamp).toLocaleString()); const labels = data.map(d => formatDateLabel(d.timestamp));
const ctx = document.getElementById(`level_${safeId}`).getContext('2d'); const ctx = document.getElementById(`level_${safeId}`).getContext('2d');
const levelLabel = isLight ? 'Brightness' : 'Fan Speed'; const levelLabel = isLight ? 'Brightness' : 'Fan Speed';
const levelColor = isLight ? '#ffcd56' : '#9966ff'; const levelColor = isLight ? '#ffcd56' : '#9966ff';
@@ -202,24 +213,44 @@ function updateChart(id, ctx, labels, datasets, extraOptions = {}) {
chartInstances[id].destroy(); chartInstances[id].destroy();
} }
const defaultOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: { position: 'top' }
},
scales: {
x: {
ticks: {
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 12
}
}
}
};
// Merge scales specifically
const mergedScales = { ...defaultOptions.scales, ...(extraOptions.scales || {}) };
// Merge options
const options = {
...defaultOptions,
...extraOptions,
scales: mergedScales
};
chartInstances[id] = new Chart(ctx, { chartInstances[id] = new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels: labels, labels: labels,
datasets: datasets.map(ds => ({ ...ds, borderWidth: 2, tension: 0.3, pointRadius: 1 })) datasets: datasets.map(ds => ({ ...ds, borderWidth: 2, tension: 0.3, pointRadius: 1 }))
}, },
options: { options: options
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: { position: 'top' }
},
...extraOptions
}
}); });
} }

View File

@@ -3,9 +3,13 @@ import express from 'express';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import webpack from 'webpack';
import webpackDevMiddleware from 'webpack-dev-middleware';
import config from './webpack.config.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const compiler = webpack(config);
// --- CONFIGURATION --- // --- CONFIGURATION ---
const BASE_URL = 'http://www.acinfinityserver.com'; const BASE_URL = 'http://www.acinfinityserver.com';
@@ -197,7 +201,7 @@ async function poll() {
// --- EXPRESS SERVER --- // --- EXPRESS SERVER ---
const app = express(); const app = express();
app.use(express.static(path.join(__dirname, 'public')));
// API: Devices // API: Devices
app.get('/api/devices', (req, res) => { app.get('/api/devices', (req, res) => {
@@ -242,12 +246,38 @@ app.get('/api/history', (req, res) => {
} }
}); });
// Webpack Middleware
// NOTE: We override publicPath to '/' here because Nginx strips the '/ac/' prefix.
// The incoming request for '/ac/bundle.js' becomes '/bundle.js' at this server.
const devMiddleware = webpackDevMiddleware(compiler, {
publicPath: '/',
writeToDisk: false,
});
app.use(devMiddleware);
// Serve index.html for root request (SPA Fallback-ish)
app.get('/', (req, res) => {
// Access index.html from the memory filesystem
// We attempt to read it from the middleware's outputFileSystem
const indexFile = path.join(config.output.path, 'index.html');
const fs = devMiddleware.context.outputFileSystem;
// Simple wait/retry logic could be added here, but usually startup takes a second.
if (fs && fs.existsSync(indexFile)) {
const html = fs.readFileSync(indexFile);
res.set('Content-Type', 'text/html');
res.send(html);
} else {
res.status(202).send('Building... Please refresh in a moment.');
}
});
// Start Server & Daemon // Start Server & Daemon
app.listen(PORT, '127.0.0.1', () => { app.listen(PORT, '127.0.0.1', () => {
console.log(`Dashboard Server running at http://127.0.0.1:${PORT}`); console.log(`Dashboard Server running at http://127.0.0.1:${PORT}`);
// Start Polling Loop // Start Polling Loop
console.log(`Starting AC Infinity Poll Loop (Interval: ${POLL_INTERVAL_MS}ms)`); console.log(`Starting AC Infinity Poll Loop (Interval: ${POLL_INTERVAL_MS}ms)`);
poll(); // Initial run // poll(); // Initial run (optional)
setInterval(poll, POLL_INTERVAL_MS); setInterval(poll, POLL_INTERVAL_MS);
}); });

37
src/client/App.js Normal file
View 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;

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

45
webpack.config.js Normal file
View File

@@ -0,0 +1,45 @@
import path from 'path';
import { fileURLToPath } from 'url';
import HtmlWebpackPlugin from 'html-webpack-plugin';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
mode: 'development',
entry: './src/client/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
publicPath: '/ac/'
},
module: {
rules: [
{
test: /\.m?js/,
resolve: {
fullySpecified: false
}
},
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: './src/client/index.html'
})
],
resolve: {
extensions: ['.js', '.jsx']
}
};