feat: Introduce a React-based frontend dashboard with Webpack and Babel for improved UI and development.
This commit is contained in:
6
.babelrc
Normal file
6
.babelrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
"@babel/preset-env",
|
||||||
|
"@babel/preset-react"
|
||||||
|
]
|
||||||
|
}
|
||||||
4777
package-lock.json
generated
4777
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,13 +213,7 @@ function updateChart(id, ctx, labels, datasets, extraOptions = {}) {
|
|||||||
chartInstances[id].destroy();
|
chartInstances[id].destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
chartInstances[id] = new Chart(ctx, {
|
const defaultOptions = {
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: labels,
|
|
||||||
datasets: datasets.map(ds => ({ ...ds, borderWidth: 2, tension: 0.3, pointRadius: 1 }))
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: {
|
interaction: {
|
||||||
@@ -218,8 +223,34 @@ function updateChart(id, ctx, labels, datasets, extraOptions = {}) {
|
|||||||
plugins: {
|
plugins: {
|
||||||
legend: { position: 'top' }
|
legend: { position: 'top' }
|
||||||
},
|
},
|
||||||
...extraOptions
|
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, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: datasets.map(ds => ({ ...ds, borderWidth: 2, tension: 0.3, pointRadius: 1 }))
|
||||||
|
},
|
||||||
|
options: options
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
34
server.js
34
server.js
@@ -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
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 />);
|
||||||
45
webpack.config.js
Normal file
45
webpack.config.js
Normal 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']
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user