u
This commit is contained in:
14
uiserver/.env.example
Normal file
14
uiserver/.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Database path (default: ../server/data/sensors.db)
|
||||||
|
DB_PATH=
|
||||||
|
|
||||||
|
# JWT secret for authentication (CHANGE IN PRODUCTION!)
|
||||||
|
JWT_SECRET=your-secret-key-here
|
||||||
|
|
||||||
|
# WebSocket port for agent connections (default: 3962)
|
||||||
|
WS_PORT=3962
|
||||||
|
|
||||||
|
# Webpack dev server port (default: 3905)
|
||||||
|
DEV_SERVER_PORT=3905
|
||||||
|
|
||||||
|
# Rule runner interval in milliseconds (default: 10000 = 10s)
|
||||||
|
RULE_RUNNER_INTERVAL=10000
|
||||||
28
uiserver/api/auth.js
Normal file
28
uiserver/api/auth.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Auth API - Login endpoint
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = function setupAuthApi(app, { db, bcrypt, jwt, JWT_SECRET }) {
|
||||||
|
// POST /api/login
|
||||||
|
app.post('/api/login', (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('SELECT * FROM users WHERE username = ?');
|
||||||
|
const user = stmt.get(username);
|
||||||
|
|
||||||
|
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role
|
||||||
|
}, JWT_SECRET, { expiresIn: '24h' });
|
||||||
|
|
||||||
|
res.json({ token, role: user.role, username: user.username });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
25
uiserver/api/devices.js
Normal file
25
uiserver/api/devices.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Devices API - List unique device/channel pairs
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = function setupDevicesApi(app, { db, OUTPUT_CHANNELS }) {
|
||||||
|
// GET /api/devices - Returns list of unique device/channel pairs (sensors + outputs)
|
||||||
|
app.get('/api/devices', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!db) throw new Error('Database not connected');
|
||||||
|
// Get sensor channels
|
||||||
|
const sensorStmt = db.prepare("SELECT DISTINCT device, channel FROM sensor_events WHERE data_type = 'number' ORDER BY device, channel");
|
||||||
|
const sensorRows = sensorStmt.all();
|
||||||
|
|
||||||
|
// Add output channels with 'output' as device
|
||||||
|
const outputRows = OUTPUT_CHANNELS.map(ch => ({
|
||||||
|
device: 'output',
|
||||||
|
channel: ch.channel
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json([...sensorRows, ...outputRows]);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
43
uiserver/api/index.js
Normal file
43
uiserver/api/index.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* API Routes Index - Sets up all API endpoints
|
||||||
|
*/
|
||||||
|
|
||||||
|
const setupAuthApi = require('./auth');
|
||||||
|
const setupViewsApi = require('./views');
|
||||||
|
const setupRulesApi = require('./rules');
|
||||||
|
const setupOutputsApi = require('./outputs');
|
||||||
|
const setupDevicesApi = require('./devices');
|
||||||
|
const setupReadingsApi = require('./readings');
|
||||||
|
|
||||||
|
module.exports = function setupAllApis(app, context) {
|
||||||
|
const { db, bcrypt, jwt, JWT_SECRET, OUTPUT_CHANNELS, OUTPUT_BINDINGS, runRules, activeRuleIds } = context;
|
||||||
|
|
||||||
|
// Auth middleware helpers
|
||||||
|
const checkAuth = (req, res, next) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader) {
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
jwt.verify(token, JWT_SECRET, (err, user) => {
|
||||||
|
if (user) req.user = user;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireAdmin = (req, res, next) => {
|
||||||
|
if (!req.user || req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup all API routes
|
||||||
|
setupAuthApi(app, { db, bcrypt, jwt, JWT_SECRET });
|
||||||
|
setupViewsApi(app, { db, checkAuth, requireAdmin });
|
||||||
|
setupRulesApi(app, { db, checkAuth, requireAdmin, runRules, activeRuleIds });
|
||||||
|
setupOutputsApi(app, { db, OUTPUT_CHANNELS, OUTPUT_BINDINGS });
|
||||||
|
setupDevicesApi(app, { db, OUTPUT_CHANNELS });
|
||||||
|
setupReadingsApi(app, { db });
|
||||||
|
};
|
||||||
73
uiserver/api/outputs.js
Normal file
73
uiserver/api/outputs.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Outputs API - Output channel definitions and values
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = function setupOutputsApi(app, { db, OUTPUT_CHANNELS, OUTPUT_BINDINGS }) {
|
||||||
|
// GET /api/outputs - List output channel definitions
|
||||||
|
app.get('/api/outputs', (req, res) => {
|
||||||
|
res.json(OUTPUT_CHANNELS);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/outputs/values - Get current output values
|
||||||
|
app.get('/api/outputs/values', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!db) throw new Error('Database not connected');
|
||||||
|
const result = {};
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT channel, value FROM output_events
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT MAX(id) FROM output_events GROUP BY channel
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
const rows = stmt.all();
|
||||||
|
rows.forEach(row => {
|
||||||
|
result[row.channel] = row.value;
|
||||||
|
});
|
||||||
|
// Fill in defaults for missing channels
|
||||||
|
OUTPUT_CHANNELS.forEach(ch => {
|
||||||
|
if (result[ch.channel] === undefined) {
|
||||||
|
result[ch.channel] = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/outputs/commands - Get desired states for bound devices
|
||||||
|
// Agents poll this to get commands. Returns { "device:channel": { state: 0|1 } }
|
||||||
|
app.get('/api/outputs/commands', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!db) throw new Error('Database not connected');
|
||||||
|
|
||||||
|
// Get current output values
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT channel, value FROM output_events
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT MAX(id) FROM output_events GROUP BY channel
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
const rows = stmt.all();
|
||||||
|
const outputValues = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
outputValues[row.channel] = row.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map to device commands
|
||||||
|
const commands = {};
|
||||||
|
for (const [outputChannel, binding] of Object.entries(OUTPUT_BINDINGS)) {
|
||||||
|
const value = outputValues[outputChannel] ?? 0;
|
||||||
|
const deviceKey = `${binding.device}:${binding.channel}`;
|
||||||
|
commands[deviceKey] = {
|
||||||
|
state: value > 0 ? 1 : 0,
|
||||||
|
source: outputChannel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(commands);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
124
uiserver/api/readings.js
Normal file
124
uiserver/api/readings.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Readings API - Sensor and output data for charts
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = function setupReadingsApi(app, { db }) {
|
||||||
|
// GET /api/readings
|
||||||
|
// Query params: since, until, selection (comma-separated device:channel pairs)
|
||||||
|
app.get('/api/readings', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!db) throw new Error('Database not connected');
|
||||||
|
const { since, until } = req.query;
|
||||||
|
const startTime = since || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const endTime = until || new Date().toISOString();
|
||||||
|
|
||||||
|
const requestedSensorChannels = []; // [{device, channel}]
|
||||||
|
const requestedOutputChannels = []; // [channel]
|
||||||
|
|
||||||
|
if (req.query.selection) {
|
||||||
|
const selections = req.query.selection.split(',');
|
||||||
|
selections.forEach(s => {
|
||||||
|
const lastColonIndex = s.lastIndexOf(':');
|
||||||
|
if (lastColonIndex !== -1) {
|
||||||
|
const d = s.substring(0, lastColonIndex);
|
||||||
|
const c = s.substring(lastColonIndex + 1);
|
||||||
|
if (d === 'output') {
|
||||||
|
requestedOutputChannels.push(c);
|
||||||
|
} else {
|
||||||
|
requestedSensorChannels.push({ device: d, channel: c });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
// 1. Fetch sensor data
|
||||||
|
if (requestedSensorChannels.length > 0) {
|
||||||
|
let sql = 'SELECT * FROM sensor_events WHERE timestamp > ? AND timestamp <= ? ';
|
||||||
|
const params = [startTime, endTime];
|
||||||
|
|
||||||
|
const placeholders = [];
|
||||||
|
requestedSensorChannels.forEach(ch => {
|
||||||
|
placeholders.push('(device = ? AND channel = ?)');
|
||||||
|
params.push(ch.device, ch.channel);
|
||||||
|
});
|
||||||
|
if (placeholders.length > 0) {
|
||||||
|
sql += `AND (${placeholders.join(' OR ')}) `;
|
||||||
|
}
|
||||||
|
sql += 'ORDER BY timestamp ASC';
|
||||||
|
|
||||||
|
const rows = db.prepare(sql).all(...params);
|
||||||
|
|
||||||
|
// Backfill for sensors
|
||||||
|
const backfillStmt = db.prepare(`
|
||||||
|
SELECT * FROM sensor_events
|
||||||
|
WHERE device = ? AND channel = ?
|
||||||
|
AND timestamp <= ?
|
||||||
|
AND (until >= ? OR until IS NULL)
|
||||||
|
ORDER BY timestamp DESC LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const backfillRows = [];
|
||||||
|
requestedSensorChannels.forEach(ch => {
|
||||||
|
const prev = backfillStmt.get(ch.device, ch.channel, startTime, startTime);
|
||||||
|
if (prev) backfillRows.push(prev);
|
||||||
|
});
|
||||||
|
|
||||||
|
[...backfillRows, ...rows].forEach(row => {
|
||||||
|
const key = `${row.device}:${row.channel}`;
|
||||||
|
if (!result[key]) result[key] = [];
|
||||||
|
const pt = [row.timestamp, row.value];
|
||||||
|
if (row.until) pt.push(row.until);
|
||||||
|
result[key].push(pt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch output data
|
||||||
|
if (requestedOutputChannels.length > 0) {
|
||||||
|
let sql = 'SELECT * FROM output_events WHERE timestamp > ? AND timestamp <= ? ';
|
||||||
|
const params = [startTime, endTime];
|
||||||
|
|
||||||
|
const placeholders = requestedOutputChannels.map(() => 'channel = ?');
|
||||||
|
sql += `AND (${placeholders.join(' OR ')}) `;
|
||||||
|
params.push(...requestedOutputChannels);
|
||||||
|
sql += 'ORDER BY timestamp ASC';
|
||||||
|
|
||||||
|
const rows = db.prepare(sql).all(...params);
|
||||||
|
|
||||||
|
// Backfill for outputs
|
||||||
|
const backfillStmt = db.prepare(`
|
||||||
|
SELECT * FROM output_events
|
||||||
|
WHERE channel = ?
|
||||||
|
AND timestamp <= ?
|
||||||
|
AND (until >= ? OR until IS NULL)
|
||||||
|
ORDER BY timestamp DESC LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const backfillRows = [];
|
||||||
|
requestedOutputChannels.forEach(ch => {
|
||||||
|
const prev = backfillStmt.get(ch, startTime, startTime);
|
||||||
|
if (prev) {
|
||||||
|
backfillRows.push(prev);
|
||||||
|
} else {
|
||||||
|
// No data at all - add default 0 value at startTime
|
||||||
|
backfillRows.push({ channel: ch, timestamp: startTime, value: 0, until: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
[...backfillRows, ...rows].forEach(row => {
|
||||||
|
const key = `output:${row.channel}`;
|
||||||
|
if (!result[key]) result[key] = [];
|
||||||
|
const pt = [row.timestamp, row.value];
|
||||||
|
if (row.until) pt.push(row.until);
|
||||||
|
result[key].push(pt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
166
uiserver/api/rules.js
Normal file
166
uiserver/api/rules.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Rules API - CRUD for automation rules
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = function setupRulesApi(app, { db, checkAuth, requireAdmin, runRules, activeRuleIds }) {
|
||||||
|
// Apply checkAuth middleware to rules routes
|
||||||
|
app.use('/api/rules', checkAuth);
|
||||||
|
|
||||||
|
// GET /api/rules/status - Get currently active rule IDs
|
||||||
|
app.get('/api/rules/status', (req, res) => {
|
||||||
|
res.json({ activeIds: Array.from(activeRuleIds) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/rules - List all rules
|
||||||
|
app.get('/api/rules', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!db) throw new Error('Database not connected');
|
||||||
|
const stmt = db.prepare('SELECT * FROM rules ORDER BY position ASC, id ASC');
|
||||||
|
const rows = stmt.all();
|
||||||
|
const rules = rows.map(row => ({
|
||||||
|
...row,
|
||||||
|
conditions: JSON.parse(row.conditions || '{}'),
|
||||||
|
action: JSON.parse(row.action || '{}')
|
||||||
|
}));
|
||||||
|
res.json(rules);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/rules - Create rule (admin only)
|
||||||
|
app.post('/api/rules', requireAdmin, (req, res) => {
|
||||||
|
const { name, type = 'static', enabled = 1, conditions, action } = req.body;
|
||||||
|
if (!name || !conditions || !action) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields: name, conditions, action' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO rules (name, type, enabled, conditions, action, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
const info = stmt.run(
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
enabled ? 1 : 0,
|
||||||
|
JSON.stringify(conditions),
|
||||||
|
JSON.stringify(action),
|
||||||
|
req.user?.id || null
|
||||||
|
);
|
||||||
|
runRules(); // Trigger rules immediately
|
||||||
|
global.insertChangelog(req.user?.username || 'admin', `Created rule "${name}"`);
|
||||||
|
res.json({ id: info.lastInsertRowid, name, type, enabled, conditions, action });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/rules/:id - Update rule (admin only)
|
||||||
|
app.put('/api/rules/:id', requireAdmin, (req, res) => {
|
||||||
|
const { name, type, enabled, conditions, action } = req.body;
|
||||||
|
try {
|
||||||
|
// Get old rule for comparison
|
||||||
|
const oldRule = db.prepare('SELECT * FROM rules WHERE id = ?').get(req.params.id);
|
||||||
|
if (!oldRule) {
|
||||||
|
return res.status(404).json({ error: 'Rule not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE rules SET name = ?, type = ?, enabled = ?, conditions = ?, action = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
const info = stmt.run(
|
||||||
|
name,
|
||||||
|
type || 'static',
|
||||||
|
enabled ? 1 : 0,
|
||||||
|
JSON.stringify(conditions),
|
||||||
|
JSON.stringify(action),
|
||||||
|
req.params.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (info.changes > 0) {
|
||||||
|
runRules(); // Trigger rules immediately
|
||||||
|
|
||||||
|
// Build detailed changelog
|
||||||
|
const changes = [];
|
||||||
|
if (oldRule.name !== name) {
|
||||||
|
changes.push(`name: "${oldRule.name}" → "${name}"`);
|
||||||
|
}
|
||||||
|
if (!!oldRule.enabled !== !!enabled) {
|
||||||
|
changes.push(`enabled: ${oldRule.enabled ? 'on' : 'off'} → ${enabled ? 'on' : 'off'}`);
|
||||||
|
}
|
||||||
|
const oldConditions = oldRule.conditions || '{}';
|
||||||
|
const newConditions = JSON.stringify(conditions);
|
||||||
|
if (oldConditions !== newConditions) {
|
||||||
|
changes.push('conditions changed');
|
||||||
|
}
|
||||||
|
const oldAction = oldRule.action || '{}';
|
||||||
|
const newAction = JSON.stringify(action);
|
||||||
|
if (oldAction !== newAction) {
|
||||||
|
try {
|
||||||
|
const oldA = JSON.parse(oldAction);
|
||||||
|
const newA = action;
|
||||||
|
if (oldA.channel !== newA.channel) {
|
||||||
|
changes.push(`action channel: ${oldA.channel} → ${newA.channel}`);
|
||||||
|
}
|
||||||
|
if (JSON.stringify(oldA.value) !== JSON.stringify(newA.value)) {
|
||||||
|
changes.push(`action value: ${JSON.stringify(oldA.value)} → ${JSON.stringify(newA.value)}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
changes.push('action changed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeText = changes.length > 0
|
||||||
|
? `Updated rule "${name}": ${changes.join(', ')}`
|
||||||
|
: `Updated rule "${name}" (no changes)`;
|
||||||
|
global.insertChangelog(req.user?.username || 'admin', changeText);
|
||||||
|
|
||||||
|
res.json({ id: req.params.id, name, type, enabled, conditions, action });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Rule not found' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/rules/:id - Delete rule (admin only)
|
||||||
|
app.delete('/api/rules/:id', requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('DELETE FROM rules WHERE id = ?');
|
||||||
|
const ruleName = db.prepare('SELECT name FROM rules WHERE id = ?').get(req.params.id)?.name || 'Unknown Rule';
|
||||||
|
const info = stmt.run(req.params.id);
|
||||||
|
if (info.changes > 0) {
|
||||||
|
runRules(); // Trigger rules immediately
|
||||||
|
global.insertChangelog(req.user?.username || 'admin', `Deleted rule "${ruleName}" (ID: ${req.params.id})`);
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Rule not found' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/rules/reorder - Reorder rules (admin only)
|
||||||
|
app.post('/api/rules/reorder', requireAdmin, (req, res) => {
|
||||||
|
const { order } = req.body;
|
||||||
|
if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' });
|
||||||
|
|
||||||
|
const updateStmt = db.prepare('UPDATE rules SET position = ? WHERE id = ?');
|
||||||
|
const updateMany = db.transaction((items) => {
|
||||||
|
for (const item of items) {
|
||||||
|
updateStmt.run(item.position, item.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateMany(order);
|
||||||
|
runRules(); // Trigger rules immediately
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
186
uiserver/api/views.js
Normal file
186
uiserver/api/views.js
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Views API - CRUD for dashboard views
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = function setupViewsApi(app, { db, checkAuth, requireAdmin }) {
|
||||||
|
// Apply checkAuth middleware to views routes
|
||||||
|
app.use('/api/views', checkAuth);
|
||||||
|
|
||||||
|
// POST /api/views - Create view (admin only)
|
||||||
|
app.post('/api/views', requireAdmin, (req, res) => {
|
||||||
|
const { name, config } = req.body;
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('INSERT INTO views (name, config, created_by) VALUES (?, ?, ?)');
|
||||||
|
const info = stmt.run(name, JSON.stringify(config), req.user.id);
|
||||||
|
global.insertChangelog(req.user.username, `Created view "${name}"`);
|
||||||
|
res.json({ id: info.lastInsertRowid, name, config });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/views - List all views (public)
|
||||||
|
app.get('/api/views', (req, res) => {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('SELECT * FROM views ORDER BY position ASC, id ASC');
|
||||||
|
const rows = stmt.all();
|
||||||
|
const views = rows.map(row => {
|
||||||
|
try {
|
||||||
|
return { ...row, config: JSON.parse(row.config) };
|
||||||
|
} catch (e) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json(views);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/views/:id - Get single view
|
||||||
|
app.get('/api/views/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('SELECT * FROM views WHERE id = ?');
|
||||||
|
const view = stmt.get(req.params.id);
|
||||||
|
if (view) {
|
||||||
|
view.config = JSON.parse(view.config);
|
||||||
|
res.json(view);
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'View not found' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/views/:id - Delete view (admin only)
|
||||||
|
app.delete('/api/views/:id', requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('DELETE FROM views WHERE id = ?');
|
||||||
|
const viewName = db.prepare('SELECT name FROM views WHERE id = ?').get(req.params.id)?.name || 'Unknown View';
|
||||||
|
const info = stmt.run(req.params.id);
|
||||||
|
if (info.changes > 0) {
|
||||||
|
global.insertChangelog(req.user.username, `Deleted view "${viewName}" (ID: ${req.params.id})`);
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'View not found' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/views/:id - Update view (admin only)
|
||||||
|
app.put('/api/views/:id', requireAdmin, (req, res) => {
|
||||||
|
const { name, config } = req.body;
|
||||||
|
try {
|
||||||
|
// Get old view for comparison
|
||||||
|
const oldView = db.prepare('SELECT * FROM views WHERE id = ?').get(req.params.id);
|
||||||
|
if (!oldView) {
|
||||||
|
return res.status(404).json({ error: 'View not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = db.prepare('UPDATE views SET name = ?, config = ? WHERE id = ?');
|
||||||
|
const info = stmt.run(name, JSON.stringify(config), req.params.id);
|
||||||
|
if (info.changes > 0) {
|
||||||
|
// Build detailed changelog
|
||||||
|
const changes = [];
|
||||||
|
|
||||||
|
// Check name change
|
||||||
|
if (oldView.name !== name) {
|
||||||
|
changes.push(`renamed: "${oldView.name}" → "${name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse configs for comparison
|
||||||
|
let oldConfig = {};
|
||||||
|
try { oldConfig = JSON.parse(oldView.config || '{}'); } catch (e) { }
|
||||||
|
const newConfig = config || {};
|
||||||
|
|
||||||
|
// Compare channels
|
||||||
|
const oldChannels = (oldConfig.channels || []).map(ch =>
|
||||||
|
typeof ch === 'string' ? ch : ch.channel
|
||||||
|
);
|
||||||
|
const newChannels = (newConfig.channels || []).map(ch =>
|
||||||
|
typeof ch === 'string' ? ch : ch.channel
|
||||||
|
);
|
||||||
|
|
||||||
|
const added = newChannels.filter(ch => !oldChannels.includes(ch));
|
||||||
|
const removed = oldChannels.filter(ch => !newChannels.includes(ch));
|
||||||
|
|
||||||
|
if (added.length > 0) {
|
||||||
|
changes.push(`added channels: ${added.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (removed.length > 0) {
|
||||||
|
changes.push(`removed channels: ${removed.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for color/fill changes
|
||||||
|
const oldChannelConfigs = {};
|
||||||
|
(oldConfig.channels || []).forEach(ch => {
|
||||||
|
if (typeof ch === 'object') {
|
||||||
|
oldChannelConfigs[ch.channel] = ch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const newChannelConfigs = {};
|
||||||
|
(newConfig.channels || []).forEach(ch => {
|
||||||
|
if (typeof ch === 'object') {
|
||||||
|
newChannelConfigs[ch.channel] = ch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const colorChanges = [];
|
||||||
|
for (const ch of newChannels) {
|
||||||
|
const oldCh = oldChannelConfigs[ch] || {};
|
||||||
|
const newCh = newChannelConfigs[ch] || {};
|
||||||
|
if (oldCh.color !== newCh.color || oldCh.fillColor !== newCh.fillColor) {
|
||||||
|
colorChanges.push(ch.split(':').pop());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (colorChanges.length > 0) {
|
||||||
|
changes.push(`colors changed for: ${colorChanges.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check order change
|
||||||
|
if (added.length === 0 && removed.length === 0 &&
|
||||||
|
JSON.stringify(oldChannels) !== JSON.stringify(newChannels)) {
|
||||||
|
changes.push('channel order changed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeText = changes.length > 0
|
||||||
|
? `Updated view "${name}": ${changes.join('; ')}`
|
||||||
|
: `Updated view "${name}" (no significant changes)`;
|
||||||
|
global.insertChangelog(req.user.username, changeText);
|
||||||
|
|
||||||
|
res.json({ id: req.params.id, name, config });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'View not found' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/views/reorder - Reorder views (admin only)
|
||||||
|
app.post('/api/views/reorder', requireAdmin, (req, res) => {
|
||||||
|
const { order } = req.body;
|
||||||
|
console.log('[API] Reorder request:', order);
|
||||||
|
if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' });
|
||||||
|
|
||||||
|
const updateStmt = db.prepare('UPDATE views SET position = ? WHERE id = ?');
|
||||||
|
const updateMany = db.transaction((items) => {
|
||||||
|
for (const item of items) {
|
||||||
|
console.log('[API] Updating view', item.id, 'to position', item.position);
|
||||||
|
updateStmt.run(item.position, item.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateMany(order);
|
||||||
|
console.log('[API] Reorder successful');
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[API] Reorder error:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -12,7 +12,9 @@ config();
|
|||||||
// Database connection for Dev Server API
|
// Database connection for Dev Server API
|
||||||
const dbPath = process.env.DB_PATH || path.resolve(__dirname, '../server/data/sensors.db');
|
const dbPath = process.env.DB_PATH || path.resolve(__dirname, '../server/data/sensors.db');
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-key-change-me';
|
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-key-change-me';
|
||||||
const WS_PORT = process.env.WS_PORT || 3962;
|
const WS_PORT = parseInt(process.env.WS_PORT || '3962', 10);
|
||||||
|
const DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT || '3905', 10);
|
||||||
|
const RULE_RUNNER_INTERVAL = parseInt(process.env.RULE_RUNNER_INTERVAL || '10000', 10);
|
||||||
let db;
|
let db;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -572,12 +574,16 @@ function runRules() {
|
|||||||
console.error('[RuleRunner] Error running rules:', err.message);
|
console.error('[RuleRunner] Error running rules:', err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also sync immediately on startup after a short delay
|
// Also sync immediately on startup after a short delay
|
||||||
setTimeout(syncOutputStates, 5000);
|
setTimeout(syncOutputStates, 5000);
|
||||||
|
|
||||||
// Start the WebSocket server
|
// Start the WebSocket server
|
||||||
const agentWss = createAgentWebSocketServer();
|
const agentWss = createAgentWebSocketServer();
|
||||||
|
|
||||||
|
// Import API setup
|
||||||
|
const setupAllApis = require('./api');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: './src/index.js',
|
entry: './src/index.js',
|
||||||
output: {
|
output: {
|
||||||
@@ -621,7 +627,7 @@ module.exports = {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
devServer: {
|
devServer: {
|
||||||
port: 3905,
|
port: DEV_SERVER_PORT,
|
||||||
historyApiFallback: true,
|
historyApiFallback: true,
|
||||||
hot: true,
|
hot: true,
|
||||||
allowedHosts: 'all',
|
allowedHosts: 'all',
|
||||||
@@ -634,484 +640,26 @@ module.exports = {
|
|||||||
throw new Error('webpack-dev-server is not defined');
|
throw new Error('webpack-dev-server is not defined');
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Endpoints
|
// Setup body parser
|
||||||
const app = devServer.app;
|
const app = devServer.app;
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
// --- Auth API ---
|
// Setup all API routes from extracted modules
|
||||||
app.post('/api/login', (req, res) => {
|
setupAllApis(app, {
|
||||||
const { username, password } = req.body;
|
db,
|
||||||
try {
|
bcrypt,
|
||||||
const stmt = db.prepare('SELECT * FROM users WHERE username = ?');
|
jwt,
|
||||||
const user = stmt.get(username);
|
JWT_SECRET,
|
||||||
|
OUTPUT_CHANNELS,
|
||||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
OUTPUT_BINDINGS,
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
runRules,
|
||||||
}
|
activeRuleIds
|
||||||
|
|
||||||
const token = jwt.sign({
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
role: user.role
|
|
||||||
}, JWT_SECRET, { expiresIn: '24h' });
|
|
||||||
|
|
||||||
res.json({ token, role: user.role, username: user.username });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Middleware to check auth (Optional for read, required for write)
|
// Start rule runner
|
||||||
const checkAuth = (req, res, next) => {
|
const ruleRunnerInterval = setInterval(runRules, RULE_RUNNER_INTERVAL);
|
||||||
const authHeader = req.headers.authorization;
|
console.log(`[RuleRunner] Started background job (${RULE_RUNNER_INTERVAL / 1000}s interval)`);
|
||||||
if (authHeader) {
|
|
||||||
const token = authHeader.split(' ')[1];
|
|
||||||
jwt.verify(token, JWT_SECRET, (err, user) => {
|
|
||||||
if (user) req.user = user;
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const requireAdmin = (req, res, next) => {
|
|
||||||
if (!req.user || req.user.role !== 'admin') {
|
|
||||||
return res.status(403).json({ error: 'Admin access required' });
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
||||||
app.use('/api/views', checkAuth);
|
|
||||||
|
|
||||||
// --- Views API ---
|
|
||||||
app.post('/api/views', requireAdmin, (req, res) => {
|
|
||||||
const { name, config } = req.body;
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('INSERT INTO views (name, config, created_by) VALUES (?, ?, ?)');
|
|
||||||
const info = stmt.run(name, JSON.stringify(config), req.user.id);
|
|
||||||
global.insertChangelog(req.user.username, `Created view "${name}"`);
|
|
||||||
res.json({ id: info.lastInsertRowid, name, config });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Publicly list views
|
|
||||||
app.get('/api/views', (req, res) => {
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('SELECT * FROM views ORDER BY position ASC, id ASC');
|
|
||||||
const rows = stmt.all();
|
|
||||||
const views = rows.map(row => {
|
|
||||||
try {
|
|
||||||
return { ...row, config: JSON.parse(row.config) };
|
|
||||||
} catch (e) {
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
res.json(views);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/views/:id', (req, res) => {
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('SELECT * FROM views WHERE id = ?');
|
|
||||||
const view = stmt.get(req.params.id);
|
|
||||||
if (view) {
|
|
||||||
view.config = JSON.parse(view.config);
|
|
||||||
res.json(view);
|
|
||||||
} else {
|
|
||||||
res.status(404).json({ error: 'View not found' });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete View
|
|
||||||
app.delete('/api/views/:id', requireAdmin, (req, res) => {
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('DELETE FROM views WHERE id = ?');
|
|
||||||
// Get name before delete for logging
|
|
||||||
const viewName = db.prepare('SELECT name FROM views WHERE id = ?').get(req.params.id)?.name || 'Unknown View';
|
|
||||||
const info = stmt.run(req.params.id);
|
|
||||||
if (info.changes > 0) {
|
|
||||||
global.insertChangelog(req.user.username, `Deleted view "${viewName}" (ID: ${req.params.id})`);
|
|
||||||
res.json({ success: true });
|
|
||||||
} else {
|
|
||||||
res.status(404).json({ error: 'View not found' });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update View
|
|
||||||
app.put('/api/views/:id', requireAdmin, (req, res) => {
|
|
||||||
const { name, config } = req.body;
|
|
||||||
try {
|
|
||||||
// Get old view for comparison
|
|
||||||
const oldView = db.prepare('SELECT * FROM views WHERE id = ?').get(req.params.id);
|
|
||||||
if (!oldView) {
|
|
||||||
return res.status(404).json({ error: 'View not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const stmt = db.prepare('UPDATE views SET name = ?, config = ? WHERE id = ?');
|
|
||||||
const info = stmt.run(name, JSON.stringify(config), req.params.id);
|
|
||||||
if (info.changes > 0) {
|
|
||||||
// Build detailed changelog
|
|
||||||
const changes = [];
|
|
||||||
|
|
||||||
// Check name change
|
|
||||||
if (oldView.name !== name) {
|
|
||||||
changes.push(`renamed: "${oldView.name}" → "${name}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse configs for comparison
|
|
||||||
let oldConfig = {};
|
|
||||||
try { oldConfig = JSON.parse(oldView.config || '{}'); } catch (e) { }
|
|
||||||
const newConfig = config || {};
|
|
||||||
|
|
||||||
// Compare channels
|
|
||||||
const oldChannels = (oldConfig.channels || []).map(ch =>
|
|
||||||
typeof ch === 'string' ? ch : ch.channel
|
|
||||||
);
|
|
||||||
const newChannels = (newConfig.channels || []).map(ch =>
|
|
||||||
typeof ch === 'string' ? ch : ch.channel
|
|
||||||
);
|
|
||||||
|
|
||||||
const added = newChannels.filter(ch => !oldChannels.includes(ch));
|
|
||||||
const removed = oldChannels.filter(ch => !newChannels.includes(ch));
|
|
||||||
|
|
||||||
if (added.length > 0) {
|
|
||||||
changes.push(`added channels: ${added.join(', ')}`);
|
|
||||||
}
|
|
||||||
if (removed.length > 0) {
|
|
||||||
changes.push(`removed channels: ${removed.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for color/fill changes
|
|
||||||
const oldChannelConfigs = {};
|
|
||||||
(oldConfig.channels || []).forEach(ch => {
|
|
||||||
if (typeof ch === 'object') {
|
|
||||||
oldChannelConfigs[ch.channel] = ch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const newChannelConfigs = {};
|
|
||||||
(newConfig.channels || []).forEach(ch => {
|
|
||||||
if (typeof ch === 'object') {
|
|
||||||
newChannelConfigs[ch.channel] = ch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const colorChanges = [];
|
|
||||||
for (const ch of newChannels) {
|
|
||||||
const oldCh = oldChannelConfigs[ch] || {};
|
|
||||||
const newCh = newChannelConfigs[ch] || {};
|
|
||||||
if (oldCh.color !== newCh.color || oldCh.fillColor !== newCh.fillColor) {
|
|
||||||
colorChanges.push(ch.split(':').pop()); // Just the channel name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (colorChanges.length > 0) {
|
|
||||||
changes.push(`colors changed for: ${colorChanges.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check order change
|
|
||||||
if (added.length === 0 && removed.length === 0 &&
|
|
||||||
JSON.stringify(oldChannels) !== JSON.stringify(newChannels)) {
|
|
||||||
changes.push('channel order changed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeText = changes.length > 0
|
|
||||||
? `Updated view "${name}": ${changes.join('; ')}`
|
|
||||||
: `Updated view "${name}" (no significant changes)`;
|
|
||||||
global.insertChangelog(req.user.username, changeText);
|
|
||||||
|
|
||||||
res.json({ id: req.params.id, name, config });
|
|
||||||
} else {
|
|
||||||
res.status(404).json({ error: 'View not found' });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reorder Views
|
|
||||||
app.post('/api/views/reorder', requireAdmin, (req, res) => {
|
|
||||||
const { order } = req.body;
|
|
||||||
console.log('[API] Reorder request:', order);
|
|
||||||
if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' });
|
|
||||||
|
|
||||||
const updateStmt = db.prepare('UPDATE views SET position = ? WHERE id = ?');
|
|
||||||
const updateMany = db.transaction((items) => {
|
|
||||||
for (const item of items) {
|
|
||||||
console.log('[API] Updating view', item.id, 'to position', item.position);
|
|
||||||
updateStmt.run(item.position, item.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
updateMany(order);
|
|
||||||
console.log('[API] Reorder successful');
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[API] Reorder error:', err);
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// RULES API
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Apply checkAuth middleware to rules API routes
|
|
||||||
app.use('/api/rules', checkAuth);
|
|
||||||
|
|
||||||
// Virtual output channel definitions
|
|
||||||
// Virtual output channel definitions - MOVED TO GLOBAL SCOPE
|
|
||||||
|
|
||||||
// GET /api/outputs - List output channel definitions
|
|
||||||
app.get('/api/outputs', (req, res) => {
|
|
||||||
res.json(OUTPUT_CHANNELS);
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/outputs/values - Get current output values
|
|
||||||
app.get('/api/outputs/values', (req, res) => {
|
|
||||||
try {
|
|
||||||
if (!db) throw new Error('Database not connected');
|
|
||||||
const result = {};
|
|
||||||
const stmt = db.prepare(`
|
|
||||||
SELECT channel, value FROM output_events
|
|
||||||
WHERE id IN (
|
|
||||||
SELECT MAX(id) FROM output_events GROUP BY channel
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
const rows = stmt.all();
|
|
||||||
rows.forEach(row => {
|
|
||||||
result[row.channel] = row.value;
|
|
||||||
});
|
|
||||||
// Fill in defaults for missing channels
|
|
||||||
OUTPUT_CHANNELS.forEach(ch => {
|
|
||||||
if (result[ch.channel] === undefined) {
|
|
||||||
result[ch.channel] = 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
res.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/outputs/commands - Get desired states for bound devices
|
|
||||||
// Agents poll this to get commands. Returns { "device:channel": { state: 0|1 } }
|
|
||||||
app.get('/api/outputs/commands', (req, res) => {
|
|
||||||
try {
|
|
||||||
if (!db) throw new Error('Database not connected');
|
|
||||||
|
|
||||||
// Get current output values
|
|
||||||
const stmt = db.prepare(`
|
|
||||||
SELECT channel, value FROM output_events
|
|
||||||
WHERE id IN (
|
|
||||||
SELECT MAX(id) FROM output_events GROUP BY channel
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
const rows = stmt.all();
|
|
||||||
const outputValues = {};
|
|
||||||
rows.forEach(row => {
|
|
||||||
outputValues[row.channel] = row.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Map to device commands
|
|
||||||
const commands = {};
|
|
||||||
for (const [outputChannel, binding] of Object.entries(OUTPUT_BINDINGS)) {
|
|
||||||
const value = outputValues[outputChannel] ?? 0;
|
|
||||||
const deviceKey = `${binding.device}:${binding.channel}`;
|
|
||||||
commands[deviceKey] = {
|
|
||||||
state: value > 0 ? 1 : 0,
|
|
||||||
source: outputChannel
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(commands);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/rules/status - Get currently active rule IDs
|
|
||||||
app.get('/api/rules/status', (req, res) => {
|
|
||||||
res.json({ activeIds: Array.from(activeRuleIds) });
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/rules - List all rules
|
|
||||||
app.get('/api/rules', (req, res) => {
|
|
||||||
try {
|
|
||||||
if (!db) throw new Error('Database not connected');
|
|
||||||
const stmt = db.prepare('SELECT * FROM rules ORDER BY position ASC, id ASC');
|
|
||||||
const rows = stmt.all();
|
|
||||||
const rules = rows.map(row => ({
|
|
||||||
...row,
|
|
||||||
conditions: JSON.parse(row.conditions || '{}'),
|
|
||||||
action: JSON.parse(row.action || '{}')
|
|
||||||
}));
|
|
||||||
res.json(rules);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// POST /api/rules - Create rule (admin only)
|
|
||||||
app.post('/api/rules', requireAdmin, (req, res) => {
|
|
||||||
const { name, type = 'static', enabled = 1, conditions, action } = req.body;
|
|
||||||
if (!name || !conditions || !action) {
|
|
||||||
return res.status(400).json({ error: 'Missing required fields: name, conditions, action' });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare(`
|
|
||||||
INSERT INTO rules (name, type, enabled, conditions, action, created_by)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
const info = stmt.run(
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
enabled ? 1 : 0,
|
|
||||||
JSON.stringify(conditions),
|
|
||||||
JSON.stringify(action),
|
|
||||||
req.user?.id || null
|
|
||||||
);
|
|
||||||
runRules(); // Trigger rules immediately
|
|
||||||
global.insertChangelog(req.user?.username || 'admin', `Created rule "${name}"`);
|
|
||||||
res.json({ id: info.lastInsertRowid, name, type, enabled, conditions, action });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// PUT /api/rules/:id - Update rule (admin only)
|
|
||||||
app.put('/api/rules/:id', requireAdmin, (req, res) => {
|
|
||||||
const { name, type, enabled, conditions, action } = req.body;
|
|
||||||
try {
|
|
||||||
// Get old rule for comparison
|
|
||||||
const oldRule = db.prepare('SELECT * FROM rules WHERE id = ?').get(req.params.id);
|
|
||||||
if (!oldRule) {
|
|
||||||
return res.status(404).json({ error: 'Rule not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
|
||||||
UPDATE rules SET name = ?, type = ?, enabled = ?, conditions = ?, action = ?, updated_at = datetime('now')
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
const info = stmt.run(
|
|
||||||
name,
|
|
||||||
type || 'static',
|
|
||||||
enabled ? 1 : 0,
|
|
||||||
JSON.stringify(conditions),
|
|
||||||
JSON.stringify(action),
|
|
||||||
req.params.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (info.changes > 0) {
|
|
||||||
runRules(); // Trigger rules immediately
|
|
||||||
|
|
||||||
// Build detailed changelog
|
|
||||||
const changes = [];
|
|
||||||
if (oldRule.name !== name) {
|
|
||||||
changes.push(`name: "${oldRule.name}" → "${name}"`);
|
|
||||||
}
|
|
||||||
if (!!oldRule.enabled !== !!enabled) {
|
|
||||||
changes.push(`enabled: ${oldRule.enabled ? 'on' : 'off'} → ${enabled ? 'on' : 'off'}`);
|
|
||||||
}
|
|
||||||
const oldConditions = oldRule.conditions || '{}';
|
|
||||||
const newConditions = JSON.stringify(conditions);
|
|
||||||
if (oldConditions !== newConditions) {
|
|
||||||
changes.push('conditions changed');
|
|
||||||
}
|
|
||||||
const oldAction = oldRule.action || '{}';
|
|
||||||
const newAction = JSON.stringify(action);
|
|
||||||
if (oldAction !== newAction) {
|
|
||||||
// Parse to show what changed in action
|
|
||||||
try {
|
|
||||||
const oldA = JSON.parse(oldAction);
|
|
||||||
const newA = action;
|
|
||||||
if (oldA.channel !== newA.channel) {
|
|
||||||
changes.push(`action channel: ${oldA.channel} → ${newA.channel}`);
|
|
||||||
}
|
|
||||||
if (JSON.stringify(oldA.value) !== JSON.stringify(newA.value)) {
|
|
||||||
changes.push(`action value: ${JSON.stringify(oldA.value)} → ${JSON.stringify(newA.value)}`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
changes.push('action changed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeText = changes.length > 0
|
|
||||||
? `Updated rule "${name}": ${changes.join(', ')}`
|
|
||||||
: `Updated rule "${name}" (no changes)`;
|
|
||||||
global.insertChangelog(req.user?.username || 'admin', changeText);
|
|
||||||
|
|
||||||
res.json({ id: req.params.id, name, type, enabled, conditions, action });
|
|
||||||
} else {
|
|
||||||
res.status(404).json({ error: 'Rule not found' });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// DELETE /api/rules/:id - Delete rule (admin only)
|
|
||||||
app.delete('/api/rules/:id', requireAdmin, (req, res) => {
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('DELETE FROM rules WHERE id = ?');
|
|
||||||
// Get name before delete
|
|
||||||
const ruleName = db.prepare('SELECT name FROM rules WHERE id = ?').get(req.params.id)?.name || 'Unknown Rule';
|
|
||||||
const info = stmt.run(req.params.id);
|
|
||||||
if (info.changes > 0) {
|
|
||||||
runRules(); // Trigger rules immediately
|
|
||||||
global.insertChangelog(req.user?.username || 'admin', `Deleted rule "${ruleName}" (ID: ${req.params.id})`);
|
|
||||||
res.json({ success: true });
|
|
||||||
} else {
|
|
||||||
res.status(404).json({ error: 'Rule not found' });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/rules/reorder - Reorder rules (admin only)
|
|
||||||
app.post('/api/rules/reorder', requireAdmin, (req, res) => {
|
|
||||||
const { order } = req.body;
|
|
||||||
if (!Array.isArray(order)) return res.status(400).json({ error: 'Invalid format' });
|
|
||||||
|
|
||||||
const updateStmt = db.prepare('UPDATE rules SET position = ? WHERE id = ?');
|
|
||||||
const updateMany = db.transaction((items) => {
|
|
||||||
for (const item of items) {
|
|
||||||
updateStmt.run(item.position, item.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
updateMany(order);
|
|
||||||
runRules(); // Trigger rules immediately
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// RULE RUNNER (Background Job)
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// Rule Engine functions moved to global scope
|
|
||||||
|
|
||||||
// Start rule runner (every 10 seconds)
|
|
||||||
const ruleRunnerInterval = setInterval(runRules, 10000);
|
|
||||||
console.log('[RuleRunner] Started background job (10s interval)');
|
|
||||||
|
|
||||||
// Clean up on server close
|
// Clean up on server close
|
||||||
devServer.server?.on('close', () => {
|
devServer.server?.on('close', () => {
|
||||||
@@ -1119,155 +667,6 @@ module.exports = {
|
|||||||
console.log('[RuleRunner] Stopped background job');
|
console.log('[RuleRunner] Stopped background job');
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/devices
|
|
||||||
// Returns list of unique device/channel pairs (sensors + outputs)
|
|
||||||
app.get('/api/devices', (req, res) => {
|
|
||||||
try {
|
|
||||||
if (!db) throw new Error('Database not connected');
|
|
||||||
// Get sensor channels
|
|
||||||
const sensorStmt = db.prepare("SELECT DISTINCT device, channel FROM sensor_events WHERE data_type = 'number' ORDER BY device, channel");
|
|
||||||
const sensorRows = sensorStmt.all();
|
|
||||||
|
|
||||||
// Add output channels with 'output' as device
|
|
||||||
const outputRows = OUTPUT_CHANNELS.map(ch => ({
|
|
||||||
device: 'output',
|
|
||||||
channel: ch.channel
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.json([...sensorRows, ...outputRows]);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/readings
|
|
||||||
// Query params: devices (comma sep), channels (comma sep), since (timestamp)
|
|
||||||
// Actually, user wants "Last 24h".
|
|
||||||
// We can accept `since` or valid ISO string.
|
|
||||||
// Expected params: `?device=x&channel=y` (single) or query for multiple?
|
|
||||||
// User asked for "chart that is refreshed once a minute... display the last 24 hours with the devices/channels previously selected"
|
|
||||||
// Efficient query: select * from sensor_events where timestamp > ? and (device,channel) IN (...)
|
|
||||||
// For simplicity, let's allow fetching by multiple devices/channels or just all for last 24h and filter client side?
|
|
||||||
// No, database filtering is better.
|
|
||||||
// Let's support ?since=ISO_DATE
|
|
||||||
|
|
||||||
app.get('/api/readings', (req, res) => {
|
|
||||||
try {
|
|
||||||
if (!db) throw new Error('Database not connected');
|
|
||||||
const { since, until } = req.query;
|
|
||||||
const startTime = since || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
||||||
const endTime = until || new Date().toISOString();
|
|
||||||
|
|
||||||
const requestedSensorChannels = []; // [{device, channel}]
|
|
||||||
const requestedOutputChannels = []; // [channel]
|
|
||||||
|
|
||||||
if (req.query.selection) {
|
|
||||||
const selections = req.query.selection.split(',');
|
|
||||||
selections.forEach(s => {
|
|
||||||
const lastColonIndex = s.lastIndexOf(':');
|
|
||||||
if (lastColonIndex !== -1) {
|
|
||||||
const d = s.substring(0, lastColonIndex);
|
|
||||||
const c = s.substring(lastColonIndex + 1);
|
|
||||||
if (d === 'output') {
|
|
||||||
requestedOutputChannels.push(c);
|
|
||||||
} else {
|
|
||||||
requestedSensorChannels.push({ device: d, channel: c });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {};
|
|
||||||
|
|
||||||
// 1. Fetch sensor data
|
|
||||||
if (requestedSensorChannels.length > 0) {
|
|
||||||
let sql = 'SELECT * FROM sensor_events WHERE timestamp > ? AND timestamp <= ? ';
|
|
||||||
const params = [startTime, endTime];
|
|
||||||
|
|
||||||
const placeholders = [];
|
|
||||||
requestedSensorChannels.forEach(ch => {
|
|
||||||
placeholders.push('(device = ? AND channel = ?)');
|
|
||||||
params.push(ch.device, ch.channel);
|
|
||||||
});
|
|
||||||
if (placeholders.length > 0) {
|
|
||||||
sql += `AND (${placeholders.join(' OR ')}) `;
|
|
||||||
}
|
|
||||||
sql += 'ORDER BY timestamp ASC';
|
|
||||||
|
|
||||||
const rows = db.prepare(sql).all(...params);
|
|
||||||
|
|
||||||
// Backfill for sensors
|
|
||||||
const backfillStmt = db.prepare(`
|
|
||||||
SELECT * FROM sensor_events
|
|
||||||
WHERE device = ? AND channel = ?
|
|
||||||
AND timestamp <= ?
|
|
||||||
AND (until >= ? OR until IS NULL)
|
|
||||||
ORDER BY timestamp DESC LIMIT 1
|
|
||||||
`);
|
|
||||||
|
|
||||||
const backfillRows = [];
|
|
||||||
requestedSensorChannels.forEach(ch => {
|
|
||||||
const prev = backfillStmt.get(ch.device, ch.channel, startTime, startTime);
|
|
||||||
if (prev) backfillRows.push(prev);
|
|
||||||
});
|
|
||||||
|
|
||||||
[...backfillRows, ...rows].forEach(row => {
|
|
||||||
const key = `${row.device}:${row.channel}`;
|
|
||||||
if (!result[key]) result[key] = [];
|
|
||||||
const pt = [row.timestamp, row.value];
|
|
||||||
if (row.until) pt.push(row.until);
|
|
||||||
result[key].push(pt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fetch output data
|
|
||||||
if (requestedOutputChannels.length > 0) {
|
|
||||||
let sql = 'SELECT * FROM output_events WHERE timestamp > ? AND timestamp <= ? ';
|
|
||||||
const params = [startTime, endTime];
|
|
||||||
|
|
||||||
const placeholders = requestedOutputChannels.map(() => 'channel = ?');
|
|
||||||
sql += `AND (${placeholders.join(' OR ')}) `;
|
|
||||||
params.push(...requestedOutputChannels);
|
|
||||||
sql += 'ORDER BY timestamp ASC';
|
|
||||||
|
|
||||||
const rows = db.prepare(sql).all(...params);
|
|
||||||
|
|
||||||
// Backfill for outputs
|
|
||||||
const backfillStmt = db.prepare(`
|
|
||||||
SELECT * FROM output_events
|
|
||||||
WHERE channel = ?
|
|
||||||
AND timestamp <= ?
|
|
||||||
AND (until >= ? OR until IS NULL)
|
|
||||||
ORDER BY timestamp DESC LIMIT 1
|
|
||||||
`);
|
|
||||||
|
|
||||||
const backfillRows = [];
|
|
||||||
requestedOutputChannels.forEach(ch => {
|
|
||||||
const prev = backfillStmt.get(ch, startTime, startTime);
|
|
||||||
if (prev) {
|
|
||||||
backfillRows.push(prev);
|
|
||||||
} else {
|
|
||||||
// No data at all - add default 0 value at startTime
|
|
||||||
backfillRows.push({ channel: ch, timestamp: startTime, value: 0, until: null });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
[...backfillRows, ...rows].forEach(row => {
|
|
||||||
const key = `output:${row.channel}`;
|
|
||||||
if (!result[key]) result[key] = [];
|
|
||||||
const pt = [row.timestamp, row.value];
|
|
||||||
if (row.until) pt.push(row.until);
|
|
||||||
result[key].push(pt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return middlewares;
|
return middlewares;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user