Genesis
This commit is contained in:
6
.babelrc
Normal file
6
.babelrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
"@babel/preset-env",
|
||||||
|
"@babel/preset-react"
|
||||||
|
]
|
||||||
|
}
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
ac_data.db
|
||||||
|
dashboard_log.txt
|
||||||
195
manage-users.js
Normal file
195
manage-users.js
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { input, password, select, confirm } from '@inquirer/prompts';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const DB_FILE = 'ac_data.db';
|
||||||
|
|
||||||
|
const db = new Database(DB_FILE);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertUser = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)');
|
||||||
|
const getAllUsers = db.prepare('SELECT id, username, role, created_at FROM users ORDER BY id');
|
||||||
|
const getUserById = db.prepare('SELECT * FROM users WHERE id = ?');
|
||||||
|
const updateUserRole = db.prepare('UPDATE users SET role = ? WHERE id = ?');
|
||||||
|
const updateUserPassword = db.prepare('UPDATE users SET password_hash = ? WHERE id = ?');
|
||||||
|
const deleteUser = db.prepare('DELETE FROM users WHERE id = ?');
|
||||||
|
|
||||||
|
console.log('\n╔════════════════════════════════════╗');
|
||||||
|
console.log('║ 🔐 User Manager - AC Dashboard ║');
|
||||||
|
console.log('╚════════════════════════════════════╝\n');
|
||||||
|
|
||||||
|
async function listUsers() {
|
||||||
|
const users = getAllUsers.all();
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log(' No users found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(' ID │ Username │ Role │ Created');
|
||||||
|
console.log(' ────┼────────────────┼────────┼─────────────────────');
|
||||||
|
users.forEach(u => {
|
||||||
|
const id = String(u.id).padStart(3);
|
||||||
|
const name = u.username.padEnd(14);
|
||||||
|
const role = u.role.padEnd(6);
|
||||||
|
const date = u.created_at?.slice(0, 16) || 'N/A';
|
||||||
|
const roleColor = u.role === 'admin' ? '\x1b[35m' : '\x1b[33m';
|
||||||
|
console.log(` ${id} │ ${name} │ ${roleColor}${role}\x1b[0m │ ${date}`);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser() {
|
||||||
|
const username = await input({
|
||||||
|
message: 'Username:',
|
||||||
|
validate: v => v.length >= 3 || 'Min 3 characters'
|
||||||
|
});
|
||||||
|
|
||||||
|
const pwd = await password({
|
||||||
|
message: 'Password:',
|
||||||
|
mask: '*',
|
||||||
|
validate: v => v.length >= 4 || 'Min 4 characters'
|
||||||
|
});
|
||||||
|
|
||||||
|
const role = await select({
|
||||||
|
message: 'Role:',
|
||||||
|
choices: [
|
||||||
|
{ name: '👤 user', value: 'user' },
|
||||||
|
{ name: '👑 admin', value: 'admin' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hash = await bcrypt.hash(pwd, 10);
|
||||||
|
insertUser.run(username, hash, role);
|
||||||
|
console.log(`\n✅ User "${username}" created as ${role}\n`);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||||
|
console.log(`\n❌ User "${username}" already exists\n`);
|
||||||
|
} else throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editUser() {
|
||||||
|
const users = getAllUsers.all();
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log(' No users to edit.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = await select({
|
||||||
|
message: 'Select user to edit:',
|
||||||
|
choices: users.map(u => ({
|
||||||
|
name: `${u.username} (${u.role})`,
|
||||||
|
value: u.id
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
const action = await select({
|
||||||
|
message: 'What to change?',
|
||||||
|
choices: [
|
||||||
|
{ name: '🔑 Change password', value: 'password' },
|
||||||
|
{ name: '👤 Change role', value: 'role' },
|
||||||
|
{ name: '← Back', value: 'back' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (action === 'back') return;
|
||||||
|
|
||||||
|
if (action === 'password') {
|
||||||
|
const pwd = await password({
|
||||||
|
message: 'New password:',
|
||||||
|
mask: '*',
|
||||||
|
validate: v => v.length >= 4 || 'Min 4 characters'
|
||||||
|
});
|
||||||
|
const hash = await bcrypt.hash(pwd, 10);
|
||||||
|
updateUserPassword.run(hash, userId);
|
||||||
|
console.log('\n✅ Password updated\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'role') {
|
||||||
|
const user = getUserById.get(userId);
|
||||||
|
const newRole = await select({
|
||||||
|
message: 'New role:',
|
||||||
|
choices: [
|
||||||
|
{ name: '👤 user', value: 'user' },
|
||||||
|
{ name: '👑 admin', value: 'admin' }
|
||||||
|
],
|
||||||
|
default: user.role
|
||||||
|
});
|
||||||
|
updateUserRole.run(newRole, userId);
|
||||||
|
console.log(`\n✅ Role changed to ${newRole}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUser() {
|
||||||
|
const users = getAllUsers.all();
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log(' No users to delete.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = await select({
|
||||||
|
message: 'Select user to delete:',
|
||||||
|
choices: users.map(u => ({
|
||||||
|
name: `${u.username} (${u.role})`,
|
||||||
|
value: u.id
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = getUserById.get(userId);
|
||||||
|
const confirmed = await confirm({
|
||||||
|
message: `Delete user "${user.username}"?`,
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmed) {
|
||||||
|
deleteUser.run(userId);
|
||||||
|
console.log(`\n✅ User "${user.username}" deleted\n`);
|
||||||
|
} else {
|
||||||
|
console.log('\n❌ Cancelled\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const action = await select({
|
||||||
|
message: 'What would you like to do?',
|
||||||
|
choices: [
|
||||||
|
{ name: '📋 List users', value: 'list' },
|
||||||
|
{ name: '➕ Create user', value: 'create' },
|
||||||
|
{ name: '✏️ Edit user', value: 'edit' },
|
||||||
|
{ name: '🗑️ Delete user', value: 'delete' },
|
||||||
|
{ name: '🚪 Exit', value: 'exit' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (action === 'exit') {
|
||||||
|
console.log('Bye!\n');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'list') await listUsers();
|
||||||
|
if (action === 'create') await createUser();
|
||||||
|
if (action === 'edit') await editUser();
|
||||||
|
if (action === 'delete') await removeUser();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'ExitPromptError') {
|
||||||
|
console.log('\nBye!\n');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
7131
package-lock.json
generated
Normal file
7131
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "ac-infinity-test",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test script for AC Infinity API",
|
||||||
|
"main": "test-ac-api.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"start": "node test-ac-api.js"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"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",
|
||||||
|
"@inquirer/prompts": "^8.1.0",
|
||||||
|
"@mui/material": "^7.3.6",
|
||||||
|
"babel-loader": "^10.0.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"better-sqlite3": "^12.5.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"css-loader": "^7.1.2",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"html-webpack-plugin": "^5.6.5",
|
||||||
|
"ink": "^6.5.1",
|
||||||
|
"ink-text-input": "^6.0.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
257
public/dashboard.js
Normal file
257
public/dashboard.js
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
const scriptPath = document.currentScript ? document.currentScript.src : window.location.href;
|
||||||
|
const API_BASE = scriptPath.substring(0, scriptPath.lastIndexOf('/') + 1) + 'api/';
|
||||||
|
|
||||||
|
// Store device info globally to simplify reload
|
||||||
|
let filteredDevices = [];
|
||||||
|
// Grouped: { "ControllerName": [ {dev info...}, {dev info...} ] }
|
||||||
|
let groupedDevices = {};
|
||||||
|
let currentRange = 'day';
|
||||||
|
let chartInstances = {};
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
setupControls();
|
||||||
|
await loadDevices();
|
||||||
|
|
||||||
|
// Auto-refresh data every 60 seconds
|
||||||
|
setInterval(loadData, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupControls() {
|
||||||
|
// Range Buttons
|
||||||
|
document.querySelectorAll('.range-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
e.target.classList.add('active');
|
||||||
|
currentRange = e.target.dataset.range;
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and setup device containers (Grouped by Controller)
|
||||||
|
*/
|
||||||
|
async function loadDevices() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}devices`);
|
||||||
|
const rawDevices = await res.json();
|
||||||
|
const container = document.getElementById('devicesContainer');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (rawDevices.length === 0) {
|
||||||
|
container.innerHTML = '<p>No devices found.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by Controller Name
|
||||||
|
groupedDevices = rawDevices.reduce((acc, dev) => {
|
||||||
|
if (!acc[dev.dev_name]) acc[dev.dev_name] = [];
|
||||||
|
acc[dev.dev_name].push(dev);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Create Section per Controller
|
||||||
|
for (const [controllerName, ports] of Object.entries(groupedDevices)) {
|
||||||
|
const safeControllerName = controllerName.replace(/\s+/g, '_');
|
||||||
|
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'controller-section';
|
||||||
|
|
||||||
|
// Generate Ports HTML
|
||||||
|
let portsHtml = '';
|
||||||
|
ports.forEach(port => {
|
||||||
|
const safePortId = `${controllerName}_${port.port}`.replace(/\s+/g, '_');
|
||||||
|
|
||||||
|
portsHtml += `
|
||||||
|
<div class="port-card">
|
||||||
|
<h4>${port.port_name || 'Port ' + port.port}</h4>
|
||||||
|
<div class="canvas-container small">
|
||||||
|
<canvas id="level_${safePortId}"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
section.innerHTML = `
|
||||||
|
<div class="controller-header">
|
||||||
|
<h2>${controllerName}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="environment-row">
|
||||||
|
<div class="chart-wrapper wide">
|
||||||
|
<h3>Environment (Temp / Humidity)</h3>
|
||||||
|
<div class="canvas-container">
|
||||||
|
<canvas id="env_${safeControllerName}"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ports-grid">
|
||||||
|
${portsHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger initial data load
|
||||||
|
loadData();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load devices", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch data and render
|
||||||
|
*/
|
||||||
|
async function loadData() {
|
||||||
|
for (const [controllerName, ports] of Object.entries(groupedDevices)) {
|
||||||
|
const safeControllerName = controllerName.replace(/\s+/g, '_');
|
||||||
|
|
||||||
|
// 1. Fetch Environment Data (Use first port as representative source)
|
||||||
|
if (ports.length > 0) {
|
||||||
|
const firstPort = ports[0];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}history?devName=${encodeURIComponent(controllerName)}&port=${firstPort.port}&range=${currentRange}`);
|
||||||
|
const data = await res.json();
|
||||||
|
renderEnvChart(safeControllerName, data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to load env data for ${controllerName}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch Level Data for EACH port
|
||||||
|
for (const port of ports) {
|
||||||
|
const safePortId = `${controllerName}_${port.port}`.replace(/\s+/g, '_');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}history?devName=${encodeURIComponent(controllerName)}&port=${port.port}&range=${currentRange}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const isLight = port.port_name && port.port_name.toLowerCase().includes('light');
|
||||||
|
renderLevelChart(safePortId, data, isLight);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to load level data for ${controllerName}:${port.port}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const labels = data.map(d => formatDateLabel(d.timestamp));
|
||||||
|
const ctx = document.getElementById(`env_${safeName}`).getContext('2d');
|
||||||
|
|
||||||
|
updateChart(`env_${safeName}`, ctx, labels, [
|
||||||
|
{
|
||||||
|
label: 'Temperature (°C)',
|
||||||
|
data: data.map(d => d.temp_c),
|
||||||
|
borderColor: '#ff6384',
|
||||||
|
yAxisID: 'y'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Humidity (%)',
|
||||||
|
data: data.map(d => d.humidity),
|
||||||
|
borderColor: '#36a2eb',
|
||||||
|
yAxisID: 'y1'
|
||||||
|
}
|
||||||
|
], {
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'left',
|
||||||
|
suggestedMin: 15,
|
||||||
|
title: { display: true, text: 'Temp (°C)' }
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'right',
|
||||||
|
grid: { drawOnChartArea: false },
|
||||||
|
suggestedMin: 30,
|
||||||
|
suggestedMax: 80,
|
||||||
|
title: { display: true, text: 'Humidity (%)' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLevelChart(safeId, data, isLight) {
|
||||||
|
const labels = data.map(d => formatDateLabel(d.timestamp));
|
||||||
|
const ctx = document.getElementById(`level_${safeId}`).getContext('2d');
|
||||||
|
const levelLabel = isLight ? 'Brightness' : 'Fan Speed';
|
||||||
|
const levelColor = isLight ? '#ffcd56' : '#9966ff';
|
||||||
|
|
||||||
|
updateChart(`level_${safeId}`, ctx, labels, [{
|
||||||
|
label: levelLabel,
|
||||||
|
data: data.map(d => d.fan_speed),
|
||||||
|
borderColor: levelColor,
|
||||||
|
backgroundColor: levelColor,
|
||||||
|
stepped: true
|
||||||
|
}], {
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
suggestedMin: 0,
|
||||||
|
suggestedMax: 10,
|
||||||
|
ticks: { stepSize: 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChart(id, ctx, labels, datasets, extraOptions = {}) {
|
||||||
|
if (chartInstances[id]) {
|
||||||
|
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, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: datasets.map(ds => ({ ...ds, borderWidth: 2, tension: 0.3, pointRadius: 1 }))
|
||||||
|
},
|
||||||
|
options: options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
36
public/index.html
Normal file
36
public/index.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AC Inf</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>AC Inf</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<!-- Device Select Removed -->
|
||||||
|
|
||||||
|
<div class="time-range">
|
||||||
|
<button class="range-btn active" data-range="day">24 Hours</button>
|
||||||
|
<button class="range-btn" data-range="week">7 Days</button>
|
||||||
|
<button class="range-btn" data-range="month">30 Days</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="devicesContainer" class="devices-container">
|
||||||
|
<!-- Dynamic Content Will Be Loaded Here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="dashboard.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
202
public/style.css
Normal file
202
public/style.css
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
:root {
|
||||||
|
--bg-color: #f4f4f9;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--text-color: #333;
|
||||||
|
--accent-color: #2c974b;
|
||||||
|
--border-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--card-bg);
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-range {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--accent-color);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-btn.active,
|
||||||
|
.range-btn:hover {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-wrapper {
|
||||||
|
background: var(--card-bg);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 300px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multi-Device Layout */
|
||||||
|
.device-section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-header {
|
||||||
|
background: #e9ecef;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-left: 5px solid #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 400px;
|
||||||
|
/* Ensure 2 columns on wide screens */
|
||||||
|
background: white;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-container {
|
||||||
|
position: relative;
|
||||||
|
height: 300px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-divider {
|
||||||
|
border: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: #ddd;
|
||||||
|
margin: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Range Buttons Active State */
|
||||||
|
.range-btn.active {
|
||||||
|
background-color: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grouped Controller Layout */
|
||||||
|
.controller-section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller-header {
|
||||||
|
border-bottom: 2px solid #f0f0f0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Environment Chart (Full Width) */
|
||||||
|
.environment-row {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-wrapper.wide {
|
||||||
|
width: 100%;
|
||||||
|
min-width: unset;
|
||||||
|
box-shadow: none;
|
||||||
|
/* Inside controller card */
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ports Grid */
|
||||||
|
.ports-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-card {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-card h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-container.small {
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override chart height for small containers */
|
||||||
|
.port-card canvas {
|
||||||
|
height: 200px !important;
|
||||||
|
}
|
||||||
355
server.js
Normal file
355
server.js
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import express from 'express';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import webpack from 'webpack';
|
||||||
|
import webpackDevMiddleware from 'webpack-dev-middleware';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import config from './webpack.config.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const compiler = webpack(config);
|
||||||
|
|
||||||
|
// --- CONFIGURATION ---
|
||||||
|
const BASE_URL = 'http://www.acinfinityserver.com';
|
||||||
|
const USER_AGENT = 'ACController/1.9.7 (com.acinfinity.humiture; build:533; iOS 18.5.0) Alamofire/5.10.2';
|
||||||
|
const POLL_INTERVAL_MS = 60000; // 60 seconds
|
||||||
|
const DB_FILE = 'ac_data.db';
|
||||||
|
const PORT = 3905;
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
||||||
|
|
||||||
|
// Device Type Mapping
|
||||||
|
const DEVICE_TYPES = {
|
||||||
|
1: 'Outlet',
|
||||||
|
3: 'Fan',
|
||||||
|
7: 'Light'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check Credentials
|
||||||
|
if (!process.env.AC_EMAIL || !process.env.AC_PASSWORD) {
|
||||||
|
console.error('Error: AC_EMAIL and AC_PASSWORD must be set in .env file');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DATABASE SETUP ---
|
||||||
|
// Note: Opened in Read/Write mode (default)
|
||||||
|
const db = new Database(DB_FILE);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS readings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
dev_id TEXT,
|
||||||
|
dev_name TEXT,
|
||||||
|
port INTEGER,
|
||||||
|
port_name TEXT,
|
||||||
|
temp_c REAL,
|
||||||
|
humidity REAL,
|
||||||
|
vpd REAL,
|
||||||
|
fan_speed INTEGER,
|
||||||
|
on_speed INTEGER,
|
||||||
|
off_speed INTEGER
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertStmt = db.prepare(`
|
||||||
|
INSERT INTO readings (dev_id, dev_name, port, port_name, temp_c, humidity, vpd, fan_speed, on_speed, off_speed)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const getUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?');
|
||||||
|
|
||||||
|
// --- AC INFINITY API LOGIC ---
|
||||||
|
let token = null;
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
console.log('Logging in...');
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('appEmail', process.env.AC_EMAIL);
|
||||||
|
params.append('appPasswordl', process.env.AC_PASSWORD);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/api/user/appUserLogin`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'User-Agent': USER_AGENT
|
||||||
|
},
|
||||||
|
body: params
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.code === 200) {
|
||||||
|
console.log('Login successful.');
|
||||||
|
return data.data.appId;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Login failed: ${data.msg} (${data.code})`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDeviceList(authToken) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('userId', authToken);
|
||||||
|
|
||||||
|
const response = await fetch(`${BASE_URL}/api/user/devInfoListAll`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
'token': authToken,
|
||||||
|
'phoneType': '1',
|
||||||
|
'appVersion': '1.9.7'
|
||||||
|
},
|
||||||
|
body: params
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.code === 200) return data.data || [];
|
||||||
|
throw new Error(`Get device list failed: ${data.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDeviceModeSettings(authToken, devId, port) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('devId', devId);
|
||||||
|
params.append('port', port.toString());
|
||||||
|
|
||||||
|
const response = await fetch(`${BASE_URL}/api/dev/getdevModeSettingList`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
'token': authToken,
|
||||||
|
'phoneType': '1',
|
||||||
|
'appVersion': '1.9.7',
|
||||||
|
'minversion': '3.5'
|
||||||
|
},
|
||||||
|
body: params
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.code === 200) return data.data;
|
||||||
|
console.warn(`Failed to get settings for ${devId}: ${data.msg}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
if (!token) {
|
||||||
|
token = await login();
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = await getDeviceList(token);
|
||||||
|
console.log(`[${new Date().toISOString()}] Data Fetch: Found ${devices.length} controllers.`);
|
||||||
|
|
||||||
|
for (const device of devices) {
|
||||||
|
const ports = device.deviceInfo && device.deviceInfo.ports ? device.deviceInfo.ports : [];
|
||||||
|
|
||||||
|
if (ports.length === 0) {
|
||||||
|
console.warn(`Device ${device.devName} has no ports info.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const portInfo of ports) {
|
||||||
|
// Filter by online status
|
||||||
|
if (portInfo.online === 1) {
|
||||||
|
const port = portInfo.port;
|
||||||
|
const settings = await getDeviceModeSettings(token, device.devId, port);
|
||||||
|
|
||||||
|
if (settings) {
|
||||||
|
const tempC = settings.temperature ? settings.temperature / 100 : null;
|
||||||
|
const hum = settings.humidity ? settings.humidity / 100 : null;
|
||||||
|
const vpd = settings.vpdnums ? settings.vpdnums / 100 : null;
|
||||||
|
|
||||||
|
// Determine Port Name
|
||||||
|
let portName = portInfo.portName;
|
||||||
|
if (!portName || portName.startsWith('Port ')) {
|
||||||
|
const typeName = DEVICE_TYPES[settings.atType];
|
||||||
|
if (typeName) {
|
||||||
|
portName = typeName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
insertStmt.run(
|
||||||
|
device.devId,
|
||||||
|
device.devName,
|
||||||
|
port,
|
||||||
|
portName,
|
||||||
|
tempC,
|
||||||
|
hum,
|
||||||
|
vpd,
|
||||||
|
settings.speak,
|
||||||
|
settings.onSpead,
|
||||||
|
settings.offSpead
|
||||||
|
);
|
||||||
|
|
||||||
|
let label = 'Level';
|
||||||
|
if (portName === 'Fan') label = 'Fan Speed';
|
||||||
|
if (portName === 'Light') label = 'Brightness';
|
||||||
|
|
||||||
|
console.log(`Saved reading for ${device.devName} (${portName}): ${tempC}°C, ${hum}%, ${label}: ${settings.speak}/10`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Polling error:', error.message);
|
||||||
|
token = null; // Reset token to force re-login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- EXPRESS SERVER ---
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Auth: Login
|
||||||
|
app.post('/api/auth/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ error: 'Username and password required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = getUserByUsername.get(username);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, user.password_hash);
|
||||||
|
if (!valid) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ id: user.id, username: user.username, role: user.role },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '7d' }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ token, user: { username: user.username, role: user.role } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth: Get current user
|
||||||
|
app.get('/api/auth/me', (req, res) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
|
|
||||||
|
res.json({ user: { username: decoded.username, role: decoded.role } });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||||
|
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Devices
|
||||||
|
app.get('/api/devices', (req, res) => {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT DISTINCT dev_name, port, port_name
|
||||||
|
FROM readings
|
||||||
|
ORDER BY dev_name, port
|
||||||
|
`);
|
||||||
|
const rows = stmt.all();
|
||||||
|
res.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: History
|
||||||
|
app.get('/api/history', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { devName, port, range } = req.query;
|
||||||
|
if (!devName || !port) return res.status(400).json({ error: 'Missing devName or port' });
|
||||||
|
|
||||||
|
let timeFilter;
|
||||||
|
switch (range) {
|
||||||
|
case 'week': timeFilter = "-7 days"; break;
|
||||||
|
case 'month': timeFilter = "-30 days"; break;
|
||||||
|
case 'day': default: timeFilter = "-24 hours"; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT timestamp || 'Z' as timestamp, temp_c, humidity, vpd, fan_speed, on_speed
|
||||||
|
FROM readings
|
||||||
|
WHERE dev_name = ? AND port = ? AND timestamp >= datetime('now', ?)
|
||||||
|
ORDER BY timestamp ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const rows = stmt.all(devName, parseInt(port, 10), timeFilter);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
headers: (req, res, context) => {
|
||||||
|
// Set cache headers for hashed bundle files (immutable)
|
||||||
|
if (req.url && req.url.match(/\.[a-f0-9]{8,}\.(js|css)$/i)) {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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
|
||||||
|
app.listen(PORT, '127.0.0.1', () => {
|
||||||
|
console.log(`Dashboard Server running at http://127.0.0.1:${PORT}`);
|
||||||
|
|
||||||
|
// Start Polling Loop
|
||||||
|
console.log(`Starting AC Infinity Poll Loop (Interval: ${POLL_INTERVAL_MS}ms)`);
|
||||||
|
// poll(); // Initial run (optional)
|
||||||
|
setInterval(poll, POLL_INTERVAL_MS);
|
||||||
|
});
|
||||||
131
src/client/App.js
Normal file
131
src/client/App.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ThemeProvider, createTheme, CssBaseline, AppBar, Toolbar, Typography, Container, Box, Button, Chip } from '@mui/material';
|
||||||
|
import Dashboard from './Dashboard';
|
||||||
|
import RuleManager from './RuleManager';
|
||||||
|
import LoginDialog from './LoginDialog';
|
||||||
|
import { AuthProvider, useAuth } from './AuthContext';
|
||||||
|
|
||||||
|
// Gruvbox Dark color palette
|
||||||
|
const gruvboxDark = {
|
||||||
|
bg0: '#282828', // background
|
||||||
|
bg1: '#3c3836', // lighter background (cards)
|
||||||
|
bg2: '#504945', // selection / borders
|
||||||
|
fg: '#ebdbb2', // foreground
|
||||||
|
fg2: '#d5c4a1', // secondary text
|
||||||
|
aqua: '#8ec07c', // primary accent
|
||||||
|
orange: '#fe8019', // secondary accent
|
||||||
|
red: '#fb4934',
|
||||||
|
green: '#b8bb26',
|
||||||
|
yellow: '#fabd2f',
|
||||||
|
blue: '#83a598',
|
||||||
|
purple: '#d3869b',
|
||||||
|
};
|
||||||
|
|
||||||
|
const darkTheme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'dark',
|
||||||
|
primary: {
|
||||||
|
main: gruvboxDark.aqua,
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: gruvboxDark.orange,
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: gruvboxDark.bg0,
|
||||||
|
paper: gruvboxDark.bg1,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: gruvboxDark.fg,
|
||||||
|
secondary: gruvboxDark.fg2,
|
||||||
|
},
|
||||||
|
divider: gruvboxDark.bg2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const { user, loading, login, logout, isAuthenticated, isAdmin } = useAuth();
|
||||||
|
const [showLogin, setShowLogin] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
|
<AppBar position="static">
|
||||||
|
<Toolbar>
|
||||||
|
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||||
|
Tischlerei Dashboard
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<Chip
|
||||||
|
label={user.username}
|
||||||
|
color={isAdmin ? 'secondary' : 'default'}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
...(isAdmin && {
|
||||||
|
background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)'
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isAdmin && (
|
||||||
|
<Chip
|
||||||
|
label="ADMIN"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: gruvboxDark.purple,
|
||||||
|
color: gruvboxDark.bg0,
|
||||||
|
fontWeight: 700
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
color="inherit"
|
||||||
|
onClick={logout}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => setShowLogin(true)}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
sx={{ borderColor: gruvboxDark.aqua }}
|
||||||
|
>
|
||||||
|
🔐 Admin Login
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
{/* Dashboard is always visible to everyone */}
|
||||||
|
<Dashboard />
|
||||||
|
|
||||||
|
{/* Rule Manager only visible to logged-in admins */}
|
||||||
|
{isAdmin && <RuleManager />}
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{/* Login dialog - shown on demand */}
|
||||||
|
<LoginDialog
|
||||||
|
open={showLogin}
|
||||||
|
onClose={() => setShowLogin(false)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={darkTheme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<AuthProvider>
|
||||||
|
<AppContent />
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
79
src/client/AuthContext.js
Normal file
79
src/client/AuthContext.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Check for existing session on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/auth/me', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setUser(data.user);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth check failed:', error);
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (username, password) => {
|
||||||
|
const res = await fetch('api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || 'Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
localStorage.setItem('authToken', data.token);
|
||||||
|
setUser(data.user);
|
||||||
|
return data.user;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isAdmin: user?.role === 'admin'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
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: 'background.paper', borderLeft: '6px solid', borderLeftColor: 'primary.main' }}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
{/* Environment Chart */}
|
||||||
|
<Box sx={{ height: 350, mb: 6 }}>
|
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
Environment (Temp / Humidity)
|
||||||
|
</Typography>
|
||||||
|
<EnvChart data={envData} range={range} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mt: 2, mb: 3 }} />
|
||||||
|
|
||||||
|
{/* Port Grid */}
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{ports.map((port) => {
|
||||||
|
const isLight = port.port_name && port.port_name.toLowerCase().includes('light');
|
||||||
|
const isCO2 = port.port_name && port.port_name.toLowerCase().includes('co2');
|
||||||
|
const pData = portData[port.port] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid size={{ xs: 12, md: 6, lg: 4 }} key={port.port}>
|
||||||
|
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
{port.port_name || `Port ${port.port}`}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ height: 250 }}>
|
||||||
|
<LevelChart data={pData} isLight={isLight} isCO2={isCO2} 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.4,
|
||||||
|
pointRadius: 0,
|
||||||
|
borderWidth: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Humidity (%)',
|
||||||
|
data: data.map(d => d.humidity),
|
||||||
|
borderColor: '#36a2eb',
|
||||||
|
backgroundColor: '#36a2eb',
|
||||||
|
yAxisID: 'y1',
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 0,
|
||||||
|
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} />;
|
||||||
|
}
|
||||||
82
src/client/LevelChart.js
Normal file
82
src/client/LevelChart.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
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, isCO2, 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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine label and color based on sensor type
|
||||||
|
const levelLabel = isCO2 ? 'CO2 (ppm)' : (isLight ? 'Brightness' : 'Fan Speed');
|
||||||
|
const levelColor = isCO2 ? '#4caf50' : (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: !isCO2, // CO2 uses smooth lines
|
||||||
|
tension: isCO2 ? 0.4 : 0, // Smooth for CO2, stepped for others
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// CO2 needs different Y-axis scale (ppm range)
|
||||||
|
const yScale = isCO2
|
||||||
|
? { suggestedMin: 400, suggestedMax: 2000 }
|
||||||
|
: { suggestedMin: 0, suggestedMax: 10, ticks: { stepSize: 1 } };
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
maxRotation: 0,
|
||||||
|
autoSkip: true,
|
||||||
|
maxTicksLimit: 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: yScale
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Line data={chartData} options={options} />;
|
||||||
|
}
|
||||||
150
src/client/LoginDialog.js
Normal file
150
src/client/LoginDialog.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
CircularProgress,
|
||||||
|
InputAdornment,
|
||||||
|
IconButton,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
|
||||||
|
// Simple eye icons using unicode
|
||||||
|
const VisibilityIcon = () => <span style={{ fontSize: '1.2rem' }}>👁</span>;
|
||||||
|
const VisibilityOffIcon = () => <span style={{ fontSize: '1.2rem' }}>👁🗨</span>;
|
||||||
|
|
||||||
|
export default function LoginDialog({ open, onClose }) {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
// Success - close dialog and reset form
|
||||||
|
setUsername('');
|
||||||
|
setPassword('');
|
||||||
|
if (onClose) onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setError('');
|
||||||
|
if (onClose) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
background: 'linear-gradient(145deg, #3c3836 0%, #282828 100%)',
|
||||||
|
borderRadius: 3,
|
||||||
|
border: '1px solid #504945'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ textAlign: 'center', pb: 1 }}>
|
||||||
|
<Typography variant="h5" component="div" sx={{ fontWeight: 600 }}>
|
||||||
|
🔐 Dashboard Login
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
Tischlerei Automation Control
|
||||||
|
</Typography>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogContent>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Username"
|
||||||
|
type="text"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="Password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
edge="end"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 3 }}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
size="large"
|
||||||
|
disabled={loading || !username || !password}
|
||||||
|
sx={{
|
||||||
|
py: 1.5,
|
||||||
|
background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)',
|
||||||
|
'&:hover': {
|
||||||
|
background: 'linear-gradient(45deg, #98c98a 30%, #c5c836 90%)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<CircularProgress size={20} color="inherit" />
|
||||||
|
<span>Signing in...</span>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
'Sign In'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
src/client/RuleCard.js
Normal file
161
src/client/RuleCard.js
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
Switch,
|
||||||
|
IconButton,
|
||||||
|
Chip,
|
||||||
|
Tooltip
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
// Simple icons using unicode/emoji
|
||||||
|
const EditIcon = () => <span style={{ fontSize: '1rem' }}>✏️</span>;
|
||||||
|
const DeleteIcon = () => <span style={{ fontSize: '1rem' }}>🗑️</span>;
|
||||||
|
|
||||||
|
const dayLabels = {
|
||||||
|
mon: 'M', tue: 'T', wed: 'W', thu: 'T', fri: 'F', sat: 'S', sun: 'S'
|
||||||
|
};
|
||||||
|
|
||||||
|
const dayOrder = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||||
|
|
||||||
|
function TriggerSummary({ trigger }) {
|
||||||
|
if (trigger.type === 'time') {
|
||||||
|
const days = trigger.days || [];
|
||||||
|
const isEveryDay = days.length === 7;
|
||||||
|
const isWeekdays = days.length === 5 &&
|
||||||
|
['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
|
||||||
|
|
||||||
|
let dayText = isEveryDay ? 'Every day' :
|
||||||
|
isWeekdays ? 'Weekdays' :
|
||||||
|
dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Chip
|
||||||
|
label="⏰ Time"
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2">
|
||||||
|
At <strong>{trigger.time}</strong> • {dayText}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trigger.type === 'sensor') {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Chip
|
||||||
|
label="📊 Sensor"
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: '#fabd2f', color: '#282828', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2">
|
||||||
|
When <strong>{trigger.sensor}</strong> {trigger.operator} <strong>{trigger.value}</strong>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionSummary({ action }) {
|
||||||
|
if (action.type === 'toggle') {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Chip
|
||||||
|
label={action.state ? '🔛 ON' : '🔴 OFF'}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: action.state ? '#b8bb26' : '#fb4934',
|
||||||
|
color: '#282828',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Turn <strong>{action.target}</strong> {action.state ? 'on' : 'off'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === 'keepOn') {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Chip
|
||||||
|
label="⏱️ Timed"
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Keep <strong>{action.target}</strong> on for <strong>{action.duration} min</strong>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RuleCard({ rule, onEdit, onDelete, onToggle }) {
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
opacity: rule.enabled ? 1 : 0.6,
|
||||||
|
transition: 'opacity 0.2s, transform 0.2s',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: rule.enabled ? '#504945' : '#3c3836',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'translateX(4px)',
|
||||||
|
borderColor: rule.enabled ? '#8ec07c' : '#504945'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
|
{rule.name}
|
||||||
|
</Typography>
|
||||||
|
{!rule.enabled && (
|
||||||
|
<Chip
|
||||||
|
label="Disabled"
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: '#504945', fontSize: '0.7rem' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<TriggerSummary trigger={rule.trigger} />
|
||||||
|
<ActionSummary action={rule.action} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Tooltip title={rule.enabled ? 'Disable rule' : 'Enable rule'}>
|
||||||
|
<Switch
|
||||||
|
checked={rule.enabled}
|
||||||
|
onChange={onToggle}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Edit rule">
|
||||||
|
<IconButton onClick={onEdit} size="small">
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete rule">
|
||||||
|
<IconButton onClick={onDelete} size="small" sx={{ '&:hover': { color: '#fb4934' } }}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
346
src/client/RuleEditor.js
Normal file
346
src/client/RuleEditor.js
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
ToggleButton,
|
||||||
|
ToggleButtonGroup,
|
||||||
|
Typography,
|
||||||
|
Divider,
|
||||||
|
Slider,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
const DAYS = [
|
||||||
|
{ key: 'mon', label: 'Mon' },
|
||||||
|
{ key: 'tue', label: 'Tue' },
|
||||||
|
{ key: 'wed', label: 'Wed' },
|
||||||
|
{ key: 'thu', label: 'Thu' },
|
||||||
|
{ key: 'fri', label: 'Fri' },
|
||||||
|
{ key: 'sat', label: 'Sat' },
|
||||||
|
{ key: 'sun', label: 'Sun' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const SENSORS = ['Temperature', 'Humidity', 'CO2', 'VPD', 'Light Level'];
|
||||||
|
const OPERATORS = [
|
||||||
|
{ value: '>', label: 'Greater than (>)' },
|
||||||
|
{ value: '<', label: 'Less than (<)' },
|
||||||
|
{ value: '>=', label: 'Greater or equal (≥)' },
|
||||||
|
{ value: '<=', label: 'Less or equal (≤)' },
|
||||||
|
{ value: '==', label: 'Equal to (=)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const OUTPUTS = [
|
||||||
|
'Workshop Light',
|
||||||
|
'Exhaust Fan',
|
||||||
|
'Heater',
|
||||||
|
'Humidifier',
|
||||||
|
'All Outlets',
|
||||||
|
'Grow Light',
|
||||||
|
'Circulation Fan'
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RuleEditor({ open, rule, onSave, onClose }) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [triggerType, setTriggerType] = useState('time');
|
||||||
|
|
||||||
|
// Time trigger state
|
||||||
|
const [time, setTime] = useState('08:00');
|
||||||
|
const [days, setDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
|
||||||
|
|
||||||
|
// Sensor trigger state
|
||||||
|
const [sensor, setSensor] = useState('Temperature');
|
||||||
|
const [operator, setOperator] = useState('>');
|
||||||
|
const [sensorValue, setSensorValue] = useState(25);
|
||||||
|
|
||||||
|
// Action state
|
||||||
|
const [actionType, setActionType] = useState('toggle');
|
||||||
|
const [target, setTarget] = useState('Workshop Light');
|
||||||
|
const [toggleState, setToggleState] = useState(true);
|
||||||
|
const [duration, setDuration] = useState(15);
|
||||||
|
|
||||||
|
// Reset form when rule changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (rule) {
|
||||||
|
setName(rule.name);
|
||||||
|
setTriggerType(rule.trigger.type);
|
||||||
|
|
||||||
|
if (rule.trigger.type === 'time') {
|
||||||
|
setTime(rule.trigger.time);
|
||||||
|
setDays(rule.trigger.days || []);
|
||||||
|
} else {
|
||||||
|
setSensor(rule.trigger.sensor);
|
||||||
|
setOperator(rule.trigger.operator);
|
||||||
|
setSensorValue(rule.trigger.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionType(rule.action.type);
|
||||||
|
setTarget(rule.action.target);
|
||||||
|
if (rule.action.type === 'toggle') {
|
||||||
|
setToggleState(rule.action.state);
|
||||||
|
} else {
|
||||||
|
setDuration(rule.action.duration);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset to defaults for new rule
|
||||||
|
setName('');
|
||||||
|
setTriggerType('time');
|
||||||
|
setTime('08:00');
|
||||||
|
setDays(['mon', 'tue', 'wed', 'thu', 'fri']);
|
||||||
|
setSensor('Temperature');
|
||||||
|
setOperator('>');
|
||||||
|
setSensorValue(25);
|
||||||
|
setActionType('toggle');
|
||||||
|
setTarget('Workshop Light');
|
||||||
|
setToggleState(true);
|
||||||
|
setDuration(15);
|
||||||
|
}
|
||||||
|
}, [rule, open]);
|
||||||
|
|
||||||
|
const handleDaysChange = (event, newDays) => {
|
||||||
|
if (newDays.length > 0) {
|
||||||
|
setDays(newDays);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const ruleData = {
|
||||||
|
name,
|
||||||
|
trigger: triggerType === 'time'
|
||||||
|
? { type: 'time', time, days }
|
||||||
|
: { type: 'sensor', sensor, operator, value: sensorValue },
|
||||||
|
action: actionType === 'toggle'
|
||||||
|
? { type: 'toggle', target, state: toggleState }
|
||||||
|
: { type: 'keepOn', target, duration }
|
||||||
|
};
|
||||||
|
onSave(ruleData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = name.trim().length > 0 &&
|
||||||
|
(triggerType !== 'time' || days.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
background: 'linear-gradient(145deg, #3c3836 0%, #282828 100%)',
|
||||||
|
border: '1px solid #504945'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
{rule ? '✏️ Edit Rule' : '➕ Create New Rule'}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, pt: 1 }}>
|
||||||
|
{/* Rule Name */}
|
||||||
|
<TextField
|
||||||
|
label="Rule Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
placeholder="e.g., Morning Lights"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Trigger Section */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
TRIGGER (When to activate)
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||||
|
<InputLabel>Trigger Type</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={triggerType}
|
||||||
|
label="Trigger Type"
|
||||||
|
onChange={(e) => setTriggerType(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="time">⏰ Time-based</MenuItem>
|
||||||
|
<MenuItem value="sensor">📊 Sensor Value</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{triggerType === 'time' && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Time"
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => setTime(e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Days of Week
|
||||||
|
</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={days}
|
||||||
|
onChange={handleDaysChange}
|
||||||
|
size="small"
|
||||||
|
sx={{ flexWrap: 'wrap' }}
|
||||||
|
>
|
||||||
|
{DAYS.map(day => (
|
||||||
|
<ToggleButton
|
||||||
|
key={day.key}
|
||||||
|
value={day.key}
|
||||||
|
sx={{
|
||||||
|
'&.Mui-selected': {
|
||||||
|
bgcolor: '#8ec07c',
|
||||||
|
color: '#282828',
|
||||||
|
'&:hover': { bgcolor: '#98c98a' }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{day.label}
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{triggerType === 'sensor' && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Sensor</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={sensor}
|
||||||
|
label="Sensor"
|
||||||
|
onChange={(e) => setSensor(e.target.value)}
|
||||||
|
>
|
||||||
|
{SENSORS.map(s => (
|
||||||
|
<MenuItem key={s} value={s}>{s}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
|
<FormControl sx={{ minWidth: 180 }}>
|
||||||
|
<InputLabel>Condition</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={operator}
|
||||||
|
label="Condition"
|
||||||
|
onChange={(e) => setOperator(e.target.value)}
|
||||||
|
>
|
||||||
|
{OPERATORS.map(op => (
|
||||||
|
<MenuItem key={op.value} value={op.value}>{op.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField
|
||||||
|
label="Value"
|
||||||
|
type="number"
|
||||||
|
value={sensorValue}
|
||||||
|
onChange={(e) => setSensorValue(Number(e.target.value))}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Action Section */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
ACTION (What to do)
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||||
|
<InputLabel>Action Type</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={actionType}
|
||||||
|
label="Action Type"
|
||||||
|
onChange={(e) => setActionType(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="toggle">🔛 Toggle On/Off</MenuItem>
|
||||||
|
<MenuItem value="keepOn">⏱️ Keep On for X Minutes</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||||
|
<InputLabel>Target Output</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={target}
|
||||||
|
label="Target Output"
|
||||||
|
onChange={(e) => setTarget(e.target.value)}
|
||||||
|
>
|
||||||
|
{OUTPUTS.map(o => (
|
||||||
|
<MenuItem key={o} value={o}>{o}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{actionType === 'toggle' && (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={toggleState}
|
||||||
|
onChange={(e) => setToggleState(e.target.checked)}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={toggleState ? 'Turn ON' : 'Turn OFF'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actionType === 'keepOn' && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Duration: {duration} minutes
|
||||||
|
</Typography>
|
||||||
|
<Slider
|
||||||
|
value={duration}
|
||||||
|
onChange={(e, val) => setDuration(val)}
|
||||||
|
min={1}
|
||||||
|
max={120}
|
||||||
|
marks={[
|
||||||
|
{ value: 1, label: '1m' },
|
||||||
|
{ value: 30, label: '30m' },
|
||||||
|
{ value: 60, label: '1h' },
|
||||||
|
{ value: 120, label: '2h' }
|
||||||
|
]}
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||||
|
<Button onClick={onClose} color="inherit">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
variant="contained"
|
||||||
|
disabled={!isValid}
|
||||||
|
sx={{
|
||||||
|
background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)',
|
||||||
|
'&:hover': {
|
||||||
|
background: 'linear-gradient(45deg, #98c98a 30%, #c5c836 90%)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rule ? 'Save Changes' : 'Create Rule'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
src/client/RuleManager.js
Normal file
173
src/client/RuleManager.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
Divider
|
||||||
|
} from '@mui/material';
|
||||||
|
import RuleCard from './RuleCard';
|
||||||
|
import RuleEditor from './RuleEditor';
|
||||||
|
|
||||||
|
// Initial mock rules for demonstration
|
||||||
|
const initialRules = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Morning Light',
|
||||||
|
enabled: true,
|
||||||
|
trigger: {
|
||||||
|
type: 'time',
|
||||||
|
time: '06:30',
|
||||||
|
days: ['mon', 'tue', 'wed', 'thu', 'fri']
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'toggle',
|
||||||
|
target: 'Workshop Light',
|
||||||
|
state: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'High Humidity Fan',
|
||||||
|
enabled: true,
|
||||||
|
trigger: {
|
||||||
|
type: 'sensor',
|
||||||
|
sensor: 'Humidity',
|
||||||
|
operator: '>',
|
||||||
|
value: 70
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'keepOn',
|
||||||
|
target: 'Exhaust Fan',
|
||||||
|
duration: 15
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Evening Shutdown',
|
||||||
|
enabled: false,
|
||||||
|
trigger: {
|
||||||
|
type: 'time',
|
||||||
|
time: '18:00',
|
||||||
|
days: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'toggle',
|
||||||
|
target: 'All Outlets',
|
||||||
|
state: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RuleManager() {
|
||||||
|
const [rules, setRules] = useState(initialRules);
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [editingRule, setEditingRule] = useState(null);
|
||||||
|
|
||||||
|
const handleAddRule = () => {
|
||||||
|
setEditingRule(null);
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditRule = (rule) => {
|
||||||
|
setEditingRule(rule);
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRule = (ruleId) => {
|
||||||
|
setRules(rules.filter(r => r.id !== ruleId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleRule = (ruleId) => {
|
||||||
|
setRules(rules.map(r =>
|
||||||
|
r.id === ruleId ? { ...r, enabled: !r.enabled } : r
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveRule = (ruleData) => {
|
||||||
|
if (editingRule) {
|
||||||
|
// Update existing rule
|
||||||
|
setRules(rules.map(r =>
|
||||||
|
r.id === editingRule.id ? { ...r, ...ruleData } : r
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// Add new rule
|
||||||
|
const newRule = {
|
||||||
|
...ruleData,
|
||||||
|
id: Math.max(0, ...rules.map(r => r.id)) + 1,
|
||||||
|
enabled: true
|
||||||
|
};
|
||||||
|
setRules([...rules, newRule]);
|
||||||
|
}
|
||||||
|
setEditorOpen(false);
|
||||||
|
setEditingRule(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseEditor = () => {
|
||||||
|
setEditorOpen(false);
|
||||||
|
setEditingRule(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
mt: 4,
|
||||||
|
p: 3,
|
||||||
|
background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)',
|
||||||
|
border: '1px solid #504945'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
⚙️ Automation Rules
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Configure triggers and actions for home automation
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleAddRule}
|
||||||
|
sx={{
|
||||||
|
background: 'linear-gradient(45deg, #b8bb26 30%, #8ec07c 90%)',
|
||||||
|
'&:hover': {
|
||||||
|
background: 'linear-gradient(45deg, #c5c836 30%, #98c98a 90%)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add Rule
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
No rules configured. Click "Add Rule" to create one.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{rules.map(rule => (
|
||||||
|
<RuleCard
|
||||||
|
key={rule.id}
|
||||||
|
rule={rule}
|
||||||
|
onEdit={() => handleEditRule(rule)}
|
||||||
|
onDelete={() => handleDeleteRule(rule.id)}
|
||||||
|
onToggle={() => handleToggleRule(rule.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RuleEditor
|
||||||
|
open={editorOpen}
|
||||||
|
rule={editingRule}
|
||||||
|
onSave={handleSaveRule}
|
||||||
|
onClose={handleCloseEditor}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>Tischlerei 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 />);
|
||||||
184
test-ac-api.js
Normal file
184
test-ac-api.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const BASE_URL = 'http://www.acinfinityserver.com';
|
||||||
|
const USER_AGENT = 'ACController/1.9.7 (com.acinfinity.humiture; build:533; iOS 18.5.0) Alamofire/5.10.2';
|
||||||
|
|
||||||
|
// Helper to check credentials
|
||||||
|
if (!process.env.AC_EMAIL || !process.env.AC_PASSWORD) {
|
||||||
|
console.error('Error: AC_EMAIL and AC_PASSWORD must be set in .env file');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login to AC Infinity API
|
||||||
|
* @returns {Promise<string>} userId (token)
|
||||||
|
*/
|
||||||
|
async function login() {
|
||||||
|
console.log('Attempting login...');
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('appEmail', process.env.AC_EMAIL);
|
||||||
|
params.append('appPasswordl', process.env.AC_PASSWORD); // Note: appPasswordl with 'l'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/api/user/appUserLogin`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'User-Agent': USER_AGENT
|
||||||
|
},
|
||||||
|
body: params
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.code === 200) {
|
||||||
|
console.log('Login successful!');
|
||||||
|
return data.data.appId; // This is the token
|
||||||
|
} else {
|
||||||
|
throw new Error(`Login failed: ${data.msg || 'Unknown error'} (Code: ${data.code})`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get All Devices
|
||||||
|
* @param {string} token
|
||||||
|
* @returns {Promise<Array>} device list
|
||||||
|
*/
|
||||||
|
async function getDeviceList(token) {
|
||||||
|
console.log('Fetching device list...');
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('userId', token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/api/user/devInfoListAll`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
'token': token,
|
||||||
|
'phoneType': '1',
|
||||||
|
'appVersion': '1.9.7'
|
||||||
|
},
|
||||||
|
body: params
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.code === 200) {
|
||||||
|
const devices = data.data || [];
|
||||||
|
console.log(`Found ${devices.length} devices.`);
|
||||||
|
return devices;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Get device list failed: ${data.msg} (Code: ${data.code})`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get device list error:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Device Mode Settings
|
||||||
|
* @param {string} token
|
||||||
|
* @param {string} devId
|
||||||
|
* @param {number} port
|
||||||
|
*/
|
||||||
|
async function getDeviceModeSettings(token, devId, port = 1) {
|
||||||
|
console.log(`Fetching settings for device ${devId}, port ${port}...`);
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('devId', devId);
|
||||||
|
params.append('port', port.toString());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/api/dev/getdevModeSettingList`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
'token': token,
|
||||||
|
'phoneType': '1',
|
||||||
|
'appVersion': '1.9.7',
|
||||||
|
'minversion': '3.5'
|
||||||
|
},
|
||||||
|
body: params
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.code === 200) {
|
||||||
|
console.log('Settings retrieved successfully.');
|
||||||
|
return data.data;
|
||||||
|
} else {
|
||||||
|
// 403 or other errors might happen
|
||||||
|
console.warn(`Get settings warning: ${data.msg} (Code: ${data.code})`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get settings error:', error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main execution
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
// 1. Login
|
||||||
|
const token = await login();
|
||||||
|
|
||||||
|
// 2. Get Devices
|
||||||
|
const devices = await getDeviceList(token);
|
||||||
|
|
||||||
|
if (devices.length === 0) {
|
||||||
|
console.log("No devices found on this account.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Inspect first device
|
||||||
|
const firstDevice = devices[0];
|
||||||
|
console.log('\n--- First Device Details ---');
|
||||||
|
console.log(`Name: ${firstDevice.devName}`);
|
||||||
|
console.log(`ID: ${firstDevice.devId}`);
|
||||||
|
console.log(`Mac Address: ${firstDevice.devMacAddr || 'N/A'}`);
|
||||||
|
console.log(`Type: ${firstDevice.devType}`);
|
||||||
|
console.log(`WiFi SSID: ${firstDevice.wifiName || 'N/A'}`);
|
||||||
|
console.log(`Firmware: ${firstDevice.firmwareVersion || 'N/A'}`);
|
||||||
|
console.log(`Hardware: ${firstDevice.hardwareVersion || 'N/A'}`);
|
||||||
|
|
||||||
|
// 4. Get Settings for first device
|
||||||
|
const settings = await getDeviceModeSettings(
|
||||||
|
token,
|
||||||
|
firstDevice.devId,
|
||||||
|
firstDevice.externalPort || 1
|
||||||
|
);
|
||||||
|
|
||||||
|
if (settings) {
|
||||||
|
console.log('\n--- Device Settings (First Port) ---');
|
||||||
|
|
||||||
|
const tempC = settings.temperature ? settings.temperature / 100 : 'N/A';
|
||||||
|
const tempF = settings.temperatureF ? settings.temperatureF / 100 : 'N/A';
|
||||||
|
const hum = settings.humidity ? settings.humidity / 100 : 'N/A';
|
||||||
|
const vpd = settings.vpdnums ? settings.vpdnums / 100 : 'N/A';
|
||||||
|
|
||||||
|
console.log(`Temperature: ${tempC}°C / ${tempF}°F`);
|
||||||
|
console.log(`Humidity: ${hum}%`);
|
||||||
|
console.log(`VPD: ${vpd} kPa`);
|
||||||
|
console.log(`Current Fan Speed: ${settings.speak}/10`);
|
||||||
|
console.log(`On Speed: ${settings.onSpead}/10`);
|
||||||
|
console.log(`Off Speed: ${settings.offSpead}/10`);
|
||||||
|
console.log('-----------------------------------');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Script failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
46
webpack.config.js
Normal file
46
webpack.config.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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.[contenthash].js',
|
||||||
|
publicPath: '/ac/',
|
||||||
|
clean: true // Clean dist folder on rebuild
|
||||||
|
},
|
||||||
|
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