This commit is contained in:
sebseb7
2025-12-21 01:43:19 +01:00
parent 6b9ebb5ea0
commit 97056ebc5c
10 changed files with 2322 additions and 13 deletions

195
manage-users.js Normal file
View 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();

1086
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,14 +16,19 @@
"@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",

View File

@@ -5,6 +5,8 @@ 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);
@@ -17,6 +19,7 @@ const USER_AGENT = 'ACController/1.9.7 (com.acinfinity.humiture; build:533; iOS
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 = {
@@ -52,11 +55,23 @@ db.exec(`
)
`);
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;
@@ -201,7 +216,58 @@ async function poll() {
// --- 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) => {

View File

@@ -1,6 +1,9 @@
import React from 'react';
import { ThemeProvider, createTheme, CssBaseline, AppBar, Toolbar, Typography, Container, Box } from '@mui/material';
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 = {
@@ -39,22 +42,77 @@ const darkTheme = createTheme({
},
});
function App() {
function AppContent() {
const { user, loading, logout, isAuthenticated, isAdmin } = useAuth();
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Typography>Loading...</Typography>
</Box>
);
}
if (!isAuthenticated) {
return <LoginDialog open={true} />;
}
return (
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<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 }}>
<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"
sx={{ ml: 1 }}
>
Logout
</Button>
</Box>
</Toolbar>
</AppBar>
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
<Dashboard />
{isAdmin && <RuleManager />}
</Container>
</Box>
);
}
function App() {
return (
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<AuthProvider>
<AppContent />
</AuthProvider>
</ThemeProvider>
);
}

79
src/client/AuthContext.js Normal file
View 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;
}

140
src/client/LoginDialog.js Normal file
View File

@@ -0,0 +1,140 @@
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 }) {
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);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<Dialog
open={open}
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
View 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
View 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
View 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>
);
}