alarms
This commit is contained in:
150
package-lock.json
generated
150
package-lock.json
generated
@@ -31,6 +31,7 @@
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"style-loader": "^4.0.0",
|
||||
"telegraf": "^4.16.3",
|
||||
"webpack": "^5.104.1",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-middleware": "^7.4.5"
|
||||
@@ -2521,6 +2522,12 @@
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@telegraf/types": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz",
|
||||
"integrity": "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
@@ -2801,6 +2808,18 @@
|
||||
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -3174,12 +3193,34 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-alloc": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz",
|
||||
"integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-alloc-unsafe": "^1.1.0",
|
||||
"buffer-fill": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-alloc-unsafe": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz",
|
||||
"integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-fill": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
|
||||
"integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -4012,6 +4053,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
@@ -5071,6 +5121,15 @@
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -5156,6 +5215,26 @@
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
@@ -5272,6 +5351,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-timeout": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-4.1.0.tgz",
|
||||
"integrity": "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
@@ -5987,12 +6075,30 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-compare": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/safe-compare/-/safe-compare-1.1.4.tgz",
|
||||
"integrity": "sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-alloc": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sandwich-stream": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sandwich-stream/-/sandwich-stream-2.0.2.tgz",
|
||||
"integrity": "sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -6492,6 +6598,28 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/telegraf": {
|
||||
"version": "4.16.3",
|
||||
"resolved": "https://registry.npmjs.org/telegraf/-/telegraf-4.16.3.tgz",
|
||||
"integrity": "sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@telegraf/types": "^7.1.0",
|
||||
"abort-controller": "^3.0.0",
|
||||
"debug": "^4.3.4",
|
||||
"mri": "^1.2.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"p-timeout": "^4.1.0",
|
||||
"safe-compare": "^1.1.4",
|
||||
"sandwich-stream": "^2.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"telegraf": "lib/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.44.1",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
|
||||
@@ -6575,6 +6703,12 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tree-dump": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz",
|
||||
@@ -6754,6 +6888,12 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.104.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
|
||||
@@ -6926,6 +7066,16 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"style-loader": "^4.0.0",
|
||||
"telegraf": "^4.16.3",
|
||||
"webpack": "^5.104.1",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-middleware": "^7.4.5"
|
||||
|
||||
319
server.js
319
server.js
@@ -8,6 +8,7 @@ import webpackDevMiddleware from 'webpack-dev-middleware';
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import config from './webpack.config.js';
|
||||
import { Telegraf } from 'telegraf';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -20,6 +21,7 @@ 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';
|
||||
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
|
||||
|
||||
// Device Type Mapping
|
||||
const DEVICE_TYPES = {
|
||||
@@ -81,13 +83,28 @@ db.exec(`
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS alarms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
trigger_type TEXT NOT NULL,
|
||||
trigger_data TEXT NOT NULL,
|
||||
action_type TEXT NOT NULL DEFAULT 'telegram',
|
||||
action_data TEXT NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
color_tag TEXT DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: Add sort_order and color_tag if they don't exist
|
||||
try {
|
||||
db.exec('ALTER TABLE rules ADD COLUMN sort_order INTEGER DEFAULT 0');
|
||||
} catch (e) { /* column already exists */ }
|
||||
try {
|
||||
db.exec('ALTER TABLE rules ADD COLUMN color_tag TEXT DEFAULT NULL');
|
||||
} catch (e) { /* column already exists */ }
|
||||
try { db.exec('ALTER TABLE rules ADD COLUMN sort_order INTEGER DEFAULT 0'); } catch (e) { }
|
||||
try { db.exec('ALTER TABLE rules ADD COLUMN color_tag TEXT DEFAULT NULL'); } catch (e) { }
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN telegram_id TEXT DEFAULT NULL'); } catch (e) { }
|
||||
try { db.exec('ALTER TABLE alarms ADD COLUMN sort_order INTEGER DEFAULT 0'); } catch (e) { }
|
||||
try { db.exec('ALTER TABLE alarms ADD COLUMN color_tag TEXT DEFAULT NULL'); } catch (e) { }
|
||||
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO readings (dev_id, dev_name, port, port_name, temp_c, humidity, vpd, fan_speed, on_speed, off_speed)
|
||||
@@ -111,6 +128,147 @@ const updateRuleOrder = db.prepare('UPDATE rules SET sort_order = ? WHERE id = ?
|
||||
const deleteRule = db.prepare('DELETE FROM rules WHERE id = ?');
|
||||
const getMaxSortOrder = db.prepare('SELECT COALESCE(MAX(sort_order), 0) as max_order FROM rules');
|
||||
|
||||
// Alarms prepared statements
|
||||
const getAllAlarms = db.prepare('SELECT * FROM alarms ORDER BY sort_order, id');
|
||||
const getAlarmById = db.prepare('SELECT * FROM alarms WHERE id = ?');
|
||||
const insertAlarm = db.prepare(`
|
||||
INSERT INTO alarms (name, enabled, trigger_type, trigger_data, action_type, action_data, sort_order, color_tag)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const updateAlarm = db.prepare(`
|
||||
UPDATE alarms SET name = ?, enabled = ?, trigger_type = ?, trigger_data = ?, action_type = ?, action_data = ?, color_tag = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
const updateAlarmOrder = db.prepare('UPDATE alarms SET sort_order = ? WHERE id = ?');
|
||||
const deleteAlarm = db.prepare('DELETE FROM alarms WHERE id = ?');
|
||||
const getMaxAlarmSortOrder = db.prepare('SELECT COALESCE(MAX(sort_order), 0) as max_order FROM alarms');
|
||||
|
||||
// User Profile statements
|
||||
const updateUserTelegramId = db.prepare('UPDATE users SET telegram_id = ? WHERE id = ?');
|
||||
const getAllTelegramUsers = db.prepare("SELECT telegram_id FROM users WHERE telegram_id IS NOT NULL AND telegram_id != ''");
|
||||
|
||||
// --- TELEGRAM BOT ---
|
||||
let bot = null;
|
||||
if (TELEGRAM_BOT_TOKEN) {
|
||||
bot = new Telegraf(TELEGRAM_BOT_TOKEN);
|
||||
|
||||
// Simple start handler to give user their ID
|
||||
bot.start((ctx) => {
|
||||
const userId = ctx.from.id;
|
||||
ctx.reply(`Hello! Your Telegram ID is: ${userId}\n\nPlease copy this ID and paste it into your profile on the Dashboard.`);
|
||||
});
|
||||
|
||||
bot.launch().catch(err => console.error('Telegram Bot Launch Error:', err));
|
||||
console.log('Telegram Bot initialized.');
|
||||
|
||||
// Graceful stop
|
||||
process.once('SIGINT', () => bot.stop('SIGINT'));
|
||||
process.once('SIGTERM', () => bot.stop('SIGTERM'));
|
||||
} else {
|
||||
console.warn('TELEGRAM_BOT_TOKEN not set. Alarms will not send notifications.');
|
||||
}
|
||||
|
||||
async function sendTelegramNotification(message) {
|
||||
if (!bot) return;
|
||||
try {
|
||||
const users = getAllTelegramUsers.all();
|
||||
for (const u of users) {
|
||||
await bot.telegram.sendMessage(u.telegram_id, message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send Telegram notification:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- ALARM LOGIC ---
|
||||
// Simple state tracking to avoid spamming: alarmId -> lastTriggeredTime
|
||||
const alarmStates = new Map();
|
||||
|
||||
function evaluateAlarms(readings) {
|
||||
const alarms = getAllAlarms.all();
|
||||
|
||||
alarms.forEach(alarm => {
|
||||
if (!alarm.enabled) return;
|
||||
|
||||
// Skip if recently triggered (debounce 5 mins)
|
||||
const lastTrigger = alarmStates.get(alarm.id);
|
||||
if (lastTrigger && (Date.now() - lastTrigger) < 5 * 60 * 1000) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const trigger = JSON.parse(alarm.trigger_data);
|
||||
let triggered = false;
|
||||
|
||||
// Check Sensors
|
||||
if (trigger.sensors && trigger.sensors.length > 0) {
|
||||
const results = trigger.sensors.map(cond => {
|
||||
const sensorId = cond.sensor; // e.g., "Wall Display:temp"
|
||||
const operator = cond.operator;
|
||||
const threshold = cond.value;
|
||||
|
||||
// Find matching reading
|
||||
// sensorId format: "DevName:temp" or "DevName:humid" or "DevName:Port:level"
|
||||
const parts = sensorId.split(':');
|
||||
const devName = parts[0];
|
||||
const type = parts.length === 2 ? parts[1] : parts[2]; // temp/humid OR level
|
||||
|
||||
// Find the latest reading for this device+type
|
||||
// readings is an array of objects passed from poll()
|
||||
// But poll inserts individually. We need to aggregate or pass the full context.
|
||||
// Implementation Detail: poll() inserts one by one.
|
||||
// Better approach: poll() collects all readings, THEN calls evaluateAlarms(allReadings).
|
||||
|
||||
const reading = readings.find(r => r.devName === devName);
|
||||
if (!reading) return false;
|
||||
|
||||
let value = null;
|
||||
if (type === 'temp') value = reading.temp_c;
|
||||
if (type === 'humidity') value = reading.humidity;
|
||||
if (type === 'level') {
|
||||
// reading.ports is array of {port, temp, hum, speak...}
|
||||
const portNum = parseInt(parts[1]);
|
||||
const p = reading.ports.find(rp => rp.port === portNum);
|
||||
if (p) value = p.speak;
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) return false;
|
||||
|
||||
// Numeric comparison
|
||||
switch (operator) {
|
||||
case '>': return value > threshold;
|
||||
case '<': return value < threshold;
|
||||
case '>=': return value >= threshold;
|
||||
case '<=': return value <= threshold;
|
||||
case '==': return value == threshold;
|
||||
default: return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (trigger.sensorLogic === 'or') {
|
||||
triggered = results.some(r => r);
|
||||
} else {
|
||||
triggered = results.every(r => r);
|
||||
}
|
||||
}
|
||||
|
||||
// Time triggers are handled differently (cron-like), skipping for now as per "sensor trigger" focus in plan,
|
||||
// but structure supports it. If time trigger is ONLY trigger, we'd need a separate loop.
|
||||
// Assuming sensor triggers for now based on context of "readings".
|
||||
|
||||
if (triggered) {
|
||||
console.log(`ALARM TRIGGERED: ${alarm.name}`);
|
||||
alarmStates.set(alarm.id, Date.now());
|
||||
const action = JSON.parse(alarm.action_data);
|
||||
const msg = `🚨 ALARM: ${alarm.name}\n\n${action.message || 'No message'}\n\nSeverity: ${action.severity || 'Info'}`;
|
||||
sendTelegramNotification(msg);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error evaluating alarm ${alarm.id}:`, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- AC INFINITY API LOGIC ---
|
||||
let token = null;
|
||||
|
||||
@@ -197,6 +355,8 @@ async function poll() {
|
||||
const devices = await getDeviceList(token);
|
||||
console.log(`[${new Date().toISOString()}] Data Fetch: Found ${devices.length} controllers.`);
|
||||
|
||||
const currentReadings = []; // Collect for alarm evaluation
|
||||
|
||||
for (const device of devices) {
|
||||
const ports = device.deviceInfo && device.deviceInfo.ports ? device.deviceInfo.ports : [];
|
||||
|
||||
@@ -205,6 +365,14 @@ async function poll() {
|
||||
continue;
|
||||
}
|
||||
|
||||
const deviceReadings = {
|
||||
devName: device.devName,
|
||||
devId: device.devId,
|
||||
temp_c: null, // Device level temp (some controllers have it)
|
||||
humidity: null,
|
||||
ports: []
|
||||
};
|
||||
|
||||
for (const portInfo of ports) {
|
||||
// Filter by online status
|
||||
if (portInfo.online === 1) {
|
||||
@@ -216,6 +384,11 @@ async function poll() {
|
||||
const hum = settings.humidity ? settings.humidity / 100 : null;
|
||||
const vpd = settings.vpdnums ? settings.vpdnums / 100 : null;
|
||||
|
||||
// Some devices report environment at "device" level, others at port.
|
||||
// We'll capture first valid env reading as device reading for simplicity
|
||||
if (tempC !== null && deviceReadings.temp_c === null) deviceReadings.temp_c = tempC;
|
||||
if (hum !== null && deviceReadings.humidity === null) deviceReadings.humidity = hum;
|
||||
|
||||
// Determine Port Name
|
||||
let portName = portInfo.portName;
|
||||
if (!portName || portName.startsWith('Port ')) {
|
||||
@@ -242,11 +415,24 @@ async function poll() {
|
||||
if (portName === 'Fan') label = 'Fan Speed';
|
||||
if (portName === 'Light') label = 'Brightness';
|
||||
|
||||
deviceReadings.ports.push({
|
||||
port,
|
||||
portName,
|
||||
speak: settings.speak,
|
||||
temp: tempC,
|
||||
hum
|
||||
});
|
||||
|
||||
console.log(`Saved reading for ${device.devName} (${portName}): ${tempC}°C, ${hum}%, ${label}: ${settings.speak}/10`);
|
||||
}
|
||||
}
|
||||
}
|
||||
currentReadings.push(deviceReadings);
|
||||
}
|
||||
|
||||
// Evaluate Alarms
|
||||
evaluateAlarms(currentReadings);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Polling error:', error.message);
|
||||
token = null; // Reset token to force re-login
|
||||
@@ -281,7 +467,7 @@ app.post('/api/auth/login', async (req, res) => {
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({ token, user: { username: user.username, role: user.role } });
|
||||
res.json({ token, user: { username: user.username, role: user.role, telegramId: user.telegram_id } });
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
@@ -299,7 +485,12 @@ app.get('/api/auth/me', (req, res) => {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
res.json({ user: { username: decoded.username, role: decoded.role } });
|
||||
// Fetch fresh user data to include Telegram ID
|
||||
const user = db.prepare('SELECT username, role, telegram_id FROM users WHERE id = ?').get(decoded.id);
|
||||
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
res.json({ user: { username: user.username, role: user.role, telegramId: user.telegram_id } });
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
@@ -308,6 +499,21 @@ app.get('/api/auth/me', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/auth/profile - user updates their own profile
|
||||
app.put('/api/auth/profile', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { telegramId } = req.body;
|
||||
if (telegramId === undefined) {
|
||||
return res.status(400).json({ error: 'telegramId is required' });
|
||||
}
|
||||
updateUserTelegramId.run(telegramId, req.user.id);
|
||||
res.json({ success: true, telegramId });
|
||||
} catch (err) {
|
||||
console.error('Profile update error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- AUTH MIDDLEWARE ---
|
||||
function optionalAuth(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
@@ -478,6 +684,103 @@ app.delete('/api/rules/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- ALARMS API ---
|
||||
// GET /api/alarms - public (guests can view)
|
||||
app.get('/api/alarms', (req, res) => {
|
||||
try {
|
||||
const rows = getAllAlarms.all();
|
||||
res.json(rows.map(formatRule)); // formatRule works for alarms too as schema is compatible
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/alarms - admin only
|
||||
app.post('/api/alarms', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const { name, enabled, trigger, action, colorTags } = req.body;
|
||||
if (!name || !trigger || !action) {
|
||||
return res.status(400).json({ error: 'name, trigger, and action required' });
|
||||
}
|
||||
|
||||
// Trigger Type Logic
|
||||
let triggerType = 'combined';
|
||||
if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time';
|
||||
if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor';
|
||||
|
||||
const maxOrder = getMaxAlarmSortOrder.get().max_order;
|
||||
const sortOrder = maxOrder + 1;
|
||||
|
||||
const triggerData = JSON.stringify(trigger);
|
||||
const actionData = JSON.stringify(action); // action is { message: "...", severity: "..." }
|
||||
const colorTagsData = colorTags?.length ? JSON.stringify(colorTags) : null;
|
||||
|
||||
// action_type fixed to 'telegram'
|
||||
const result = insertAlarm.run(name, enabled ? 1 : 0, triggerType, triggerData, 'telegram', actionData, sortOrder, colorTagsData);
|
||||
|
||||
const newAlarm = getAlarmById.get(result.lastInsertRowid);
|
||||
res.status(201).json(formatRule(newAlarm));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/alarms/:id - admin only
|
||||
app.put('/api/alarms/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const existing = getAlarmById.get(id);
|
||||
if (!existing) return res.status(404).json({ error: 'Alarm not found' });
|
||||
|
||||
const { name, enabled, trigger, action, colorTags } = req.body;
|
||||
|
||||
let triggerType = 'combined';
|
||||
if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time';
|
||||
if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor';
|
||||
|
||||
const triggerData = JSON.stringify(trigger);
|
||||
const actionData = JSON.stringify(action);
|
||||
const colorTagsData = colorTags?.length ? JSON.stringify(colorTags) : null;
|
||||
|
||||
updateAlarm.run(name, enabled ? 1 : 0, triggerType, triggerData, 'telegram', actionData, colorTagsData, id);
|
||||
|
||||
const updated = getAlarmById.get(id);
|
||||
res.json(formatRule(updated));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/alarms/reorder - admin only
|
||||
app.put('/api/alarms/reorder', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const { alarmIds } = req.body;
|
||||
if (!Array.isArray(alarmIds)) return res.status(400).json({ error: 'alarmIds array required' });
|
||||
|
||||
alarmIds.forEach((id, index) => {
|
||||
updateAlarmOrder.run(index, id);
|
||||
});
|
||||
|
||||
const rows = getAllAlarms.all();
|
||||
res.json(rows.map(formatRule));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/alarms/:id - admin only
|
||||
app.delete('/api/alarms/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const existing = getAlarmById.get(id);
|
||||
if (!existing) return res.status(404).json({ error: 'Alarm not found' });
|
||||
deleteAlarm.run(id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// API: Devices
|
||||
app.get('/api/devices', (req, res) => {
|
||||
try {
|
||||
|
||||
135
src/client/AlarmCard.js
Normal file
135
src/client/AlarmCard.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Switch,
|
||||
IconButton,
|
||||
Chip,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
|
||||
export default function AlarmCard({ alarm, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags, readOnly }) {
|
||||
// Parse trigger/action data to display summary
|
||||
const trigger = alarm.trigger || {};
|
||||
const action = alarm.action || {};
|
||||
|
||||
// Get color for tag
|
||||
const getTagColor = (tagId) => {
|
||||
const tag = colorTags.find(t => t.id === tagId);
|
||||
return tag ? tag.color : 'transparent';
|
||||
};
|
||||
|
||||
const hasTags = alarm.colorTags && alarm.colorTags.length > 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)',
|
||||
color: '#ebdbb2',
|
||||
border: '1px solid #504945',
|
||||
position: 'relative',
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: readOnly ? 'none' : 'translateY(-2px)',
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.3)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ pb: '16px !important', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
|
||||
{/* Drag Handle / Sort indicators */}
|
||||
{!readOnly && (onMoveUp || onMoveDown) && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<IconButton size="small" onClick={onMoveUp} disabled={!onMoveUp} sx={{ p: 0.5, color: '#a89984' }}>
|
||||
▲
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={onMoveDown} disabled={!onMoveDown} sx={{ p: 0.5, color: '#a89984' }}>
|
||||
▼
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Enabled Switch */}
|
||||
<Switch
|
||||
checked={!!alarm.enabled}
|
||||
onChange={onToggle}
|
||||
disabled={readOnly}
|
||||
color="success"
|
||||
/>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{alarm.name}
|
||||
</Typography>
|
||||
|
||||
{/* Tags */}
|
||||
{hasTags && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{alarm.colorTags.map(tagId => (
|
||||
<Box
|
||||
key={tagId}
|
||||
sx={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
bgcolor: getTagColor(tagId),
|
||||
border: '1px solid #282828'
|
||||
}}
|
||||
title={tagId}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{action.severity && (
|
||||
<Chip
|
||||
label={action.severity.toUpperCase()}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 'bold',
|
||||
bgcolor: action.severity === 'critical' ? '#fb4934' :
|
||||
action.severity === 'warning' ? '#fe8019' : '#83a598',
|
||||
color: '#282828'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{/* Trigger Summary */}
|
||||
{trigger.scheduledTime ? (
|
||||
<span>⏰ {trigger.scheduledTime.time} ({trigger.scheduledTime.days.map(d => d.slice(0, 3)).join(',')})</span>
|
||||
) : trigger.timeRange ? (
|
||||
<span>⏰ {trigger.timeRange.start}-{trigger.timeRange.end}</span>
|
||||
) : trigger.sensors ? (
|
||||
<span>
|
||||
📊 {trigger.sensors.map(s => `${s.sensorLabel || s.sensor} ${s.operator} ${s.value}`).join(trigger.sensorLogic === 'or' ? ' OR ' : ' AND ')}
|
||||
</span>
|
||||
) : (
|
||||
<span>Unknown Trigger</span>
|
||||
)}
|
||||
<span style={{ margin: '0 8px', opacity: 0.5 }}>➜</span>
|
||||
<span>🔔 Telegram: "{action.message || 'Alert'}"</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
{!readOnly && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<IconButton onClick={onEdit} size="small" sx={{ color: '#8ec07c' }}>
|
||||
✎
|
||||
</IconButton>
|
||||
<IconButton onClick={onDelete} size="small" sx={{ color: '#fb4934' }}>
|
||||
🗑
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
372
src/client/AlarmEditor.js
Normal file
372
src/client/AlarmEditor.js
Normal file
@@ -0,0 +1,372 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Button,
|
||||
Box,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
Divider,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Paper,
|
||||
Chip,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { useI18n } from './I18nContext';
|
||||
|
||||
// Reusing some constants/components from RuleEditor logic if possible, but duplicating for isolation as per plan
|
||||
const DAYS_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: '>', label: '>' },
|
||||
{ value: '<', label: '<' },
|
||||
{ value: '>=', label: '≥' },
|
||||
{ value: '<=', label: '≤' },
|
||||
{ value: '==', label: '=' }
|
||||
];
|
||||
|
||||
function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
|
||||
return (
|
||||
<Paper sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default' }}>
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Sensor</InputLabel>
|
||||
<Select
|
||||
value={condition.sensor || ''}
|
||||
label="Sensor"
|
||||
onChange={(e) => {
|
||||
const newSensor = sensors.find(s => s.id === e.target.value);
|
||||
onChange({ ...condition, sensor: e.target.value, sensorLabel: newSensor?.label });
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
{sensors.map(s => (
|
||||
<MenuItem key={s.id} value={s.id}>{s.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 70 }}>
|
||||
<Select
|
||||
value={condition.operator || '>'}
|
||||
onChange={(e) => onChange({ ...condition, operator: e.target.value })}
|
||||
disabled={disabled}
|
||||
>
|
||||
{OPERATORS.map(op => (
|
||||
<MenuItem key={op.value} value={op.value}>{op.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
value={condition.value ?? ''}
|
||||
onChange={(e) => onChange({ ...condition, value: Number(e.target.value) })}
|
||||
sx={{ width: 80 }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{onRemove && (
|
||||
<IconButton size="small" onClick={onRemove} disabled={disabled}>
|
||||
❌
|
||||
</IconButton>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = [], colorTags: availableColorTags = [], saving }) {
|
||||
const { t } = useI18n();
|
||||
const [name, setName] = useState('');
|
||||
const [selectedTags, setSelectedTags] = useState([]);
|
||||
|
||||
// Scheduled time (not commonly used for alarms, but keeping parity with rules engine if needed)
|
||||
// Actually, alarms are usually condition-based (Value > X). Time-based alarms remind you to do something?
|
||||
// Let's keep it simple: SENSORS ONLY for now described in plan ("triggers (based on sensors, time, etc.)")
|
||||
// I'll keep the UI structure but maybe default to Sensors.
|
||||
|
||||
// Simplification: Alarms usually monitor state.
|
||||
// "Time Range" is valid (only alarm between 8am-8pm).
|
||||
// "Scheduled Time" (Alarm at 8am) is basically a Reminder.
|
||||
|
||||
// I will include: Time Range (Active Window) and Sensor Conditions.
|
||||
// I'll omit "Scheduled Time" as a trigger for now unless requested, to reduce complexity,
|
||||
// as "Alarm at 8am" is just an event. The user asked for "similar to alarms".
|
||||
|
||||
const [useTimeRange, setUseTimeRange] = useState(false);
|
||||
const [timeStart, setTimeStart] = useState('08:00');
|
||||
const [timeEnd, setTimeEnd] = useState('18:00');
|
||||
const [timeRangeDays, setTimeRangeDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']);
|
||||
|
||||
const [sensorConditions, setSensorConditions] = useState([{ sensor: '', operator: '>', value: 25 }]);
|
||||
const [sensorLogic, setSensorLogic] = useState('and');
|
||||
|
||||
// Action State (Telegram)
|
||||
const [message, setMessage] = useState('');
|
||||
const [severity, setSeverity] = useState('warning');
|
||||
|
||||
useEffect(() => {
|
||||
if (alarm) {
|
||||
setName(alarm.name);
|
||||
const tags = alarm.colorTags || (alarm.colorTag ? [alarm.colorTag] : []);
|
||||
setSelectedTags(Array.isArray(tags) ? tags : []);
|
||||
|
||||
const trigger = alarm.trigger || {};
|
||||
|
||||
setUseTimeRange(!!trigger.timeRange);
|
||||
if (trigger.timeRange) {
|
||||
setTimeStart(trigger.timeRange.start || '08:00');
|
||||
setTimeEnd(trigger.timeRange.end || '18:00');
|
||||
setTimeRangeDays(trigger.timeRange.days || []);
|
||||
}
|
||||
|
||||
if (trigger.sensors && trigger.sensors.length > 0) {
|
||||
setSensorConditions(trigger.sensors);
|
||||
setSensorLogic(trigger.sensorLogic || 'and');
|
||||
} else {
|
||||
setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
|
||||
}
|
||||
|
||||
const action = alarm.action || {};
|
||||
setMessage(action.message || '');
|
||||
setSeverity(action.severity || 'warning');
|
||||
|
||||
} else {
|
||||
setName('');
|
||||
setSelectedTags([]);
|
||||
setUseTimeRange(false);
|
||||
setTimeStart('08:00');
|
||||
setTimeEnd('18:00');
|
||||
setTimeRangeDays(['mon', 'tue', 'wed', 'thu', 'fri']);
|
||||
setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
|
||||
setSensorLogic('and');
|
||||
setMessage('');
|
||||
setSeverity('warning');
|
||||
}
|
||||
}, [alarm, open, sensors]);
|
||||
|
||||
// Default sensor init
|
||||
useEffect(() => {
|
||||
if (sensorConditions[0]?.sensor === '' && sensors.length > 0) {
|
||||
setSensorConditions([{ ...sensorConditions[0], sensor: sensors[0].id }]);
|
||||
}
|
||||
}, [sensors, sensorConditions]);
|
||||
|
||||
const addSensorCondition = () => {
|
||||
setSensorConditions([...sensorConditions, { sensor: sensors[0]?.id || '', operator: '>', value: 25 }]);
|
||||
};
|
||||
|
||||
const updateSensorCondition = (index, newCondition) => {
|
||||
const updated = [...sensorConditions];
|
||||
updated[index] = newCondition;
|
||||
setSensorConditions(updated);
|
||||
};
|
||||
|
||||
const removeSensorCondition = (index) => {
|
||||
if (sensorConditions.length > 1) {
|
||||
setSensorConditions(sensorConditions.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const trigger = {};
|
||||
|
||||
// Always require sensors for an Alarm (otherwise it's just a time-based notification, which is valid too)
|
||||
// Let's assume user wants to monitor something.
|
||||
|
||||
if (useTimeRange) {
|
||||
trigger.timeRange = { start: timeStart, end: timeEnd, days: timeRangeDays };
|
||||
}
|
||||
|
||||
trigger.sensors = sensorConditions.map(c => ({
|
||||
...c,
|
||||
sensorLabel: sensors.find(s => s.id === c.sensor)?.label
|
||||
}));
|
||||
trigger.sensorLogic = sensorLogic;
|
||||
|
||||
const action = {
|
||||
type: 'telegram',
|
||||
message,
|
||||
severity
|
||||
};
|
||||
|
||||
onSave({ name, trigger, action, colorTags: selectedTags });
|
||||
};
|
||||
|
||||
const isValid = name.trim().length > 0 &&
|
||||
sensorConditions.every(c => c.sensor) &&
|
||||
message.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: 'linear-gradient(145deg, #3c3836 0%, #282828 100%)',
|
||||
border: '1px solid #504945'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
{alarm ? 'Edit Alarm' : 'Create Alarm'}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, pt: 1 }}>
|
||||
<TextField
|
||||
label="Alarm Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
fullWidth
|
||||
disabled={saving}
|
||||
/>
|
||||
|
||||
{/* Tags */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" color="text.secondary">Tags:</Typography>
|
||||
{availableColorTags.map(tag => (
|
||||
<Box
|
||||
key={tag.id}
|
||||
onClick={() => {
|
||||
if (selectedTags.includes(tag.id)) {
|
||||
setSelectedTags(selectedTags.filter(t => t !== tag.id));
|
||||
} else {
|
||||
setSelectedTags([...selectedTags, tag.id]);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
width: 24, height: 24, borderRadius: '50%',
|
||||
bgcolor: tag.color, cursor: 'pointer',
|
||||
border: selectedTags.includes(tag.id) ? '3px solid #ebdbb2' : '2px solid transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{selectedTags.includes(tag.id) && <span style={{ fontSize: '0.7rem' }}>✓</span>}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* TRIGGER */}
|
||||
<Typography variant="subtitle2" color="text.secondary">TRIGGER CONDITIONS</Typography>
|
||||
<Divider />
|
||||
|
||||
{/* Time Window */}
|
||||
<Paper sx={{ p: 2, bgcolor: useTimeRange ? 'action.selected' : 'background.default' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={useTimeRange} onChange={(e) => setUseTimeRange(e.target.checked)} disabled={saving} />
|
||||
}
|
||||
label="Active Time Window (Optional)"
|
||||
/>
|
||||
{useTimeRange && (
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<TextField
|
||||
label="From" type="time"
|
||||
value={timeStart} onChange={(e) => setTimeStart(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }} size="small"
|
||||
/>
|
||||
<Typography>to</Typography>
|
||||
<TextField
|
||||
label="Until" type="time"
|
||||
value={timeEnd} onChange={(e) => setTimeEnd(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }} size="small"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Sensors */}
|
||||
<Paper sx={{ p: 2, bgcolor: 'action.selected' }}>
|
||||
<Typography gutterBottom fontWeight="bold">📊 Sensor Thresholds</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{sensorConditions.length > 1 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2">Logic:</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={sensorLogic} exclusive
|
||||
onChange={(e, v) => v && setSensorLogic(v)} size="small"
|
||||
>
|
||||
<ToggleButton value="and">AND</ToggleButton>
|
||||
<ToggleButton value="or">OR</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{sensorConditions.map((cond, i) => (
|
||||
<SensorCondition
|
||||
key={i}
|
||||
condition={cond}
|
||||
sensors={sensors}
|
||||
onChange={(newCond) => updateSensorCondition(i, newCond)}
|
||||
onRemove={sensorConditions.length > 1 ? () => removeSensorCondition(i) : null}
|
||||
disabled={saving}
|
||||
/>
|
||||
))}
|
||||
<Button size="small" onClick={addSensorCondition} disabled={saving} sx={{ alignSelf: 'flex-start' }}>
|
||||
+ Add Condition
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* ACTION */}
|
||||
<Typography variant="subtitle2" color="text.secondary">ACTION (Telelegram Notification)</Typography>
|
||||
<Divider />
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Severity</InputLabel>
|
||||
<Select
|
||||
value={severity}
|
||||
label="Severity"
|
||||
onChange={(e) => setSeverity(e.target.value)}
|
||||
>
|
||||
<MenuItem value="info">ℹ️ Info</MenuItem>
|
||||
<MenuItem value="warning">⚠️ Warning</MenuItem>
|
||||
<MenuItem value="critical">🔥 Critical</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="Notification Message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="e.g. Temperature is too high!"
|
||||
disabled={saving}
|
||||
/>
|
||||
|
||||
<Alert severity="info" icon={false} sx={{ bgcolor: 'rgba(2, 136, 209, 0.1)' }}>
|
||||
This message will be sent to all users who have linked their Telegram ID in their profile.
|
||||
</Alert>
|
||||
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={onClose} color="inherit" disabled={saving}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="contained"
|
||||
disabled={!isValid || saving}
|
||||
sx={{ background: 'linear-gradient(45deg, #8ec07c 30%, #b8bb26 90%)' }}
|
||||
>
|
||||
{saving ? <CircularProgress size={20} /> : (alarm ? 'Save Changes' : 'Create Alarm')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
295
src/client/AlarmManager.js
Normal file
295
src/client/AlarmManager.js
Normal file
@@ -0,0 +1,295 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Divider,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import AlarmCard from './AlarmCard';
|
||||
import AlarmEditor from './AlarmEditor';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { useI18n } from './I18nContext';
|
||||
|
||||
const COLOR_TAGS = [
|
||||
{ id: 'red', label: 'Red', color: '#fb4934' },
|
||||
{ id: 'orange', label: 'Orange', color: '#fe8019' },
|
||||
{ id: 'yellow', label: 'Yellow', color: '#fabd2f' },
|
||||
{ id: 'green', label: 'Green', color: '#b8bb26' },
|
||||
{ id: 'teal', label: 'Teal', color: '#8ec07c' },
|
||||
{ id: 'blue', label: 'Blue', color: '#83a598' },
|
||||
{ id: 'purple', label: 'Purple', color: '#d3869b' },
|
||||
{ id: 'gray', label: 'Gray', color: '#928374' }
|
||||
];
|
||||
|
||||
export default function AlarmManager() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [alarms, setAlarms] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingAlarm, setEditingAlarm] = useState(null);
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [filterTag, setFilterTag] = useState(null);
|
||||
|
||||
const getAuthHeaders = useCallback(() => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchAlarms = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('api/alarms');
|
||||
if (!res.ok) throw new Error('Failed to fetch alarms');
|
||||
const data = await res.json();
|
||||
setAlarms(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchDevices = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('api/devices');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDevices(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch devices:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlarms();
|
||||
fetchDevices();
|
||||
}, [fetchAlarms, fetchDevices]);
|
||||
|
||||
// Build available sensors (same usage as RuleManager)
|
||||
const availableSensors = [];
|
||||
const seenDevices = new Set();
|
||||
|
||||
devices.forEach(d => {
|
||||
if (!seenDevices.has(d.dev_name)) {
|
||||
seenDevices.add(d.dev_name);
|
||||
availableSensors.push({ id: `${d.dev_name}:temp`, label: `${d.dev_name} - Temperature`, type: 'temp' });
|
||||
availableSensors.push({ id: `${d.dev_name}:humidity`, label: `${d.dev_name} - Humidity`, type: 'humidity' });
|
||||
}
|
||||
availableSensors.push({
|
||||
id: `${d.dev_name}:${d.port}:level`,
|
||||
label: `${d.dev_name} - ${d.port_name} Level`,
|
||||
type: 'level'
|
||||
});
|
||||
});
|
||||
|
||||
const handleAddAlarm = () => {
|
||||
setEditingAlarm(null);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleEditAlarm = (alarm) => {
|
||||
setEditingAlarm(alarm);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteAlarm = async (id) => {
|
||||
if (!confirm('Are you sure you want to delete this alarm?')) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`api/alarms/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete alarm');
|
||||
setAlarms(alarms.filter(a => a.id !== id));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAlarm = async (id) => {
|
||||
const alarm = alarms.find(a => a.id === id);
|
||||
if (!alarm) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`api/alarms/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ ...alarm, enabled: !alarm.enabled })
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update alarm');
|
||||
const updated = await res.json();
|
||||
setAlarms(alarms.map(a => a.id === id ? updated : a));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAlarm = async (alarmData) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingAlarm) {
|
||||
const res = await fetch(`api/alarms/${editingAlarm.id}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ ...alarmData, enabled: editingAlarm.enabled })
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update alarm');
|
||||
const updated = await res.json();
|
||||
setAlarms(alarms.map(a => a.id === editingAlarm.id ? updated : a));
|
||||
} else {
|
||||
const res = await fetch('api/alarms', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ ...alarmData, enabled: true })
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create alarm');
|
||||
const newAlarm = await res.json();
|
||||
setAlarms([...alarms, newAlarm]);
|
||||
}
|
||||
setEditorOpen(false);
|
||||
setEditingAlarm(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveAlarm = async (id, direction) => {
|
||||
const idx = alarms.findIndex(a => a.id === id);
|
||||
if (idx === -1) return;
|
||||
if (direction === 'up' && idx === 0) return;
|
||||
if (direction === 'down' && idx === alarms.length - 1) return;
|
||||
|
||||
const newAlarms = [...alarms];
|
||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
[newAlarms[idx], newAlarms[swapIdx]] = [newAlarms[swapIdx], newAlarms[idx]];
|
||||
setAlarms(newAlarms);
|
||||
|
||||
try {
|
||||
await fetch('api/alarms/reorder', {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ alarmIds: newAlarms.map(a => a.id) })
|
||||
});
|
||||
} catch (err) {
|
||||
setError('Failed to save order');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAlarms = filterTag
|
||||
? alarms.filter(a => (a.colorTags || []).includes(filterTag))
|
||||
: alarms;
|
||||
|
||||
if (loading) {
|
||||
return <Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}><CircularProgress /></Paper>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
mt: 4, ml: 4, mb: 4,
|
||||
p: 3,
|
||||
background: 'linear-gradient(145deg, #3c3836 0%, #32302f 100%)', // Distinct background? Or same?
|
||||
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 }}>
|
||||
🚨 {t('alarms.title') || 'Alarms'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{isAdmin ? 'Manage system alarms and notifications.' : 'View active system alarms.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleAddAlarm}
|
||||
disabled={saving}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)',
|
||||
'&:hover': { background: 'linear-gradient(45deg, #e396a5 30%, #ff9029 90%)' }
|
||||
}}
|
||||
>
|
||||
Add Alarm
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mr: 1 }}>Filter:</Typography>
|
||||
<Chip
|
||||
label="All" size="small" onClick={() => setFilterTag(null)}
|
||||
sx={{ bgcolor: filterTag === null ? '#ebdbb2' : '#504945', color: filterTag === null ? '#282828' : '#ebdbb2' }}
|
||||
/>
|
||||
{COLOR_TAGS.map(tag => (
|
||||
<Chip
|
||||
key={tag.id} size="small" onClick={() => setFilterTag(filterTag === tag.id ? null : tag.id)}
|
||||
sx={{
|
||||
bgcolor: filterTag === tag.id ? tag.color : '#504945',
|
||||
color: filterTag === tag.id ? '#282828' : tag.color,
|
||||
border: `2px solid ${tag.color}`,
|
||||
'&:hover': { bgcolor: tag.color, color: '#282828' }
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
|
||||
{filteredAlarms.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography color="text.secondary">No alarms found.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{filteredAlarms.map((alarm, idx) => (
|
||||
<AlarmCard
|
||||
key={alarm.id}
|
||||
alarm={alarm}
|
||||
onEdit={isAdmin ? () => handleEditAlarm(alarm) : null}
|
||||
onDelete={isAdmin ? () => handleDeleteAlarm(alarm.id) : null}
|
||||
onToggle={isAdmin ? () => handleToggleAlarm(alarm.id) : null}
|
||||
onMoveUp={isAdmin && idx > 0 ? () => handleMoveAlarm(alarm.id, 'up') : null}
|
||||
onMoveDown={isAdmin && idx < filteredAlarms.length - 1 ? () => handleMoveAlarm(alarm.id, 'down') : null}
|
||||
colorTags={COLOR_TAGS}
|
||||
readOnly={!isAdmin}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<AlarmEditor
|
||||
open={editorOpen}
|
||||
alarm={editingAlarm}
|
||||
onSave={handleSaveAlarm}
|
||||
onClose={() => { setEditorOpen(false); setEditingAlarm(null); }}
|
||||
sensors={availableSensors}
|
||||
colorTags={COLOR_TAGS}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,9 @@ 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 AlarmManager from './AlarmManager';
|
||||
import LoginDialog from './LoginDialog';
|
||||
import ProfileDialog from './ProfileDialog';
|
||||
import { AuthProvider, useAuth } from './AuthContext';
|
||||
import { I18nProvider, useI18n } from './I18nContext';
|
||||
import LanguageSwitcher from './LanguageSwitcher';
|
||||
@@ -48,6 +50,7 @@ function AppContent() {
|
||||
const { user, loading, login, logout, isAuthenticated, isAdmin } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
@@ -62,10 +65,13 @@ function AppContent() {
|
||||
<>
|
||||
<Chip
|
||||
label={user.username}
|
||||
onClick={() => setShowProfile(true)}
|
||||
color={isAdmin ? 'secondary' : 'default'}
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { opacity: 0.8 },
|
||||
...(isAdmin && {
|
||||
background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)'
|
||||
})
|
||||
@@ -110,6 +116,9 @@ function AppContent() {
|
||||
|
||||
{/* Rule Manager visible to everyone (guests read-only, admins can edit) */}
|
||||
<RuleManager />
|
||||
|
||||
{/* Alarm Manager visible to everyone (guests read-only, admins can edit) */}
|
||||
<AlarmManager />
|
||||
</Container>
|
||||
|
||||
{/* Login dialog - shown on demand */}
|
||||
@@ -117,6 +126,12 @@ function AppContent() {
|
||||
open={showLogin}
|
||||
onClose={() => setShowLogin(false)}
|
||||
/>
|
||||
|
||||
{/* Profile dialog */}
|
||||
<ProfileDialog
|
||||
open={showProfile}
|
||||
onClose={() => setShowProfile(false)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
134
src/client/ProfileDialog.js
Normal file
134
src/client/ProfileDialog.js
Normal file
@@ -0,0 +1,134 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Link
|
||||
} from '@mui/material';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
export default function ProfileDialog({ open, onClose }) {
|
||||
const { user, login } = useAuth(); // We need a way to refresh user data or update context.
|
||||
// Actually, AuthContext might not expose a "refreshUser" method.
|
||||
// For now we will update the local state and rely on next page load/auth check to refresh global state,
|
||||
// OR we should ideally update the user object in AuthContext.
|
||||
// Checking AuthContext... it has `user` state. It sets user on login/checkAuth.
|
||||
// `checkAuth` is not exposed in `useAuth` return typically?
|
||||
// Let's assume we can just modify the user locally or ignore it, as long as the server has it.
|
||||
|
||||
// Better: Fetch the latest profile data when opening the dialog.
|
||||
|
||||
const [telegramId, setTelegramId] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchProfile();
|
||||
setSuccess(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch('api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.user) {
|
||||
setTelegramId(data.user.telegramId || '');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const res = await fetch('api/auth/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ telegramId })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || 'Failed to update profile');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => onClose(), 1500);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>User Profile: {user?.username}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ pt: 1, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Link your Telegram account to receive alarm notifications.
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info" icon={false}>
|
||||
To get your Telegram ID:
|
||||
<ol style={{ margin: '8px 0', paddingLeft: 20 }}>
|
||||
<li>Search for <b>@TischlereiCtrlBot</b> on Telegram</li>
|
||||
<li>Start the bot (`/start`)</li>
|
||||
<li>It will reply with your ID. Copy it here.</li>
|
||||
</ol>
|
||||
</Alert>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}><CircularProgress /></Box>
|
||||
) : (
|
||||
<TextField
|
||||
label="Telegram ID"
|
||||
value={telegramId}
|
||||
onChange={(e) => setTelegramId(e.target.value)}
|
||||
fullWidth
|
||||
placeholder="e.g. 123456789"
|
||||
helperText="Leave empty to disable notifications"
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
{success && <Alert severity="success">Profile updated successfully!</Alert>}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} color="inherit">Close</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={saving || loading}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -90,5 +90,8 @@
|
||||
"fri": "Fr",
|
||||
"sat": "Sa",
|
||||
"sun": "So"
|
||||
},
|
||||
"alarms": {
|
||||
"title": "Alarme"
|
||||
}
|
||||
}
|
||||
@@ -90,5 +90,8 @@
|
||||
"fri": "Fri",
|
||||
"sat": "Sat",
|
||||
"sun": "Sun"
|
||||
},
|
||||
"alarms": {
|
||||
"title": "Alarms"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user