diff --git a/.env b/.env index a55592e..95dfe58 100644 --- a/.env +++ b/.env @@ -9,8 +9,13 @@ DB_USER=joshbaney DB_PASSWORD=Ran0dal5! DB_NAME=heatmap -# Slack Incoming Webhook URL -SLACK_WEBHOOK_URL=https://hooks.slack.com/triggers/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 +#AWS s3 +S3_BUCKET_URL=https://s3.amazonaws.com/bwi2temps/trends -AWS s3 -S3_BUCKET_URL=https://s3.amazonaws.com/your‐bucket/trends +# Slack & AWS creds +SLACK_WEBHOOK_URL=https://hooks.slack.com/triggers/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 +AWS_ACCESS_KEY_ID=ihya/m4CONlywOPCNER22oZrbOeCdJLxp3R4H3oF +AWS_SECRET_ACCESS_KEY=AKIAQ3EGSIYOYP4L37HH +AWS_REGION=us-east-2 +S3_BUCKET_NAME=bwi2temps +S3_BASE_URL=https://s3.amazonaws.com/bwi2temps/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 26c51a8..4964504 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,16 @@ -# Use official Node.js 18 LTS alpine image +# Dockerfile FROM node:18-alpine # Create app directory -WORKDIR /app +WORKDIR /usr/src/app -# Install app dependencies +# Install dependencies COPY package*.json ./ RUN npm install --production -# Copy source +# Bundle app source COPY . . -# Expose port +# Expose port and run EXPOSE 3000 - -# Start the server -CMD ["npm", "start"] +CMD ["node", "server.js"] diff --git a/docker-compose.yaml b/docker-compose.yaml index f7f64bc..44163d0 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,32 +1,13 @@ +# docker-compose.yaml version: '3.8' services: - db: - image: mariadb:10.11 - restart: always - environment: - MYSQL_ROOT_PASSWORD: example_root_password - MYSQL_DATABASE: heat_tracker - MYSQL_USER: heat_user - MYSQL_PASSWORD: StrongP@ssw0rd - volumes: - - db_data:/var/lib/mysql - - app: + fuego-app: build: . - restart: always + env_file: .env # your DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SLACK_WEBHOOK_URL, etc. ports: - "3000:3000" - env_file: - - .env - environment: - # override DB_HOST to point at our db service - DB_HOST: db - DB_PORT: 3306 - depends_on: - - db volumes: - - .:/app - -volumes: - db_data: + - .:/usr/src/app # live code reload; remove in prod if undesired + restart: unless-stopped + # no depends_on here since the DB lives elsewhere diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..63cd579 --- /dev/null +++ b/public/index.html @@ -0,0 +1,71 @@ + + + + + Fuego – Heat Tracker + + + + + +
+ +
+ +
+ + +
+ + + + + + + + + + + + + + + +
Date/TimeTemperatureHumidityHeat IndexLocationDirection
+
+
+ + + + + + diff --git a/public/input.html b/public/input.html index 0a1b4dc..10cdfea 100644 --- a/public/input.html +++ b/public/input.html @@ -36,4 +36,42 @@ - \ No newline at end of file + + diff --git a/public/scripts/trends.js b/public/scripts/trends.js index 8ad064d..9b2a802 100644 --- a/public/scripts/trends.js +++ b/public/scripts/trends.js @@ -3,9 +3,11 @@ function average(arr) { return arr.reduce((a, b) => a + b, 0) / arr.length; } -// period definitions +// interval mappings const periodKeys = ['hourly','daily','weekly','monthly','yearly']; const periodLabels = ['Hourly','Daily','Weekly','Monthly','Yearly']; + +// bucket definitions const periodConfig = { hourly: { keyFn: r => r.timestamp.slice(0,13), labelFn: k => k.replace('T',' ') }, daily: { keyFn: r => r.timestamp.slice(0,10), labelFn: k => k }, @@ -19,22 +21,62 @@ const periodConfig = { }; // global Chart.js instance -let trendChart; +let trendChart, dataTable; -// fetch all readings +// fetch readings from server async function fetchReadings() { const res = await fetch('/api/readings'); return res.ok ? res.json() : []; } -// build or update chart +// determine direction based on dock door # +function getDirection(dock) { + return ( (dock>=124 && dock<=138) || (dock>=202 && dock<=209) ) + ? 'Inbound' + : 'Outbound'; +} + +// render DataTable +function renderTable(allReadings) { + const tbody = $('#trendTable tbody').empty(); + allReadings.forEach(r => { + const ts = new Date(r.timestamp).toLocaleString('en-US', { timeZone:'America/New_York' }); + const dir = getDirection(r.dockDoor); + tbody.append(` + + ${ts} + ${r.temperature.toFixed(1)} + ${r.humidity.toFixed(1)} + ${r.heatIndex.toFixed(1)} + ${r.dockDoor} + ${dir} + + `); + }); + + // initialize or redraw DataTable + if ($.fn.DataTable.isDataTable('#trendTable')) { + dataTable.clear().rows.add($('#trendTable tbody tr')).draw(); + } else { + dataTable = $('#trendTable').DataTable({ + paging: true, + pageLength: 25, + ordering: true, + order: [[0,'desc']], + autoWidth: false, + scrollX: true + }); + } +} + +// draw or update chart async function drawTrend() { const all = await fetchReadings(); const slider = document.getElementById('periodSlider'); const periodKey = periodKeys[slider.value]; const cfg = periodConfig[periodKey]; - // group and compute stats + // group & stats const groups = {}; all.forEach(r => { const key = cfg.keyFn(r); @@ -74,10 +116,13 @@ async function drawTrend() { } else { trendChart = new Chart(ctx, { type: 'line', - data: { labels: labels.map(cfg.labelFn), datasets }, + data: { + labels: labels.map(cfg.labelFn), + datasets + }, options: { responsive: true, - maintainAspectRatio: false, // <<— ensure the canvas fills its container + maintainAspectRatio: false, plugins: { legend: { display: true }, tooltip: { mode:'index', intersect:false }, @@ -93,17 +138,24 @@ async function drawTrend() { } }); } + + // always update table below + renderTable(all); } +// wire up controls document.addEventListener('DOMContentLoaded', () => { + // initial draw drawTrend(); + // slider document.getElementById('periodSlider') .addEventListener('input', e => { document.getElementById('periodLabel').textContent = periodLabels[e.target.value]; drawTrend(); }); + // toggles document.querySelectorAll('#metricToggles input') .forEach(chk => chk.addEventListener('change', drawTrend)); -}); +}); \ No newline at end of file diff --git a/public/trends.html b/public/trends.html index b0392ed..fdf2b4e 100644 --- a/public/trends.html +++ b/public/trends.html @@ -1,15 +1,17 @@ - - - - - Trend Graph + + + + + + + Trend Graph & Table
- + | Fuego - Heat Tracker @@ -21,33 +23,56 @@
-
- +
+
- - - - - - - - - - - - + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + +
Date/TimeTemperature (°F)Humidity (%)Heat IndexLocationDirection
+
+ + + + + - + \ No newline at end of file diff --git a/server.js b/server.js index 4dc0435..b55f7ac 100644 --- a/server.js +++ b/server.js @@ -1,32 +1,82 @@ // server.js require('dotenv').config(); -const express = require('express'); -const mysql = require('mysql2/promise'); -const bodyParser = require('body-parser'); -const path = require('path'); -const axios = require('axios'); +const express = require('express'); +const mysql = require('mysql2/promise'); +const bodyParser = require('body-parser'); +const path = require('path'); +const axios = require('axios'); const { uploadTrendsCsv } = require('./s3'); -const app = express(); +const app = express(); const PORT = process.env.PORT || 3000; -// In‑memory shift counters +// In-memory shift counters const shiftCounters = {}; -// Create MariaDB connection pool -// server.js (pool section) -const pool = mysql.createPool({ - host: process.env.DB_HOST, - port: parseInt(process.env.DB_PORT, 10) || 3306, - user: process.env.DB_USER, - password: process.env.DB_PASSWORD, - database: process.env.DB_NAME, - waitForConnections: true, - connectionLimit: 10, - queueLimit: 0, - connectTimeout: 10000 // give it up to 10s before timing out -}); +// Helper: format a Date in EST as SQL DATETIME string +function formatDateEST(date) { + const pad = n => n.toString().padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ` + + `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} +// Helper: format a Date in EST as ISO-like string (no “Z”) +function isoStringEST(date) { + const pad = n => n.toString().padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}` + + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +// Compute heat index (NOAA formula) +function computeHeatIndex(T, R) { + const [c1,c2,c3,c4,c5,c6,c7,c8,c9] = + [-42.379,2.04901523,10.14333127,-0.22475541,-0.00683783, + -0.05481717,0.00122874,0.00085282,-0.00000199]; + const HI = c1 + c2*T + c3*R + c4*T*R + + c5*T*T + c6*R*R + c7*T*T*R + + c8*T*R*R + c9*T*T*R*R; + return Math.round(HI * 100) / 100; +} + +// Determine shift info in EST +function getShiftInfo(now) { + const estNow = new Date(now.toLocaleString('en-US', { timeZone: 'America/New_York' })); + const [h,m] = [estNow.getHours(), estNow.getMinutes()]; + let shift, shiftStart = new Date(estNow); + + if (h > 7 || (h === 7 && m >= 0)) { + if (h < 17 || (h === 17 && m < 30)) { + shift = 'Day'; + shiftStart.setHours(7,0,0,0); + } else { + shift = 'Night'; + shiftStart.setHours(17,30,0,0); + } + } else { + shift = 'Night'; + shiftStart.setDate(shiftStart.getDate() - 1); + shiftStart.setHours(17,30,0,0); + } + + const key = `${shift}-${shiftStart.toISOString().slice(0,10)}-` + + `${shiftStart.getHours()}${shiftStart.getMinutes()}`; + return { shift, shiftStart, key, estNow }; +} + +// MariaDB connection pool +const pool = mysql.createPool({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT, 10) || 3306, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, + connectTimeout: 10000, + enableKeepAlive: true, + keepAliveInitialDelay: 10000, +}); // Ensure readings table exists (async () => { @@ -34,6 +84,7 @@ const pool = mysql.createPool({ CREATE TABLE IF NOT EXISTS readings ( id INT AUTO_INCREMENT PRIMARY KEY, dockDoor INT NOT NULL, + direction VARCHAR(10) NOT NULL, timestamp DATETIME NOT NULL, temperature DOUBLE, humidity DOUBLE, @@ -43,147 +94,127 @@ const pool = mysql.createPool({ await pool.execute(createSQL); })(); -// Simple SSE setup +// SSE setup let clients = []; app.get('/api/stream', (req, res) => { res.set({ - 'Content-Type': 'text/event-stream', + 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - Connection: 'keep-alive' + Connection: 'keep-alive' }); res.flushHeaders(); clients.push(res); - req.on('close', () => { - clients = clients.filter(c => c !== res); - }); + req.on('close', () => { clients = clients.filter(c => c !== res); }); }); function broadcast(event, data) { const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; clients.forEach(c => c.write(msg)); } -// Compute heat index (NOAA formula) -function computeHeatIndex(T, R) { - const c1=-42.379, c2=2.04901523, c3=10.14333127; - const c4=-0.22475541, c5=-6.83783e-3, c6=-0.05481717; - const c7=0.00122874, c8=0.00085282, c9=-0.00000199; - const HI = c1 + c2*T + c3*R + c4*T*R - + c5*T*T + c6*R*R + c7*T*T*R - + c8*T*R*R + c9*T*T*R*R; - return Math.round(HI * 100) / 100; -} - -// Determine shift info in EST -function getShiftInfo(now) { - // Convert to EST - const estNow = new Date(now.toLocaleString('en-US', { timeZone: 'America/New_York' })); - const h = estNow.getHours(), m = estNow.getMinutes(); - let shift, shiftStart; - - if (h > 7 || (h === 7 && m >= 0)) { - if (h < 17 || (h === 17 && m < 30)) { - shift = 'Day'; - shiftStart = new Date(estNow); - shiftStart.setHours(7,0,0,0); - } else { - shift = 'Night'; - shiftStart = new Date(estNow); - shiftStart.setHours(17,30,0,0); - } - } else { - shift = 'Night'; - shiftStart = new Date(estNow); - shiftStart.setDate(shiftStart.getDate() - 1); - shiftStart.setHours(17,30,0,0); - } - - const key = `${shift}-${shiftStart.toISOString().slice(0,10)}-${shiftStart.getHours()}${shiftStart.getMinutes()}`; - return { shift, shiftStart, key, estNow }; -} - // Middleware & static files app.use(bodyParser.json()); app.use(express.static(path.join(__dirname, 'public'))); -// Dual‐reading POST endpoint +// Dual-reading POST endpoint app.post('/api/readings', async (req, res) => { try { - const { inbound, outbound } = req.body; - const hiIn = computeHeatIndex(inbound.temperature, inbound.humidity); - const hiOut = computeHeatIndex(outbound.temperature, outbound.humidity); + console.log('🔔 POST /api/readings body:', req.body); + const { inbound = {}, outbound = {} } = req.body; + const { dockDoor: inDoor, temperature: inTemp, humidity: inHum } = inbound; + const { dockDoor: outDoor, temperature: outTemp, humidity: outHum } = outbound; + + // Validate inputs + if ([inDoor, inTemp, inHum, outDoor, outTemp, outHum].some(v => v === undefined)) { + return res.status(400).json({ error: 'Missing one of inbound/outbound fields' }); + } + + // Compute heat indices + const hiIn = computeHeatIndex(inTemp, inHum); + const hiOut = computeHeatIndex(outTemp, outHum); // Shift & period logic const { shift, shiftStart, key, estNow } = getShiftInfo(new Date()); shiftCounters[key] = (shiftCounters[key] || 0) + 1; const period = shiftCounters[key]; - // Insert both readings with server‐side NOW() + // Prepare EST timestamps + const sqlTimestamp = formatDateEST(estNow); + const broadcastTimestamp = isoStringEST(estNow); + + // Insert readings into DB const insertSQL = ` INSERT INTO readings - (dockDoor, timestamp, temperature, humidity, heatIndex) - VALUES (?, NOW(), ?, ?, ?) + (dockDoor, direction, timestamp, temperature, humidity, heatIndex) + VALUES (?, ?, ?, ?, ?, ?) `; - await pool.execute(insertSQL, [inbound.dockDoor, inbound.temperature, inbound.humidity, hiIn]); - await pool.execute(insertSQL, [outbound.dockDoor, outbound.temperature, outbound.humidity, hiOut]); + await pool.execute(insertSQL, [inDoor, 'inbound', sqlTimestamp, inTemp, inHum, hiIn]); + await pool.execute(insertSQL, [outDoor, 'outbound', sqlTimestamp, outTemp, outHum, hiOut]); - // Broadcast to SSE clients (use UTC ISO for front‐end) - const isoNow = new Date().toISOString(); - broadcast('new-reading', { dockDoor: inbound.dockDoor, timestamp: isoNow, ...inbound, heatIndex: hiIn }); - broadcast('new-reading', { dockDoor: outbound.dockDoor, timestamp: isoNow, ...outbound, heatIndex: hiOut }); - - // Format EST timestamp for Slack - const estString = estNow.toLocaleString('en-US', { - timeZone: 'America/New_York', - hour12: false, - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit' + // Broadcast via SSE (EST) + broadcast('new-reading', { + dockDoor: inDoor, + direction: 'inbound', + timestamp: broadcastTimestamp, + temperature: inTemp, + humidity: inHum, + heatIndex: hiIn + }); + broadcast('new-reading', { + dockDoor: outDoor, + direction: 'outbound', + timestamp: broadcastTimestamp, + temperature: outTemp, + humidity: outHum, + heatIndex: hiOut }); - // Base Slack text - let slackText = - `${shift} Shift ${period} temperature checks for ${estString} EST - -Inbound Dock Door: ${inbound.dockDoor} -Temp: ${inbound.temperature}°F -Humidity: ${inbound.humidity}% -Heat Index: ${hiIn} - -Outbound Dock Door: ${outbound.dockDoor} -Temp: ${outbound.temperature}°F -Humidity: ${outbound.humidity}% -Heat Index: ${hiOut}`; - - // Upload today's CSV and append URL - const dateKey = `${String(estNow.getFullYear())}${String(estNow.getMonth()+1).padStart(2,'0')}${String(estNow.getDate()).padStart(2,'0')}`; + // Generate and upload today's CSV, get URL + const dateKey = `${estNow.getFullYear()}${String(estNow.getMonth()+1).padStart(2,'0')}${String(estNow.getDate()).padStart(2,'0')}`; const [rows] = await pool.execute( `SELECT * FROM readings - WHERE DATE(timestamp) = CURDATE() - ORDER BY timestamp ASC` + WHERE DATE(timestamp) = CURDATE() + ORDER BY timestamp ASC` ); + let csvUrl = null; try { - const publicUrl = await uploadTrendsCsv(dateKey, rows); - slackText += `\n\nDownload daily trends: ${publicUrl}`; - } catch(uploadErr) { + csvUrl = await uploadTrendsCsv(dateKey, rows); + } catch (uploadErr) { console.error('Failed to upload CSV to S3:', uploadErr); } - // Send Slack notification - await axios.post(process.env.SLACK_WEBHOOK_URL, { text: slackText }); + // Flat JSON Slack payload with CSV URL + const slackPayload = { + text: 'New temperature readings recorded', + inbound_dockDoor: inDoor, + inbound_temperature: inTemp, + inbound_humidity: inHum, + hiIn, + outbound_dockDoor: outDoor, + outbound_temperature: outTemp, + outbound_humidity: outHum, + hiOut, + shift, + period, + timestamp: broadcastTimestamp, + csvUrl // include the CSV download URL + }; + console.log('🛠️ Slack payload:', slackPayload); + await axios.post(process.env.SLACK_WEBHOOK_URL, slackPayload); - res.json({ success: true, shift, period }); + res.json({ success: true, shift, period, csvUrl }); } catch (err) { - console.error('POST /api/readings error:', err); + console.error('❌ POST /api/readings error:', err); res.status(500).json({ error: err.message }); } }); -// Return all readings as JSON +// Return all readings app.get('/api/readings', async (req, res) => { try { const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`); res.json(rows); } catch (err) { - console.error('GET /api/readings error:', err); + console.error('❌ GET /api/readings error:', err); res.status(500).json({ error: err.message }); } }); @@ -194,21 +225,19 @@ app.get('/api/export', async (req, res) => { const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`); res.setHeader('Content-disposition', 'attachment; filename=readings.csv'); res.set('Content-Type', 'text/csv'); - res.write('id,dockDoor,timestamp,temperature,humidity,heatIndex\n'); + res.write('id,dockDoor,direction,timestamp,temperature,humidity,heatIndex\n'); rows.forEach(r => { - // ensure ISO timestamp - const ts = (r.timestamp instanceof Date) - ? r.timestamp.toISOString() - : r.timestamp; - res.write(`${r.id},${r.dockDoor},${ts},${r.temperature},${r.humidity},${r.heatIndex}\n`); + const ts = (r.timestamp instanceof Date) ? formatDateEST(r.timestamp) : r.timestamp; + res.write(`${r.id},${r.dockDoor},${r.direction},${ts},${r.temperature},${r.humidity},${r.heatIndex}\n`); }); res.end(); } catch (err) { - console.error('GET /api/export error:', err); + console.error('❌ GET /api/export error:', err); res.status(500).send(err.message); } }); +// Start server app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); });