docker
This commit is contained in:
parent
3c26875e5e
commit
697eb80d9c
13
.env
13
.env
@ -9,8 +9,13 @@ DB_USER=joshbaney
|
|||||||
DB_PASSWORD=Ran0dal5!
|
DB_PASSWORD=Ran0dal5!
|
||||||
DB_NAME=heatmap
|
DB_NAME=heatmap
|
||||||
|
|
||||||
# Slack Incoming Webhook URL
|
#AWS s3
|
||||||
SLACK_WEBHOOK_URL=https://hooks.slack.com/triggers/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
|
S3_BUCKET_URL=https://s3.amazonaws.com/bwi2temps/trends
|
||||||
|
|
||||||
AWS s3
|
# Slack & AWS creds
|
||||||
S3_BUCKET_URL=https://s3.amazonaws.com/your‐bucket/trends
|
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/
|
14
Dockerfile
14
Dockerfile
@ -1,18 +1,16 @@
|
|||||||
# Use official Node.js 18 LTS alpine image
|
# Dockerfile
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
|
|
||||||
# Create app directory
|
# Create app directory
|
||||||
WORKDIR /app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
# Install app dependencies
|
# Install dependencies
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install --production
|
RUN npm install --production
|
||||||
|
|
||||||
# Copy source
|
# Bundle app source
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Expose port
|
# Expose port and run
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
# Start the server
|
|
||||||
CMD ["npm", "start"]
|
|
||||||
|
@ -1,32 +1,13 @@
|
|||||||
|
# docker-compose.yaml
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
fuego-app:
|
||||||
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:
|
|
||||||
build: .
|
build: .
|
||||||
restart: always
|
env_file: .env # your DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SLACK_WEBHOOK_URL, etc.
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "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:
|
volumes:
|
||||||
- .:/app
|
- .:/usr/src/app # live code reload; remove in prod if undesired
|
||||||
|
restart: unless-stopped
|
||||||
volumes:
|
# no depends_on here since the DB lives elsewhere
|
||||||
db_data:
|
|
||||||
|
71
public/index.html
Normal file
71
public/index.html
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Fuego – Heat Tracker</title>
|
||||||
|
<!-- your other CSS/links here -->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 1) Scrollable container taking full viewport height */
|
||||||
|
#scroll-container {
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2) WebKit browsers */
|
||||||
|
#scroll-container::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
#scroll-container::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
#scroll-container::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #888;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 3px solid #f1f1f1;
|
||||||
|
}
|
||||||
|
#scroll-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3) Firefox */
|
||||||
|
#scroll-container {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #888 #f1f1f1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="scroll-container">
|
||||||
|
<!-- Your graph/chart -->
|
||||||
|
<div id="graph" style="height: 60vh; padding: 1rem;">
|
||||||
|
<!-- e.g. <canvas id="myChart"></canvas> or your chart library mount point -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Your spreadsheet/table -->
|
||||||
|
<div id="spreadsheet" style="height: 60vh; padding: 1rem;">
|
||||||
|
<!-- e.g. a table or your react/vanilla table component -->
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date/Time</th>
|
||||||
|
<th>Temperature</th>
|
||||||
|
<th>Humidity</th>
|
||||||
|
<th>Heat Index</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Direction</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- rows go here -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- your scripts here -->
|
||||||
|
<script src="/socket.io.js"></script>
|
||||||
|
<script src="main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -37,3 +37,41 @@
|
|||||||
<script src="scripts/input.js"></script>
|
<script src="scripts/input.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
<script>
|
||||||
|
// make sure this runs *after* your form is in the DOM
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const form = document.getElementById('readingForm');
|
||||||
|
form.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
// grab your values
|
||||||
|
const inbound_dockDoor = +document.querySelector('input[name="inbound_dockDoor"]').value;
|
||||||
|
const inbound_temperature = +document.querySelector('input[name="inbound_temperature"]').value;
|
||||||
|
const inbound_humidity = +document.querySelector('input[name="inbound_humidity"]').value;
|
||||||
|
const outbound_dockDoor = +document.querySelector('input[name="outbound_dockDoor"]').value;
|
||||||
|
const outbound_temperature = +document.querySelector('input[name="outbound_temperature"]').value;
|
||||||
|
const outbound_humidity = +document.querySelector('input[name="outbound_humidity"]').value;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
inbound_dockDoor,
|
||||||
|
inbound_temperature,
|
||||||
|
inbound_humidity,
|
||||||
|
outbound_dockDoor,
|
||||||
|
outbound_temperature,
|
||||||
|
outbound_humidity
|
||||||
|
};
|
||||||
|
console.log('👉 Sending payload:', payload);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/readings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
console.log('✅ Server response:', data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Fetch error:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@ -3,9 +3,11 @@ function average(arr) {
|
|||||||
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// period definitions
|
// interval mappings
|
||||||
const periodKeys = ['hourly','daily','weekly','monthly','yearly'];
|
const periodKeys = ['hourly','daily','weekly','monthly','yearly'];
|
||||||
const periodLabels = ['Hourly','Daily','Weekly','Monthly','Yearly'];
|
const periodLabels = ['Hourly','Daily','Weekly','Monthly','Yearly'];
|
||||||
|
|
||||||
|
// bucket definitions
|
||||||
const periodConfig = {
|
const periodConfig = {
|
||||||
hourly: { keyFn: r => r.timestamp.slice(0,13), labelFn: k => k.replace('T',' ') },
|
hourly: { keyFn: r => r.timestamp.slice(0,13), labelFn: k => k.replace('T',' ') },
|
||||||
daily: { keyFn: r => r.timestamp.slice(0,10), labelFn: k => k },
|
daily: { keyFn: r => r.timestamp.slice(0,10), labelFn: k => k },
|
||||||
@ -19,22 +21,62 @@ const periodConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// global Chart.js instance
|
// global Chart.js instance
|
||||||
let trendChart;
|
let trendChart, dataTable;
|
||||||
|
|
||||||
// fetch all readings
|
// fetch readings from server
|
||||||
async function fetchReadings() {
|
async function fetchReadings() {
|
||||||
const res = await fetch('/api/readings');
|
const res = await fetch('/api/readings');
|
||||||
return res.ok ? res.json() : [];
|
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(`
|
||||||
|
<tr>
|
||||||
|
<td>${ts}</td>
|
||||||
|
<td>${r.temperature.toFixed(1)}</td>
|
||||||
|
<td>${r.humidity.toFixed(1)}</td>
|
||||||
|
<td>${r.heatIndex.toFixed(1)}</td>
|
||||||
|
<td>${r.dockDoor}</td>
|
||||||
|
<td>${dir}</td>
|
||||||
|
</tr>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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() {
|
async function drawTrend() {
|
||||||
const all = await fetchReadings();
|
const all = await fetchReadings();
|
||||||
const slider = document.getElementById('periodSlider');
|
const slider = document.getElementById('periodSlider');
|
||||||
const periodKey = periodKeys[slider.value];
|
const periodKey = periodKeys[slider.value];
|
||||||
const cfg = periodConfig[periodKey];
|
const cfg = periodConfig[periodKey];
|
||||||
|
|
||||||
// group and compute stats
|
// group & stats
|
||||||
const groups = {};
|
const groups = {};
|
||||||
all.forEach(r => {
|
all.forEach(r => {
|
||||||
const key = cfg.keyFn(r);
|
const key = cfg.keyFn(r);
|
||||||
@ -74,10 +116,13 @@ async function drawTrend() {
|
|||||||
} else {
|
} else {
|
||||||
trendChart = new Chart(ctx, {
|
trendChart = new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: { labels: labels.map(cfg.labelFn), datasets },
|
data: {
|
||||||
|
labels: labels.map(cfg.labelFn),
|
||||||
|
datasets
|
||||||
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false, // <<— ensure the canvas fills its container
|
maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: true },
|
legend: { display: true },
|
||||||
tooltip: { mode:'index', intersect:false },
|
tooltip: { mode:'index', intersect:false },
|
||||||
@ -93,16 +138,23 @@ async function drawTrend() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// always update table below
|
||||||
|
renderTable(all);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wire up controls
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// initial draw
|
||||||
drawTrend();
|
drawTrend();
|
||||||
|
|
||||||
// slider
|
// slider
|
||||||
document.getElementById('periodSlider')
|
document.getElementById('periodSlider')
|
||||||
.addEventListener('input', e => {
|
.addEventListener('input', e => {
|
||||||
document.getElementById('periodLabel').textContent = periodLabels[e.target.value];
|
document.getElementById('periodLabel').textContent = periodLabels[e.target.value];
|
||||||
drawTrend();
|
drawTrend();
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggles
|
// toggles
|
||||||
document.querySelectorAll('#metricToggles input')
|
document.querySelectorAll('#metricToggles input')
|
||||||
.forEach(chk => chk.addEventListener('change', drawTrend));
|
.forEach(chk => chk.addEventListener('change', drawTrend));
|
||||||
|
@ -1,15 +1,17 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css" />
|
||||||
<title>Trend Graph</title>
|
<!-- DataTables CSS for table styling, sorting arrows, etc. -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.4/css/jquery.dataTables.min.css" />
|
||||||
|
<title>Trend Graph & Table</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="main-header">
|
<header class="main-header">
|
||||||
<img src="/image/logo.png" class="logo" alt="Amazon Logo">
|
<img src="/image/logo.png" class="logo" alt="Amazon Logo" />
|
||||||
<span class="header-title"> | Fuego - Heat Tracker</span>
|
<span class="header-title"> | Fuego - Heat Tracker</span>
|
||||||
<button class="nav-btn" onclick="location.href='/input.html'">Log Reading</button>
|
<button class="nav-btn" onclick="location.href='/input.html'">Log Reading</button>
|
||||||
<button class="nav-btn" onclick="location.href='/heatmap.html'">Heat Map</button>
|
<button class="nav-btn" onclick="location.href='/heatmap.html'">Heat Map</button>
|
||||||
@ -21,33 +23,56 @@
|
|||||||
|
|
||||||
<div id="trend-controls" style="text-align:center; margin-bottom:1rem;">
|
<div id="trend-controls" style="text-align:center; margin-bottom:1rem;">
|
||||||
<!-- Interval slider -->
|
<!-- Interval slider -->
|
||||||
<label for="periodSlider">Interval: <strong><span id="periodLabel">Daily</span></strong></label><br/>
|
<label for="periodSlider">
|
||||||
<input type="range" id="periodSlider" min="0" max="4" step="1" value="1">
|
Interval: <strong><span id="periodLabel">Daily</span></strong>
|
||||||
|
</label><br/>
|
||||||
|
<input type="range" id="periodSlider" min="0" max="4" step="1" value="1" />
|
||||||
|
|
||||||
<!-- Metric/stat toggles -->
|
<!-- Metric/stat toggles -->
|
||||||
<div id="metricToggles" style="margin-top:1rem;">
|
<div id="metricToggles" style="margin-top:1rem;">
|
||||||
<!-- temperature -->
|
<label><input type="checkbox" data-metric="temp" data-stat="avg" checked /> Temp Avg</label>
|
||||||
<label><input type="checkbox" data-metric="temp" data-stat="avg" checked> Temp Avg</label>
|
<label><input type="checkbox" data-metric="temp" data-stat="min" checked /> Temp Min</label>
|
||||||
<label><input type="checkbox" data-metric="temp" data-stat="min" checked> Temp Min</label>
|
<label><input type="checkbox" data-metric="temp" data-stat="max" checked /> Temp Max</label>
|
||||||
<label><input type="checkbox" data-metric="temp" data-stat="max" checked> Temp Max</label>
|
<label><input type="checkbox" data-metric="hum" data-stat="avg" checked /> Hum Avg</label>
|
||||||
<!-- humidity -->
|
<label><input type="checkbox" data-metric="hum" data-stat="min" checked /> Hum Min</label>
|
||||||
<label><input type="checkbox" data-metric="hum" data-stat="avg" checked> Hum Avg</label>
|
<label><input type="checkbox" data-metric="hum" data-stat="max" checked /> Hum Max</label>
|
||||||
<label><input type="checkbox" data-metric="hum" data-stat="min" checked> Hum Min</label>
|
<label><input type="checkbox" data-metric="hi" data-stat="avg" checked /> HI Avg</label>
|
||||||
<label><input type="checkbox" data-metric="hum" data-stat="max" checked> Hum Max</label>
|
<label><input type="checkbox" data-metric="hi" data-stat="min" checked /> HI Min</label>
|
||||||
<!-- heat index -->
|
<label><input type="checkbox" data-metric="hi" data-stat="max" checked /> HI Max</label>
|
||||||
<label><input type="checkbox" data-metric="hi" data-stat="avg" checked> HI Avg</label>
|
|
||||||
<label><input type="checkbox" data-metric="hi" data-stat="min" checked> HI Min</label>
|
|
||||||
<label><input type="checkbox" data-metric="hi" data-stat="max" checked> HI Max</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<canvas id="trendChart"></canvas>
|
<canvas id="trendChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Table container -->
|
||||||
|
<div class="table-container">
|
||||||
|
<table id="trendTable" class="display" style="width:100%">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date/Time</th>
|
||||||
|
<th>Temperature (°F)</th>
|
||||||
|
<th>Humidity (%)</th>
|
||||||
|
<th>Heat Index</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Direction</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- populated dynamically -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart.js and zoom plugin -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
|
||||||
|
<!-- jQuery + DataTables -->
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<script src="https://cdn.datatables.net/1.13.4/js/jquery.dataTables.min.js"></script>
|
||||||
|
<!-- Your trends logic -->
|
||||||
<script src="scripts/trends.js"></script>
|
<script src="scripts/trends.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
229
server.js
229
server.js
@ -10,11 +10,60 @@ const { uploadTrendsCsv } = require('./s3');
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
// In‑memory shift counters
|
// In-memory shift counters
|
||||||
const shiftCounters = {};
|
const shiftCounters = {};
|
||||||
|
|
||||||
// Create MariaDB connection pool
|
// Helper: format a Date in EST as SQL DATETIME string
|
||||||
// server.js (pool section)
|
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({
|
const pool = mysql.createPool({
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
port: parseInt(process.env.DB_PORT, 10) || 3306,
|
port: parseInt(process.env.DB_PORT, 10) || 3306,
|
||||||
@ -24,16 +73,18 @@ const pool = mysql.createPool({
|
|||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 10,
|
connectionLimit: 10,
|
||||||
queueLimit: 0,
|
queueLimit: 0,
|
||||||
connectTimeout: 10000 // give it up to 10s before timing out
|
connectTimeout: 10000,
|
||||||
|
enableKeepAlive: true,
|
||||||
|
keepAliveInitialDelay: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Ensure readings table exists
|
// Ensure readings table exists
|
||||||
(async () => {
|
(async () => {
|
||||||
const createSQL = `
|
const createSQL = `
|
||||||
CREATE TABLE IF NOT EXISTS readings (
|
CREATE TABLE IF NOT EXISTS readings (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
dockDoor INT NOT NULL,
|
dockDoor INT NOT NULL,
|
||||||
|
direction VARCHAR(10) NOT NULL,
|
||||||
timestamp DATETIME NOT NULL,
|
timestamp DATETIME NOT NULL,
|
||||||
temperature DOUBLE,
|
temperature DOUBLE,
|
||||||
humidity DOUBLE,
|
humidity DOUBLE,
|
||||||
@ -43,7 +94,7 @@ const pool = mysql.createPool({
|
|||||||
await pool.execute(createSQL);
|
await pool.execute(createSQL);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Simple SSE setup
|
// SSE setup
|
||||||
let clients = [];
|
let clients = [];
|
||||||
app.get('/api/stream', (req, res) => {
|
app.get('/api/stream', (req, res) => {
|
||||||
res.set({
|
res.set({
|
||||||
@ -53,137 +104,117 @@ app.get('/api/stream', (req, res) => {
|
|||||||
});
|
});
|
||||||
res.flushHeaders();
|
res.flushHeaders();
|
||||||
clients.push(res);
|
clients.push(res);
|
||||||
req.on('close', () => {
|
req.on('close', () => { clients = clients.filter(c => c !== res); });
|
||||||
clients = clients.filter(c => c !== res);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
function broadcast(event, data) {
|
function broadcast(event, data) {
|
||||||
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
clients.forEach(c => c.write(msg));
|
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
|
// Middleware & static files
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
// Dual‐reading POST endpoint
|
// Dual-reading POST endpoint
|
||||||
app.post('/api/readings', async (req, res) => {
|
app.post('/api/readings', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { inbound, outbound } = req.body;
|
console.log('🔔 POST /api/readings body:', req.body);
|
||||||
const hiIn = computeHeatIndex(inbound.temperature, inbound.humidity);
|
const { inbound = {}, outbound = {} } = req.body;
|
||||||
const hiOut = computeHeatIndex(outbound.temperature, outbound.humidity);
|
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
|
// Shift & period logic
|
||||||
const { shift, shiftStart, key, estNow } = getShiftInfo(new Date());
|
const { shift, shiftStart, key, estNow } = getShiftInfo(new Date());
|
||||||
shiftCounters[key] = (shiftCounters[key] || 0) + 1;
|
shiftCounters[key] = (shiftCounters[key] || 0) + 1;
|
||||||
const period = shiftCounters[key];
|
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 = `
|
const insertSQL = `
|
||||||
INSERT INTO readings
|
INSERT INTO readings
|
||||||
(dockDoor, timestamp, temperature, humidity, heatIndex)
|
(dockDoor, direction, timestamp, temperature, humidity, heatIndex)
|
||||||
VALUES (?, NOW(), ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`;
|
`;
|
||||||
await pool.execute(insertSQL, [inbound.dockDoor, inbound.temperature, inbound.humidity, hiIn]);
|
await pool.execute(insertSQL, [inDoor, 'inbound', sqlTimestamp, inTemp, inHum, hiIn]);
|
||||||
await pool.execute(insertSQL, [outbound.dockDoor, outbound.temperature, outbound.humidity, hiOut]);
|
await pool.execute(insertSQL, [outDoor, 'outbound', sqlTimestamp, outTemp, outHum, hiOut]);
|
||||||
|
|
||||||
// Broadcast to SSE clients (use UTC ISO for front‐end)
|
// Broadcast via SSE (EST)
|
||||||
const isoNow = new Date().toISOString();
|
broadcast('new-reading', {
|
||||||
broadcast('new-reading', { dockDoor: inbound.dockDoor, timestamp: isoNow, ...inbound, heatIndex: hiIn });
|
dockDoor: inDoor,
|
||||||
broadcast('new-reading', { dockDoor: outbound.dockDoor, timestamp: isoNow, ...outbound, heatIndex: hiOut });
|
direction: 'inbound',
|
||||||
|
timestamp: broadcastTimestamp,
|
||||||
// Format EST timestamp for Slack
|
temperature: inTemp,
|
||||||
const estString = estNow.toLocaleString('en-US', {
|
humidity: inHum,
|
||||||
timeZone: 'America/New_York',
|
heatIndex: hiIn
|
||||||
hour12: false,
|
});
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
broadcast('new-reading', {
|
||||||
hour: '2-digit', minute: '2-digit'
|
dockDoor: outDoor,
|
||||||
|
direction: 'outbound',
|
||||||
|
timestamp: broadcastTimestamp,
|
||||||
|
temperature: outTemp,
|
||||||
|
humidity: outHum,
|
||||||
|
heatIndex: hiOut
|
||||||
});
|
});
|
||||||
|
|
||||||
// Base Slack text
|
// Generate and upload today's CSV, get URL
|
||||||
let slackText =
|
const dateKey = `${estNow.getFullYear()}${String(estNow.getMonth()+1).padStart(2,'0')}${String(estNow.getDate()).padStart(2,'0')}`;
|
||||||
`${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')}`;
|
|
||||||
const [rows] = await pool.execute(
|
const [rows] = await pool.execute(
|
||||||
`SELECT * FROM readings
|
`SELECT * FROM readings
|
||||||
WHERE DATE(timestamp) = CURDATE()
|
WHERE DATE(timestamp) = CURDATE()
|
||||||
ORDER BY timestamp ASC`
|
ORDER BY timestamp ASC`
|
||||||
);
|
);
|
||||||
|
let csvUrl = null;
|
||||||
try {
|
try {
|
||||||
const publicUrl = await uploadTrendsCsv(dateKey, rows);
|
csvUrl = await uploadTrendsCsv(dateKey, rows);
|
||||||
slackText += `\n\nDownload daily trends: ${publicUrl}`;
|
} catch (uploadErr) {
|
||||||
} catch(uploadErr) {
|
|
||||||
console.error('Failed to upload CSV to S3:', uploadErr);
|
console.error('Failed to upload CSV to S3:', uploadErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send Slack notification
|
// Flat JSON Slack payload with CSV URL
|
||||||
await axios.post(process.env.SLACK_WEBHOOK_URL, { text: slackText });
|
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) {
|
} catch (err) {
|
||||||
console.error('POST /api/readings error:', err);
|
console.error('❌ POST /api/readings error:', err);
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return all readings as JSON
|
// Return all readings
|
||||||
app.get('/api/readings', async (req, res) => {
|
app.get('/api/readings', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`);
|
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('GET /api/readings error:', err);
|
console.error('❌ GET /api/readings error:', err);
|
||||||
res.status(500).json({ error: err.message });
|
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`);
|
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`);
|
||||||
res.setHeader('Content-disposition', 'attachment; filename=readings.csv');
|
res.setHeader('Content-disposition', 'attachment; filename=readings.csv');
|
||||||
res.set('Content-Type', 'text/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 => {
|
rows.forEach(r => {
|
||||||
// ensure ISO timestamp
|
const ts = (r.timestamp instanceof Date) ? formatDateEST(r.timestamp) : r.timestamp;
|
||||||
const ts = (r.timestamp instanceof Date)
|
res.write(`${r.id},${r.dockDoor},${r.direction},${ts},${r.temperature},${r.humidity},${r.heatIndex}\n`);
|
||||||
? r.timestamp.toISOString()
|
|
||||||
: r.timestamp;
|
|
||||||
res.write(`${r.id},${r.dockDoor},${ts},${r.temperature},${r.humidity},${r.heatIndex}\n`);
|
|
||||||
});
|
});
|
||||||
res.end();
|
res.end();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('GET /api/export error:', err);
|
console.error('❌ GET /api/export error:', err);
|
||||||
res.status(500).send(err.message);
|
res.status(500).send(err.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server running on http://localhost:${PORT}`);
|
console.log(`Server running on http://localhost:${PORT}`);
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user