From 8797cb4990d6d0fa2f224bcda4ee5e3197685f16 Mon Sep 17 00:00:00 2001 From: EMOTIONS-HUNTER Date: Thu, 25 Jun 2026 10:27:53 -0600 Subject: [PATCH] Agregar alarmas persistentes y configuracion operativa --- PROJECT_STATUS.md | 29 ++++ README.md | 25 ++++ api/acquisition-service.js | 260 ++++++++++++++++++++++++++++++++- api/acquisition.js | 3 +- api/server.js | 175 +++++++++++++++++++++- config/runtime.json | 1 + deployment/photobioreactor.env | 2 + frontend/dashboard.css | 65 ++++++++- frontend/dashboard.js | 200 ++++++++++++++++++++++++- frontend/ezo-service.js | 2 +- frontend/index.html | 33 +++++ test/api.test.js | 249 +++++++++++++++++++++++++++++-- test/static.test.js | 10 ++ 13 files changed, 1020 insertions(+), 34 deletions(-) diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index c8126cf..dd008ea 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -41,6 +41,35 @@ ventana de procesamiento en vez de bloquear un segundo por sensor. - Los sensores deshabilitados se muestran como `DESHABILITADO`. - Un sensor deshabilitado no cuenta como `OFFLINE`, alarma ni riesgo global. +## Seguridad operativa + +- `API_AUTH_TOKEN` protege endpoints criticos cuando esta configurado. +- El dashboard envia el token como `X-API-Token` desde almacenamiento local del + navegador. +- Sin `API_AUTH_TOKEN`, el modo desarrollo permanece sin autenticacion. + +## Bitacora de alarmas + +- `logs/alarms.csv` registra transiciones a `WARNING`, `CRITICAL`, `OFFLINE` y + recuperaciones `RECOVERY`. +- `GET /api/alarms` expone los eventos para dashboard y futuras notificaciones. +- Los sensores `DISABLED` no generan eventos de alarma. + +## Umbrales configurables + +- `GET /api/config/alarms` expone los limites actuales. +- `POST /api/config/alarms` valida y persiste cambios en `config/alarms.json`. +- El dashboard permite editar min/max por sensor; guardar requiere token si + `API_AUTH_TOKEN` esta activo. + +## Retencion historica + +- `historyRetentionDays` en `config/runtime.json` controla poda automatica de + CSV. +- Valores permitidos: 7, 30, 90, 365 o 0 para retencion indefinida. +- La poda conserva los nombres actuales de archivos para no romper graficas ni + exportaciones. + ## Modos - `EZO_MODE=demo`: adquisición y comandos simulados. diff --git a/README.md b/README.md index c60e265..7af3ba6 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,31 @@ En produccion puede dejar `EZO_ENABLED_SENSORS=temperature` en `/etc/default/photobioreactor` como override. Cuando esten conectados los cuatro circuitos, deje la variable vacia y habilite RTD, pH, DO y EC desde el panel. +## Seguridad operativa + +En produccion configure `API_AUTH_TOKEN` en `/etc/default/photobioreactor` para +proteger comandos EZO, calibracion, cambios de configuracion y borrado de +historicos. El dashboard incluye un campo "Token API" que guarda el valor solo +en el navegador local y lo envia como header `X-API-Token`. + +## Bitacora de alarmas + +El recolector registra transiciones de alarma en `logs/alarms.csv` y la API las +expone en `GET /api/alarms`. Se registran entradas a `WARNING`, `CRITICAL` y +`OFFLINE`, ademas de recuperaciones a `NORMAL`. + +## Retencion historica + +`config/runtime.json` define `historyRetentionDays`. El valor puede ser 7, 30, +90, 365 o 0 para conservar indefinidamente. El recolector poda filas antiguas de +CSV sin cambiar los nombres que usa el dashboard. + +## Umbrales de alarma + +Los limites se guardan en `config/alarms.json`, se consultan con +`GET /api/config/alarms` y pueden editarse desde el dashboard. Guardar cambios +requiere `API_AUTH_TOKEN` cuando esta configurado. + ## Archivos de datos - Lecturas actuales: `data/EZORTD.json`, `EZOPH.json`, `EZODO.json`, diff --git a/api/acquisition-service.js b/api/acquisition-service.js index 1a9bd89..a894240 100644 --- a/api/acquisition-service.js +++ b/api/acquisition-service.js @@ -16,6 +16,9 @@ const LOGS_DIRECTORY = process.env.LOGS_DIRECTORY const RUNTIME_CONFIG_FILE = process.env.RUNTIME_CONFIG_FILE ? path.resolve(process.env.RUNTIME_CONFIG_FILE) : path.join(ROOT_DIRECTORY, 'config', 'runtime.json'); +const ALARM_CONFIG_FILE = process.env.ALARM_CONFIG_FILE + ? path.resolve(process.env.ALARM_CONFIG_FILE) + : path.join(ROOT_DIRECTORY, 'config', 'alarms.json'); const ACQUISITION_HELPER = process.env.EZO_ACQUIRE_HELPER ? path.resolve(process.env.EZO_ACQUIRE_HELPER) : path.join(ROOT_DIRECTORY, 'sensors', 'EZOCommand', 'EZO_ACQUIRE'); @@ -32,6 +35,10 @@ const SENSOR_FILES = { }; const ALLOWED_LOGGING_RATES = new Set([1, 5, 10, 60]); +const ALLOWED_RETENTION_DAYS = new Set([0, 7, 30, 90, 365]); +const WARNING_MARGIN_RATIO = 0.1; +const ALARM_LOG_FILE = 'alarms.csv'; +const ALARM_LOG_HEADER = 'timestamp,sensor,state,value,message\n'; const SENSOR_ALIASES = { rtd: 'temperature', temperature: 'temperature', @@ -40,6 +47,9 @@ const SENSOR_ALIASES = { ec: 'ec' }; const DEFAULT_ENABLED_SENSORS = Object.keys(SENSOR_FILES); +const previousAlarmStates = new Map(); +const lastRetentionChecks = new Map(); +const RETENTION_CHECK_INTERVAL_MS = 10 * 60 * 1000; function normalizeEnabledSensors(value, fallback = DEFAULT_ENABLED_SENSORS) { const source = Array.isArray(value) @@ -68,10 +78,14 @@ async function readRuntimeConfig() { const rawConfig = await fsPromises.readFile(RUNTIME_CONFIG_FILE, 'utf8'); const config = JSON.parse(rawConfig); const rate = Number(config.loggingRateSeconds); + const retentionDays = Number(config.historyRetentionDays); const environmentSensors = String(process.env.EZO_ENABLED_SENSORS || '').trim(); return { loggingRateSeconds: ALLOWED_LOGGING_RATES.has(rate) ? rate : 1, + historyRetentionDays: ALLOWED_RETENTION_DAYS.has(retentionDays) + ? retentionDays + : 30, enabledSensors: environmentSensors ? normalizeEnabledSensors(environmentSensors) : normalizeEnabledSensors(config.enabledSensors) @@ -79,6 +93,7 @@ async function readRuntimeConfig() { } catch { return { loggingRateSeconds: 1, + historyRetentionDays: 30, enabledSensors: normalizeEnabledSensors(process.env.EZO_ENABLED_SENSORS) }; } @@ -89,6 +104,9 @@ async function writeRuntimeConfig(updates) { const numericRate = updates.rate === undefined ? currentConfig.loggingRateSeconds : Number(updates.rate); + const retentionDays = updates.historyRetentionDays === undefined + ? currentConfig.historyRetentionDays + : Number(updates.historyRetentionDays); if (!ALLOWED_LOGGING_RATES.has(numericRate)) { const error = new Error('Frecuencia no valida.'); @@ -96,6 +114,12 @@ async function writeRuntimeConfig(updates) { throw error; } + if (!ALLOWED_RETENTION_DAYS.has(retentionDays)) { + const error = new Error('Retencion historica no valida.'); + error.status = 400; + throw error; + } + const enabledSensors = updates.enabledSensors === undefined ? currentConfig.enabledSensors : normalizeEnabledSensors(updates.enabledSensors, []); @@ -108,6 +132,7 @@ async function writeRuntimeConfig(updates) { const config = { loggingRateSeconds: numericRate, + historyRetentionDays: retentionDays, enabledSensors, updatedAt: new Date().toISOString() }; @@ -183,6 +208,210 @@ async function appendReading(filePath, timestamp, value) { ); } +function escapeCsvValue(value) { + const text = value === null || value === undefined ? '' : String(value); + return `"${text.replace(/"/g, '""')}"`; +} + +async function appendCsvRow(filePath, header, values) { + await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); + let needsHeader = false; + + try { + const stats = await fsPromises.stat(filePath); + needsHeader = stats.size === 0; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + needsHeader = true; + } + + const row = `${values.map(escapeCsvValue).join(',')}\n`; + await fsPromises.appendFile( + filePath, + `${needsHeader ? header : ''}${row}`, + 'utf8' + ); +} + +async function pruneCsvByRetention(filePath, header, retentionDays, now = new Date()) { + if (!retentionDays) return false; + + let csvText = ''; + + try { + csvText = await fsPromises.readFile(filePath, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } + + const lines = csvText.split(/\r?\n/).filter(Boolean); + + if (lines.length <= 1) return false; + + const cutoffMs = now.getTime() - retentionDays * 24 * 60 * 60 * 1000; + const rows = lines.slice(1); + const keptRows = rows.filter((line) => { + const timestamp = line.split(',', 1)[0].replace(/^"|"$/g, ''); + const parsedMs = new Date(timestamp).getTime(); + + return Number.isNaN(parsedMs) || parsedMs >= cutoffMs; + }); + + if (keptRows.length === rows.length) return false; + + await atomicWriteFile( + filePath, + `${header}${keptRows.length > 0 ? `${keptRows.join('\n')}\n` : ''}` + ); + return true; +} + +async function enforceHistoryRetention(retentionDays, now = new Date()) { + if (!retentionDays) return []; + + const prunedFiles = []; + const logFiles = [ + ...Object.values(SENSOR_FILES).map((sensor) => ({ + file: sensor.csv, + header: 'timestamp,value\n' + })), + { file: ALARM_LOG_FILE, header: ALARM_LOG_HEADER } + ]; + + for (const logFile of logFiles) { + const filePath = path.join(LOGS_DIRECTORY, logFile.file); + const lastCheck = lastRetentionChecks.get(filePath) || 0; + + if (now.getTime() - lastCheck < RETENTION_CHECK_INTERVAL_MS) { + continue; + } + + lastRetentionChecks.set(filePath, now.getTime()); + + if (await pruneCsvByRetention(filePath, logFile.header, retentionDays, now)) { + prunedFiles.push(logFile.file); + } + } + + return prunedFiles; +} + +async function readAlarmThresholds() { + try { + return JSON.parse(await fsPromises.readFile(ALARM_CONFIG_FILE, 'utf8')); + } catch { + return {}; + } +} + +function isValidThreshold(limits) { + return Boolean(limits) && + Number.isFinite(Number(limits.min)) && + Number.isFinite(Number(limits.max)) && + Number(limits.min) < Number(limits.max); +} + +function isNearThreshold(value, limits) { + const min = Number(limits.min); + const max = Number(limits.max); + const warningMargin = (max - min) * WARNING_MARGIN_RATIO; + + return value <= min + warningMargin || value >= max - warningMargin; +} + +function evaluateAlarmState(sensorId, result, thresholds) { + if (result.disabled) { + return { + state: 'DISABLED', + message: 'Sensor deshabilitado por configuracion' + }; + } + + if (!result.online) { + return { + state: 'OFFLINE', + message: 'Lectura no disponible' + }; + } + + const limits = thresholds[sensorId]; + + if (!isValidThreshold(limits)) { + return { + state: 'WARNING', + message: 'Limites de alarma no disponibles' + }; + } + + if (result.value < Number(limits.min) || result.value > Number(limits.max)) { + return { + state: 'CRITICAL', + message: `Valor fuera de rango (${limits.min} - ${limits.max})` + }; + } + + if (isNearThreshold(result.value, limits)) { + return { + state: 'WARNING', + message: `Valor cerca del limite (${limits.min} - ${limits.max})` + }; + } + + return { + state: 'NORMAL', + message: 'Dentro del rango configurado' + }; +} + +async function persistAlarmEvents(results, timestamp) { + const thresholds = await readAlarmThresholds(); + const events = []; + + for (const result of results) { + const evaluation = evaluateAlarmState(result.sensorId, result, thresholds); + const previousState = previousAlarmStates.get(result.sensorId) || 'NORMAL'; + + if (evaluation.state === 'DISABLED') { + previousAlarmStates.delete(result.sensorId); + continue; + } + + const enteredAlarm = ['WARNING', 'CRITICAL', 'OFFLINE'].includes(evaluation.state) && + previousState !== evaluation.state; + const recovered = evaluation.state === 'NORMAL' && + ['WARNING', 'CRITICAL', 'OFFLINE'].includes(previousState); + + if (enteredAlarm || recovered) { + events.push({ + timestamp, + sensorId: result.sensorId, + state: recovered ? 'RECOVERY' : evaluation.state, + value: Number.isFinite(result.value) ? result.value : '', + message: recovered ? 'Sensor recuperado a NORMAL' : evaluation.message + }); + } + + previousAlarmStates.set(result.sensorId, evaluation.state); + } + + for (const event of events) { + await appendCsvRow( + path.join(LOGS_DIRECTORY, ALARM_LOG_FILE), + ALARM_LOG_HEADER, + [ + event.timestamp, + event.sensorId, + event.state, + event.value, + event.message + ] + ); + } + + return events; +} + async function publishReading(sensorId, rawValue, timestamp, mode) { const sensor = SENSOR_FILES[sensorId]; const jsonPath = path.join(DATA_DIRECTORY, sensor.json); @@ -243,26 +472,40 @@ async function collectReadings() { Object.keys(SENSOR_FILES).map(async (sensorId) => { if (!enabledSensorSet.has(sensorId)) { await publishDisabled(sensorId, timestamp); - return { sensorId, online: false, disabled: true }; + return { sensorId, online: false, disabled: true, value: null }; } + const rawValue = readings[sensorId]; + const value = rawValue === null || rawValue === undefined || rawValue === '' + ? NaN + : Number(rawValue); + const online = await publishReading( + sensorId, + rawValue, + timestamp, + modeInfo.mode + ); + return { sensorId, - online: await publishReading( - sensorId, - readings[sensorId], - timestamp, - modeInfo.mode - ), + online, + value: Number.isFinite(value) ? value : null, disabled: false }; }) ); + const alarmEvents = await persistAlarmEvents(results, timestamp); + const prunedFiles = await enforceHistoryRetention( + runtimeConfig.historyRetentionDays, + new Date(timestamp) + ); return { mode: modeInfo.mode, timestamp, results, + alarmEvents, + prunedFiles, enabledSensors: runtimeConfig.enabledSensors }; } @@ -288,15 +531,18 @@ async function publishAllOffline(error) { module.exports = { ACQUISITION_HELPER, ALLOWED_LOGGING_RATES, + ALLOWED_RETENTION_DAYS, DEFAULT_ENABLED_SENSORS, RUNTIME_CONFIG_FILE, SENSOR_FILES, atomicWriteFile, collectReadings, detectAcquisitionMode, + enforceHistoryRetention, normalizeEnabledSensors, publishAllOffline, publishReading, + pruneCsvByRetention, readRuntimeConfig, writeRuntimeConfig }; diff --git a/api/acquisition.js b/api/acquisition.js index 112ee98..6a130b5 100644 --- a/api/acquisition.js +++ b/api/acquisition.js @@ -20,8 +20,9 @@ async function run() { const result = await collectReadings(); const onlineCount = result.results.filter((item) => item.online).length; const expectedCount = result.enabledSensors.length; + const alarmCount = result.alarmEvents.length; console.log( - `[ACQUISITION] ${result.timestamp} ${result.mode}: ${onlineCount}/${expectedCount} sensores habilitados.` + `[ACQUISITION] ${result.timestamp} ${result.mode}: ${onlineCount}/${expectedCount} sensores habilitados, ${alarmCount} eventos de alarma.` ); } catch (error) { console.error(`[ACQUISITION] ${error.message}`); diff --git a/api/server.js b/api/server.js index 6711b46..a9f98f8 100644 --- a/api/server.js +++ b/api/server.js @@ -15,14 +15,19 @@ const { const app = express(); const PORT = Number(process.env.PORT || 3000); const HOST = process.env.HOST || '127.0.0.1'; +const API_AUTH_TOKEN = process.env.API_AUTH_TOKEN || ''; const LOGS_DIRECTORY = process.env.LOGS_DIRECTORY ? path.resolve(process.env.LOGS_DIRECTORY) : path.join(__dirname, '..', 'logs'); +const ALARM_CONFIG_FILE = process.env.ALARM_CONFIG_FILE + ? path.resolve(process.env.ALARM_CONFIG_FILE) + : path.join(__dirname, '..', 'config', 'alarms.json'); const HISTORY_FILES = { temperature: 'timestamp,value\n', ph: 'timestamp,value\n', do: 'timestamp,value\n', - ec: 'timestamp,value\n' + ec: 'timestamp,value\n', + alarms: 'timestamp,sensor,state,value,message\n' }; const HISTORY_SENSOR_MAP = { rtd: { file: 'temperature.csv', name: 'RTD' }, @@ -36,6 +41,95 @@ const HISTORY_SENSOR_IDS = { do: 'do', ec: 'ec' }; +const ALARM_SENSOR_IDS = ['temperature', 'ph', 'do', 'ec']; + +function parseAlarmCsv(csvText) { + return csvText + .split(/\r?\n/) + .slice(1) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const columns = parseCsvLine(line); + + if (columns.length < 5) return null; + return { + timestamp: columns[0], + sensor: columns[1], + state: columns[2], + value: columns[3] === '' ? null : Number(columns[3]), + message: columns[4] + }; + }) + .filter(Boolean); +} + +function parseCsvLine(line) { + const columns = []; + let current = ''; + let quoted = false; + + for (let index = 0; index < line.length; index++) { + const character = line[index]; + const nextCharacter = line[index + 1]; + + if (character === '"' && quoted && nextCharacter === '"') { + current += '"'; + index++; + } else if (character === '"') { + quoted = !quoted; + } else if (character === ',' && !quoted) { + columns.push(current); + current = ''; + } else { + current += character; + } + } + + columns.push(current); + return columns; +} + +async function readAlarmConfigFile() { + const rawConfig = await fs.readFile(ALARM_CONFIG_FILE, 'utf8'); + return JSON.parse(rawConfig); +} + +function validateAlarmConfig(rawConfig) { + if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) { + throw Object.assign(new Error('Configuracion de alarmas invalida.'), { status: 400 }); + } + + const normalizedConfig = {}; + + for (const sensorId of ALARM_SENSOR_IDS) { + const limits = rawConfig[sensorId]; + const min = Number(limits && limits.min); + const max = Number(limits && limits.max); + + if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max) { + throw Object.assign( + new Error(`Umbrales invalidos para ${sensorId}.`), + { status: 400 } + ); + } + + normalizedConfig[sensorId] = { min, max }; + } + + return normalizedConfig; +} + +async function writeAlarmConfigFile(rawConfig) { + const config = validateAlarmConfig(rawConfig); + await fs.mkdir(path.dirname(ALARM_CONFIG_FILE), { recursive: true }); + await fs.writeFile( + ALARM_CONFIG_FILE, + `${JSON.stringify(config, null, 4)}\n`, + 'utf8' + ); + return config; +} app.use(cors()); app.use(express.json()); @@ -77,6 +171,23 @@ app.get('/', (req, res) => { res.redirect('/frontend/index.html'); }); +function requireApiToken(req, res, next) { + if (!API_AUTH_TOKEN) { + return next(); + } + + const providedToken = req.get('X-API-Token') || ''; + + if (providedToken === API_AUTH_TOKEN) { + return next(); + } + + return res.status(401).json({ + success: false, + error: 'Token de API requerido.' + }); +} + app.get('/api/system/ezo', (req, res) => { const commandMode = detectMode(); const acquisitionMode = detectAcquisitionMode(); @@ -150,7 +261,23 @@ app.get('/api/sensors/:type', async (req, res) => { } }); -app.post('/api/sensors/:type/command', async (req, res) => { +app.get('/api/alarms', async (req, res) => { + try { + const csvText = await fs.readFile( + path.join(LOGS_DIRECTORY, 'alarms.csv'), + 'utf8' + ); + res.json(parseAlarmCsv(csvText)); + } catch (error) { + if (error.code === 'ENOENT') return res.json([]); + console.error('[ALARMS] No se pudo leer la bitacora:', error); + return res.status(500).json({ + error: 'No se pudo leer la bitacora de alarmas.' + }); + } +}); + +app.post('/api/sensors/:type/command', requireApiToken, async (req, res) => { try { const result = await executeEzoCommand( req.params.type.toLowerCase(), @@ -171,11 +298,44 @@ app.get('/api/config/logging', async (req, res) => { res.json({ success: true, rate: config.loggingRateSeconds, + historyRetentionDays: config.historyRetentionDays, enabledSensors: config.enabledSensors }); }); -app.post('/api/config/logging', async (req, res) => { +app.get('/api/config/alarms', async (req, res) => { + try { + res.json({ + success: true, + thresholds: await readAlarmConfigFile() + }); + } catch (error) { + console.error('[CONFIG] No se pudieron leer alarmas:', error); + res.status(500).json({ + success: false, + error: 'No se pudieron leer los umbrales de alarma.' + }); + } +}); + +app.post('/api/config/alarms', requireApiToken, async (req, res) => { + try { + const thresholds = await writeAlarmConfigFile(req.body.thresholds || req.body); + console.log('[CONFIG] Umbrales de alarma actualizados.'); + res.json({ + success: true, + thresholds, + message: 'Umbrales de alarma actualizados.' + }); + } catch (error) { + res.status(error.status || 500).json({ + success: false, + error: error.message + }); + } +}); + +app.post('/api/config/logging', requireApiToken, async (req, res) => { try { const config = await writeRuntimeConfig({ rate: req.body.rate }); console.log( @@ -199,15 +359,17 @@ app.get('/api/config/runtime', async (req, res) => { res.json({ success: true, rate: config.loggingRateSeconds, + historyRetentionDays: config.historyRetentionDays, enabledSensors: config.enabledSensors, - availableSensors: Object.keys(HISTORY_FILES) + availableSensors: Object.keys(HISTORY_SENSOR_IDS) }); }); -app.post('/api/config/runtime', async (req, res) => { +app.post('/api/config/runtime', requireApiToken, async (req, res) => { try { const config = await writeRuntimeConfig({ rate: req.body.rate, + historyRetentionDays: req.body.historyRetentionDays, enabledSensors: req.body.enabledSensors }); console.log( @@ -216,6 +378,7 @@ app.post('/api/config/runtime', async (req, res) => { res.json({ success: true, rate: config.loggingRateSeconds, + historyRetentionDays: config.historyRetentionDays, enabledSensors: config.enabledSensors, message: 'La configuracion sera aplicada por el recolector en el siguiente ciclo.' }); @@ -227,7 +390,7 @@ app.post('/api/config/runtime', async (req, res) => { } }); -app.post('/api/history/clear', async (req, res) => { +app.post('/api/history/clear', requireApiToken, async (req, res) => { try { await fs.mkdir(LOGS_DIRECTORY, { recursive: true }); await Promise.all( diff --git a/config/runtime.json b/config/runtime.json index d11b47b..65058fc 100644 --- a/config/runtime.json +++ b/config/runtime.json @@ -1,5 +1,6 @@ { "loggingRateSeconds": 1, + "historyRetentionDays": 30, "enabledSensors": [ "temperature" ] diff --git a/deployment/photobioreactor.env b/deployment/photobioreactor.env index 82773c7..7915540 100644 --- a/deployment/photobioreactor.env +++ b/deployment/photobioreactor.env @@ -4,3 +4,5 @@ HOST=127.0.0.1 EZO_MODE=hardware # Deje vacio para consultar todos. Use "temperature" durante pruebas con solo RTD. EZO_ENABLED_SENSORS= +# Configure un valor secreto para proteger comandos, calibracion y cambios criticos. +API_AUTH_TOKEN= diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 6dd43e3..6390fbf 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -361,6 +361,67 @@ h1 { border-left: 4px solid var(--offline-neutral); } +.alarm-history-title { + margin: 18px 0 8px; + font-size: 1rem; +} + +.alarm-threshold-editor { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.alarm-threshold-editor h3 { + margin: 0 0 12px; + font-size: 1rem; +} + +.threshold-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.threshold-group { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel-soft); +} + +.threshold-group strong { + font-size: 0.9rem; +} + +.threshold-group label { + display: grid; + gap: 4px; + color: var(--muted); + font-size: 0.78rem; + font-weight: 700; +} + +.threshold-group input { + width: 100%; + min-height: 34px; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); +} + +.alarm-history-list { + margin-top: 8px; +} + +.alarm-history-list li.recovery { + border-left: 4px solid var(--ok); +} + .alarm-empty { justify-content: center; text-align: center; @@ -454,7 +515,8 @@ h1 { @media (max-width: 980px) { .metrics-grid, .status-grid, - .sensor-health { + .sensor-health, + .threshold-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -479,6 +541,7 @@ h1 { .metrics-grid, .status-grid, .sensor-health, + .threshold-grid, .charts-grid { grid-template-columns: 1fr; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 3689d3f..eba0800 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1,13 +1,14 @@ const POLLING_INTERVAL_MS = 1000; -const ALARM_CONFIG_FILE = "../config/alarms.json"; const WARNING_MARGIN_RATIO = 0.1; const MIN_READING_MAX_AGE_MS = 15000; // Variables dinámicas para el control de históricos let currentHistoryInterval = 10000; let currentLoggingRateSeconds = 1; +let currentHistoryRetentionDays = 30; let historyIntervalId = null; let dashboardUpdateInProgress = false; +let alarmControlsInitialized = false; // Diccionario para traducir estados en la UI sin romper las clases CSS const stateTranslations = { @@ -20,6 +21,34 @@ const stateTranslations = { let currentEnabledSensorIds = new Set(["temperature", "ph", "do", "ec"]); +function getApiHeaders(extraHeaders = {}) { + const token = localStorage.getItem("photobioreactorApiToken") || ""; + const headers = { ...extraHeaders }; + + if (token) { + headers["X-API-Token"] = token; + } + + return headers; +} + +window.getApiHeaders = getApiHeaders; + +window.saveApiToken = function () { + const input = document.getElementById("api-token-input"); + const token = input.value.trim(); + + if (token) { + localStorage.setItem("photobioreactorApiToken", token); + input.value = ""; + alert("Token API guardado en este navegador."); + return; + } + + localStorage.removeItem("photobioreactorApiToken"); + alert("Token API eliminado de este navegador."); +}; + const sensors = [ { id: "temperature", @@ -67,6 +96,7 @@ const sensors = [ const historicalCharts = [ { id: "temperature", + sensorId: "temperature", title: "Temperatura vs Tiempo", file: "../logs/temperature.csv", valueKey: "temperature", @@ -264,6 +294,7 @@ async function updateDashboard() { evaluatedResults.forEach(setSensorState); renderSystemStatus(evaluatedResults); renderAlarmSummary(evaluatedResults, alarmConfig); + renderAlarmHistory(); } finally { dashboardUpdateInProgress = false; } @@ -290,7 +321,7 @@ async function readRuntimeConfig() { async function readAlarmConfig() { try { - const response = await fetch(`${ALARM_CONFIG_FILE}?t=${Date.now()}`, { + const response = await fetch('/api/config/alarms', { cache: "no-store" }); @@ -298,7 +329,10 @@ async function readAlarmConfig() { throw new Error(`HTTP ${response.status}`); } - const thresholds = await response.json(); + const result = await response.json(); + const thresholds = result.thresholds || result; + + renderAlarmThresholdControls(thresholds); return { loaded: true, @@ -314,6 +348,77 @@ async function readAlarmConfig() { } } +function renderAlarmThresholdControls(thresholds) { + const container = document.getElementById("alarm-threshold-controls"); + + if (!container || alarmControlsInitialized) return; + + container.innerHTML = sensors + .map((sensor) => { + const limits = thresholds[sensor.id] || {}; + const min = Number.isFinite(Number(limits.min)) ? Number(limits.min) : ""; + const max = Number.isFinite(Number(limits.max)) ? Number(limits.max) : ""; + + return ` +
+ ${sensor.name} + + +
+ `}) + .join(""); + alarmControlsInitialized = true; +} + +function collectAlarmThresholdControls() { + const thresholds = {}; + + sensors.forEach((sensor) => { + const minInput = document.querySelector(`[data-threshold-sensor="${sensor.id}"][data-threshold-bound="min"]`); + const maxInput = document.querySelector(`[data-threshold-sensor="${sensor.id}"][data-threshold-bound="max"]`); + const min = Number(minInput && minInput.value); + const max = Number(maxInput && maxInput.value); + + if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max) { + throw new Error(`Umbrales invalidos para ${sensor.name}.`); + } + + thresholds[sensor.id] = { min, max }; + }); + + return thresholds; +} + +window.saveAlarmThresholds = async function () { + try { + const thresholds = collectAlarmThresholdControls(); + const response = await fetch('/api/config/alarms', { + method: 'POST', + headers: getApiHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ thresholds }) + }); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || "No se pudieron guardar los umbrales."); + } + + alarmControlsInitialized = false; + renderAlarmThresholdControls(result.thresholds); + await updateDashboard(); + alert("Umbrales de alarma actualizados."); + } catch (error) { + console.error("Error guardando umbrales:", error); + alert(error.message); + } +}; + function evaluateSensorAlarm(sensorResult, thresholds) { if (sensorResult.disabled) { return { @@ -457,6 +562,50 @@ function renderAlarmSummary(results, alarmConfig) { .join(""); } +async function readAlarmHistory() { + try { + const response = await fetch('/api/alarms', { cache: 'no-store' }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.warn("No se pudo cargar la bitacora de alarmas:", error); + return []; + } +} + +async function renderAlarmHistory() { + const historyList = document.getElementById("alarm-history-list"); + + if (!historyList) return; + + const events = await readAlarmHistory(); + const recentEvents = events.slice(-8).reverse(); + + if (recentEvents.length === 0) { + historyList.innerHTML = '
  • Sin eventos registrados
  • '; + return; + } + + historyList.innerHTML = recentEvents + .map((event) => { + const stateClass = String(event.state || "").toLowerCase(); + const timestamp = event.timestamp + ? new Date(event.timestamp).toLocaleString() + : "--"; + + return ` +
  • + ${timestamp} · ${event.sensor} + ${event.state}: ${event.message} +
  • + `}) + .join(""); +} + async function readHistoricalData(chartConfig) { try { const response = await fetch(`${chartConfig.file}?t=${Date.now()}`, { @@ -725,7 +874,7 @@ window.updateLoggingRate = async function () { try { const response = await fetch('/api/config/logging', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getApiHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ rate }) }); @@ -752,6 +901,9 @@ async function loadLoggingRate() { currentEnabledSensorIds = new Set(result.enabledSensors); setEnabledSensorControls(result.enabledSensors); } + currentHistoryRetentionDays = Number(result.historyRetentionDays ?? 30); + document.getElementById("history-retention-days").value = + String(currentHistoryRetentionDays); document.getElementById("data-logging-rate").value = String(result.rate); document.getElementById("sampling-interval").textContent = `${result.rate} ${result.rate === 1 ? "segundo" : "segundos"}`; @@ -760,6 +912,38 @@ async function loadLoggingRate() { } } +window.updateHistoryRetention = async function () { + const select = document.getElementById("history-retention-days"); + const historyRetentionDays = Number(select.value); + + try { + const response = await fetch('/api/config/runtime', { + method: 'POST', + headers: getApiHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + rate: currentLoggingRateSeconds, + historyRetentionDays, + enabledSensors: [...currentEnabledSensorIds] + }) + }); + const result = await response.json(); + + if (!response.ok) throw new Error(result.error || "No se pudo actualizar la retencion"); + + currentHistoryRetentionDays = result.historyRetentionDays; + select.value = String(result.historyRetentionDays); + alert( + result.historyRetentionDays === 0 + ? "Retención histórica desactivada." + : `Retención histórica actualizada a ${result.historyRetentionDays} días.` + ); + } catch (error) { + console.error("Error configurando retencion historica:", error); + select.value = String(currentHistoryRetentionDays); + alert("No se pudo actualizar la retención histórica."); + } +}; + function getSelectedEnabledSensors() { return [...document.querySelectorAll('input[name="enabled-sensor"]:checked')] .map((input) => input.value); @@ -785,9 +969,10 @@ window.updateEnabledSensors = async function () { try { const response = await fetch('/api/config/runtime', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getApiHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ rate: currentLoggingRateSeconds, + historyRetentionDays: currentHistoryRetentionDays, enabledSensors }) }); @@ -813,7 +998,10 @@ window.clearHistoricalData = async function () { if (!confirmacion) return; try { - const response = await fetch('/api/history/clear', { method: 'POST' }); + const response = await fetch('/api/history/clear', { + method: 'POST', + headers: getApiHeaders() + }); if (!response.ok) throw new Error("Fallo en el purgado de archivos"); // Limpia los datasets existentes sin recrear las gráficas. diff --git a/frontend/ezo-service.js b/frontend/ezo-service.js index d93ca23..9a85617 100644 --- a/frontend/ezo-service.js +++ b/frontend/ezo-service.js @@ -193,7 +193,7 @@ async function sendCommand(command, options = {}) { try { const response = await fetch(`/api/sensors/${sensorType}/command`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: window.getApiHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ command, dangerousConfirmed }) }); const data = await response.json(); diff --git a/frontend/index.html b/frontend/index.html index 6925c0f..bbc2123 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -127,6 +127,19 @@ + +
    +

    Umbrales Configurables

    +
    + +
    + +

    Bitácora de Alarmas

    +