Add MySQL2 library as dependency for the hazard history, rewrite to use mysql for data
This commit is contained in:
parent
5998d2583a
commit
bbaa2cb1a4
7 changed files with 336 additions and 238 deletions
47
README.md
47
README.md
|
|
@ -120,6 +120,48 @@ The `Ground View` screen requires a Windy Webcams API key. Create a file named `
|
||||||
|
|
||||||
You can obtain a free API key from [Windy Webcams API](https://api.windy.com/webcams).
|
You can obtain a free API key from [Windy Webcams API](https://api.windy.com/webcams).
|
||||||
|
|
||||||
|
## MySQL
|
||||||
|
|
||||||
|
`Hazard List` is now backed by MySQL in server mode. The UI only shows the latest 7 hazards, but the database retains full history.
|
||||||
|
|
||||||
|
Set these environment variables before starting the app:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WS4KP_MYSQL_HOST=127.0.0.1
|
||||||
|
WS4KP_MYSQL_PORT=3306
|
||||||
|
WS4KP_MYSQL_SOCKET_PATH=/var/run/mysql/mysql.sock
|
||||||
|
WS4KP_MYSQL_USER=root
|
||||||
|
WS4KP_MYSQL_PASSWORD=your-password
|
||||||
|
WS4KP_MYSQL_DATABASE=ws4kp_linhanced
|
||||||
|
```
|
||||||
|
|
||||||
|
If your local MariaDB/MySQL instance is socket-only, set `WS4KP_MYSQL_SOCKET_PATH` and omit `WS4KP_MYSQL_HOST` / `WS4KP_MYSQL_PORT`.
|
||||||
|
|
||||||
|
Create the required table:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE hazard_history (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
location_label VARCHAR(255) NOT NULL,
|
||||||
|
location_key VARCHAR(128) NOT NULL,
|
||||||
|
hazard_type VARCHAR(128) NOT NULL,
|
||||||
|
source VARCHAR(64) NOT NULL,
|
||||||
|
severity VARCHAR(64) DEFAULT NULL,
|
||||||
|
latest_hazard_id VARCHAR(255) DEFAULT NULL,
|
||||||
|
encountered_at DATETIME NOT NULL,
|
||||||
|
last_seen_at DATETIME NOT NULL,
|
||||||
|
ongoing TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_logical_hazard (location_key, hazard_type, source),
|
||||||
|
KEY idx_last_seen_at (last_seen_at),
|
||||||
|
KEY idx_location_key (location_key),
|
||||||
|
KEY idx_ongoing (ongoing)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
```
|
||||||
|
|
||||||
## Running Modes
|
## Running Modes
|
||||||
|
|
||||||
This fork supports two main runtime styles.
|
This fork supports two main runtime styles.
|
||||||
|
|
@ -137,6 +179,7 @@ This mode includes:
|
||||||
* Express server entry point
|
* Express server entry point
|
||||||
* proxying and caching for weather/map requests
|
* proxying and caching for weather/map requests
|
||||||
* Fastfetch-backed Server Observations
|
* Fastfetch-backed Server Observations
|
||||||
|
* MySQL-backed Hazard List history
|
||||||
* better shared performance when multiple clients use the same instance
|
* better shared performance when multiple clients use the same instance
|
||||||
|
|
||||||
### Static Mode
|
### Static Mode
|
||||||
|
|
@ -154,9 +197,9 @@ Or upload the generated `dist/` directory to your web server after running:
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
The static build has been adjusted so frontend-generated paths no longer assume deployment at `/`, which makes subdirectory hosting more practical. **Also, features that require a backend server like the on-disk cache, Fastfetch-backed Server Observations, LWN Linux News, and `Ground View` will not work when running the static build by itself.**
|
The static build has been adjusted so frontend-generated paths no longer assume deployment at `/`, which makes subdirectory hosting more practical. **Also, features that require a backend server like the on-disk cache, Fastfetch-backed Server Observations, LWN Linux News, `Ground View`, and `Hazard List` will not work when running the static build by itself.**
|
||||||
|
|
||||||
The public demo at [https://mentalnet.xyz/ws4kp-linhanced-demo/](https://mentalnet.xyz/ws4kp-linhanced-demo/) is intentionally served as a static build, so the `Linux News`, `Server Observations`, and `Ground View` screens will not work there.
|
The public demo at [https://mentalnet.xyz/ws4kp-linhanced-demo/](https://mentalnet.xyz/ws4kp-linhanced-demo/) is intentionally served as a static build, so the `Linux News`, `Server Observations`, `Ground View`, and `Hazard List` screens will not work there.
|
||||||
|
|
||||||
## International Support
|
## International Support
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import cache from './proxy/cache.mjs';
|
||||||
import devTools from './src/com.chrome.devtools.mjs';
|
import devTools from './src/com.chrome.devtools.mjs';
|
||||||
import { discoverThemes } from './src/theme-discovery.mjs';
|
import { discoverThemes } from './src/theme-discovery.mjs';
|
||||||
import { findNearestWindyWebcam, loadWindyApiKey } from './src/windy-webcams.mjs';
|
import { findNearestWindyWebcam, loadWindyApiKey } from './src/windy-webcams.mjs';
|
||||||
|
import { checkHazardHistoryTable } from './src/mysql.mjs';
|
||||||
import { getHistory, updateHistory } from './src/hazard-history.mjs';
|
import { getHistory, updateHistory } from './src/hazard-history.mjs';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
@ -203,6 +204,12 @@ const staticOptions = {
|
||||||
// Weather.gov API proxy (catch-all for any Weather.gov API endpoint)
|
// Weather.gov API proxy (catch-all for any Weather.gov API endpoint)
|
||||||
// Skip setting up routes for the caching proxy server in static mode
|
// Skip setting up routes for the caching proxy server in static mode
|
||||||
if (!process.env?.STATIC) {
|
if (!process.env?.STATIC) {
|
||||||
|
try {
|
||||||
|
await checkHazardHistoryTable();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
// Server info endpoint for fastfetch output (must be before /api/ weather proxy)
|
// Server info endpoint for fastfetch output (must be before /api/ weather proxy)
|
||||||
app.get('/api/server-info', async (req, res) => {
|
app.get('/api/server-info', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
106
package-lock.json
generated
106
package-lock.json
generated
|
|
@ -11,7 +11,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^17.0.1",
|
"dotenv": "^17.0.1",
|
||||||
"ejs": "^5.0.1",
|
"ejs": "^5.0.1",
|
||||||
"express": "^5.1.0"
|
"express": "^5.1.0",
|
||||||
|
"mysql2": "^3.22.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
|
@ -3071,6 +3072,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/aws-ssl-profiles": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/b4a": {
|
"node_modules/b4a": {
|
||||||
"version": "1.8.0",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
|
||||||
|
|
@ -3829,6 +3839,15 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/denque": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
|
@ -5094,6 +5113,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/generate-function": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-property": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/generator-function": {
|
"node_modules/generator-function": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
||||||
|
|
@ -6421,6 +6449,12 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-property": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-regex": {
|
"node_modules/is-regex": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||||
|
|
@ -6861,6 +6895,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/long": {
|
||||||
|
"version": "5.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||||
|
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/lower-case": {
|
"node_modules/lower-case": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
||||||
|
|
@ -6871,6 +6911,21 @@
|
||||||
"tslib": "^2.0.3"
|
"tslib": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lru.min": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.0.0",
|
||||||
|
"deno": ">=1.30.0",
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wellwelwel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/luxon": {
|
"node_modules/luxon": {
|
||||||
"version": "3.7.2",
|
"version": "3.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
|
@ -7037,6 +7092,40 @@
|
||||||
"node": ">= 10.13.0"
|
"node": ">= 10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mysql2": {
|
||||||
|
"version": "3.22.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.1.tgz",
|
||||||
|
"integrity": "sha512-48+9UXehKyxxiP2pqCxUq+MSFvX+v41jwsSpFDQO/jAoFuAELutBGJUhWJnDbe82/OBlIhSBMC82WeonmznT/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"aws-ssl-profiles": "^1.1.2",
|
||||||
|
"denque": "^2.1.0",
|
||||||
|
"generate-function": "^2.3.1",
|
||||||
|
"iconv-lite": "^0.7.2",
|
||||||
|
"long": "^5.3.2",
|
||||||
|
"lru.min": "^1.1.4",
|
||||||
|
"named-placeholders": "^1.1.6",
|
||||||
|
"sql-escaper": "^1.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/named-placeholders": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lru.min": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/natural-compare": {
|
"node_modules/natural-compare": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||||
|
|
@ -8379,6 +8468,21 @@
|
||||||
"node": ">= 10.13.0"
|
"node": ">= 10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sql-escaper": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.0.0",
|
||||||
|
"deno": ">=2.0.0",
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^17.0.1",
|
"dotenv": "^17.0.1",
|
||||||
"ejs": "^5.0.1",
|
"ejs": "^5.0.1",
|
||||||
"express": "^5.1.0"
|
"express": "^5.1.0",
|
||||||
|
"mysql2": "^3.22.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -285,7 +285,7 @@ class Hazards extends WeatherDisplay {
|
||||||
id: hazard.id,
|
id: hazard.id,
|
||||||
hazardType: hazard.properties?.event || 'Unknown',
|
hazardType: hazard.properties?.event || 'Unknown',
|
||||||
severity: hazard.properties?.severity || 'Unknown',
|
severity: hazard.properties?.severity || 'Unknown',
|
||||||
source: hazard.properties?.senderName?.includes('NOAA') ? 'noaa' : 'derived',
|
source: String(hazard.id || '').startsWith('derived-') ? 'derived' : 'noaa',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Send to backend
|
// Send to backend
|
||||||
|
|
|
||||||
|
|
@ -1,141 +1,24 @@
|
||||||
/**
|
import { getPool } from './mysql.mjs';
|
||||||
* Hazard History persistence module
|
|
||||||
* Tracks the last 7 hazard alerts encountered by this server instance
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
const HISTORY_FILE = path.resolve('./data/hazard-history.json');
|
|
||||||
const MAX_HISTORY_ENTRIES = 7;
|
const MAX_HISTORY_ENTRIES = 7;
|
||||||
|
|
||||||
/**
|
const toIsoString = (value) => {
|
||||||
* Ensure the cache directory exists
|
if (!value) return null;
|
||||||
*/
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
const ensureCacheDir = async () => {
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
const cacheDir = path.dirname(HISTORY_FILE);
|
|
||||||
try {
|
|
||||||
await mkdir(cacheDir, { recursive: true });
|
|
||||||
} catch (error) {
|
|
||||||
// Directory may already exist
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const mapRowToHistoryEntry = (row) => ({
|
||||||
* Load hazard history from disk
|
location: row.location_label,
|
||||||
* @returns {Array} Array of hazard history entries
|
locationKey: row.location_key,
|
||||||
*/
|
hazardType: row.hazard_type,
|
||||||
const loadHistory = async () => {
|
source: row.source,
|
||||||
try {
|
severity: row.severity,
|
||||||
await ensureCacheDir();
|
latestHazardId: row.latest_hazard_id,
|
||||||
const data = await readFile(HISTORY_FILE, 'utf8');
|
encounteredAt: toIsoString(row.encountered_at),
|
||||||
const parsed = JSON.parse(data);
|
lastSeenAt: toIsoString(row.last_seen_at),
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
ongoing: Boolean(row.ongoing),
|
||||||
} catch (error) {
|
});
|
||||||
// File doesn't exist or is corrupted, return empty array
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save hazard history to disk
|
|
||||||
* @param {Array} history - Array of hazard history entries
|
|
||||||
*/
|
|
||||||
const saveHistory = async (history) => {
|
|
||||||
try {
|
|
||||||
await ensureCacheDir();
|
|
||||||
await writeFile(HISTORY_FILE, JSON.stringify(history, null, '\t'));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save hazard history:', error.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCoordinateLocationKey = (value) => /^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/.test(value ?? '');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a stable identity for a hazard entry.
|
|
||||||
* This intentionally ignores upstream alert ids so alert revisions
|
|
||||||
* continue updating the same logical history row.
|
|
||||||
* @param {string} locationKey - Stable location key
|
|
||||||
* @param {string} hazardType - Hazard/event name
|
|
||||||
* @param {string} source - Hazard source
|
|
||||||
* @returns {string} Stable identity key
|
|
||||||
*/
|
|
||||||
const generateKey = (locationKey, hazardType, source) => `${locationKey}::${hazardType}::${source}`;
|
|
||||||
|
|
||||||
const normalizeTimestamp = (value, fallback) => {
|
|
||||||
const date = new Date(value);
|
|
||||||
return Number.isNaN(date.getTime()) ? fallback : date.toISOString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSameLogicalHazard = (left, right) => left.location === right.location
|
|
||||||
&& left.hazardType === right.hazardType
|
|
||||||
&& left.source === right.source;
|
|
||||||
|
|
||||||
const isSameRequestedLocation = (entry, location, locationKey) => {
|
|
||||||
if (locationKey && entry.locationKey === locationKey) return true;
|
|
||||||
return entry.location === location;
|
|
||||||
};
|
|
||||||
|
|
||||||
const upgradeEntryLocationKey = (entry, location, locationKey) => {
|
|
||||||
if (!locationKey || entry.locationKey === locationKey) return entry;
|
|
||||||
return {
|
|
||||||
...entry,
|
|
||||||
location,
|
|
||||||
locationKey,
|
|
||||||
key: generateKey(locationKey, entry.hazardType, entry.source),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const mergeEntries = (existing, incoming) => {
|
|
||||||
const existingEncountered = normalizeTimestamp(existing.encounteredAt, incoming.encounteredAt);
|
|
||||||
const incomingEncountered = normalizeTimestamp(incoming.encounteredAt, existing.encounteredAt);
|
|
||||||
const existingLastSeen = normalizeTimestamp(existing.lastSeenAt, incoming.lastSeenAt);
|
|
||||||
const incomingLastSeen = normalizeTimestamp(incoming.lastSeenAt, existing.lastSeenAt);
|
|
||||||
const keepIncomingLocationKey = isCoordinateLocationKey(incoming.locationKey) && !isCoordinateLocationKey(existing.locationKey);
|
|
||||||
const latestHazardId = new Date(incomingLastSeen) >= new Date(existingLastSeen)
|
|
||||||
? (incoming.latestHazardId ?? existing.latestHazardId)
|
|
||||||
: (existing.latestHazardId ?? incoming.latestHazardId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...existing,
|
|
||||||
location: keepIncomingLocationKey ? incoming.location : (existing.location || incoming.location),
|
|
||||||
locationKey: keepIncomingLocationKey ? incoming.locationKey : (existing.locationKey || incoming.locationKey),
|
|
||||||
key: keepIncomingLocationKey ? incoming.key : existing.key,
|
|
||||||
encounteredAt: new Date(existingEncountered) <= new Date(incomingEncountered) ? existingEncountered : incomingEncountered,
|
|
||||||
lastSeenAt: new Date(existingLastSeen) >= new Date(incomingLastSeen) ? existingLastSeen : incomingLastSeen,
|
|
||||||
ongoing: Boolean(existing.ongoing || incoming.ongoing),
|
|
||||||
severity: incoming.severity || existing.severity,
|
|
||||||
source: incoming.source || existing.source,
|
|
||||||
latestHazardId,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeHistory = (history = []) => {
|
|
||||||
const normalized = [];
|
|
||||||
|
|
||||||
for (const rawEntry of history) {
|
|
||||||
if (!rawEntry?.hazardType || !rawEntry?.source) continue;
|
|
||||||
const locationKey = rawEntry.locationKey || rawEntry.location;
|
|
||||||
const entry = {
|
|
||||||
...rawEntry,
|
|
||||||
locationKey,
|
|
||||||
key: generateKey(locationKey, rawEntry.hazardType, rawEntry.source),
|
|
||||||
encounteredAt: normalizeTimestamp(rawEntry.encounteredAt, new Date().toISOString()),
|
|
||||||
lastSeenAt: normalizeTimestamp(rawEntry.lastSeenAt ?? rawEntry.encounteredAt, new Date().toISOString()),
|
|
||||||
latestHazardId: rawEntry.latestHazardId ?? rawEntry.hazardId ?? rawEntry.id ?? rawEntry.key,
|
|
||||||
};
|
|
||||||
|
|
||||||
const existingIndex = normalized.findIndex((candidate) => candidate.key === entry.key || isSameLogicalHazard(candidate, entry));
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
normalized[existingIndex] = mergeEntries(normalized[existingIndex], entry);
|
|
||||||
} else {
|
|
||||||
normalized.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format location label from weather parameters
|
* Format location label from weather parameters
|
||||||
|
|
@ -147,120 +30,114 @@ const normalizeHistory = (history = []) => {
|
||||||
*/
|
*/
|
||||||
const formatLocation = (city, state, country, countryCode) => {
|
const formatLocation = (city, state, country, countryCode) => {
|
||||||
const cleanCity = city?.trim() || 'Unknown';
|
const cleanCity = city?.trim() || 'Unknown';
|
||||||
|
|
||||||
// US locations: "City, State"
|
|
||||||
if (countryCode === 'US' || countryCode === 'USA') {
|
if (countryCode === 'US' || countryCode === 'USA') {
|
||||||
const cleanState = state?.trim();
|
const cleanState = state?.trim();
|
||||||
return cleanState ? `${cleanCity}, ${cleanState}` : cleanCity;
|
return cleanState ? `${cleanCity}, ${cleanState}` : cleanCity;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-US locations: "City, Country"
|
|
||||||
const cleanCountry = country?.trim();
|
const cleanCountry = country?.trim();
|
||||||
return cleanCountry ? `${cleanCity}, ${cleanCountry}` : cleanCity;
|
return cleanCountry ? `${cleanCity}, ${cleanCountry}` : cleanCity;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const getHistory = async () => {
|
||||||
* Update hazard history with current active hazards for a location
|
const [rows] = await getPool().query(
|
||||||
* @param {Object} payload - Request payload
|
`SELECT
|
||||||
* @param {string} payload.location - Formatted location label (for display)
|
location_label,
|
||||||
* @param {string} payload.locationKey - Stable location key from lat/lon (for matching)
|
location_key,
|
||||||
* @param {Array} payload.hazards - Array of active hazards
|
hazard_type,
|
||||||
* @returns {Array} Updated history
|
source,
|
||||||
*/
|
severity,
|
||||||
const updateHistory = async (payload) => {
|
latest_hazard_id,
|
||||||
const { location, locationKey, hazards = [] } = payload;
|
encountered_at,
|
||||||
|
last_seen_at,
|
||||||
// Load existing history
|
ongoing
|
||||||
let history = normalizeHistory(await loadHistory());
|
FROM hazard_history
|
||||||
const now = new Date().toISOString();
|
ORDER BY last_seen_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
// Use locationKey for matching if provided, fall back to location for backward compatibility
|
[MAX_HISTORY_ENTRIES],
|
||||||
const matchKey = locationKey || location;
|
);
|
||||||
|
|
||||||
// Create a set of active hazard identities for this location
|
|
||||||
const activeKeys = new Set(hazards.map((hazard) => generateKey(matchKey, hazard.hazardType, hazard.source)));
|
|
||||||
|
|
||||||
// Mark previously ongoing hazards for this location as ended if no longer active
|
|
||||||
history = history.map((entry) => {
|
|
||||||
if (!isSameRequestedLocation(entry, location, locationKey)) return entry;
|
|
||||||
|
|
||||||
const upgradedEntry = upgradeEntryLocationKey(entry, location, locationKey);
|
return rows.map(mapRowToHistoryEntry);
|
||||||
|
|
||||||
// If this entry is ongoing but not in the current active set, mark it as ended
|
|
||||||
if (upgradedEntry.ongoing && !activeKeys.has(upgradedEntry.key)) {
|
|
||||||
return {
|
|
||||||
...upgradedEntry,
|
|
||||||
ongoing: false,
|
|
||||||
lastSeenAt: now,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return upgradedEntry;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add or update active hazards
|
|
||||||
hazards.forEach((hazard) => {
|
|
||||||
const key = generateKey(matchKey, hazard.hazardType, hazard.source);
|
|
||||||
const existingIndex = history.findIndex((entry) => entry.key === key);
|
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
// Update existing entry
|
|
||||||
history[existingIndex] = {
|
|
||||||
...history[existingIndex],
|
|
||||||
lastSeenAt: now,
|
|
||||||
ongoing: true,
|
|
||||||
// Update severity if it changed
|
|
||||||
severity: hazard.severity || history[existingIndex].severity,
|
|
||||||
latestHazardId: hazard.id,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Create new entry
|
|
||||||
history.push({
|
|
||||||
key,
|
|
||||||
location,
|
|
||||||
locationKey: matchKey,
|
|
||||||
hazardType: hazard.hazardType,
|
|
||||||
encounteredAt: now,
|
|
||||||
lastSeenAt: now,
|
|
||||||
ongoing: true,
|
|
||||||
severity: hazard.severity,
|
|
||||||
source: hazard.source,
|
|
||||||
latestHazardId: hazard.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
history = normalizeHistory(history);
|
|
||||||
|
|
||||||
// Sort by lastSeenAt descending (newest first)
|
|
||||||
history.sort((a, b) => new Date(b.lastSeenAt) - new Date(a.lastSeenAt));
|
|
||||||
|
|
||||||
// Trim to max entries
|
|
||||||
if (history.length > MAX_HISTORY_ENTRIES) {
|
|
||||||
history = history.slice(0, MAX_HISTORY_ENTRIES);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save updated history
|
|
||||||
await saveHistory(history);
|
|
||||||
|
|
||||||
return history;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const updateHistory = async (payload) => {
|
||||||
* Get current hazard history
|
const { location, locationKey, hazards = [] } = payload;
|
||||||
* @returns {Array} Current history entries
|
const validHazards = hazards.filter((hazard) => hazard?.hazardType && hazard?.source);
|
||||||
*/
|
const pool = getPool();
|
||||||
const getHistory = async () => {
|
const connection = await pool.getConnection();
|
||||||
const history = normalizeHistory(await loadHistory());
|
|
||||||
// Ensure sorted by lastSeenAt descending
|
try {
|
||||||
return history.sort((a, b) => new Date(b.lastSeenAt) - new Date(a.lastSeenAt));
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
if (validHazards.length === 0) {
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE hazard_history
|
||||||
|
SET ongoing = 0,
|
||||||
|
last_seen_at = UTC_TIMESTAMP()
|
||||||
|
WHERE location_key = ?
|
||||||
|
AND ongoing = 1`,
|
||||||
|
[locationKey],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const keepClauses = validHazards.map(() => '(hazard_type = ? AND source = ?)').join(' OR ');
|
||||||
|
const keepParams = validHazards.flatMap((hazard) => [hazard.hazardType, hazard.source]);
|
||||||
|
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE hazard_history
|
||||||
|
SET ongoing = 0,
|
||||||
|
last_seen_at = UTC_TIMESTAMP()
|
||||||
|
WHERE location_key = ?
|
||||||
|
AND ongoing = 1
|
||||||
|
AND NOT (${keepClauses})`,
|
||||||
|
[locationKey, ...keepParams],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const hazard of validHazards) {
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO hazard_history (
|
||||||
|
location_label,
|
||||||
|
location_key,
|
||||||
|
hazard_type,
|
||||||
|
source,
|
||||||
|
severity,
|
||||||
|
latest_hazard_id,
|
||||||
|
encountered_at,
|
||||||
|
last_seen_at,
|
||||||
|
ongoing
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, UTC_TIMESTAMP(), UTC_TIMESTAMP(), 1)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
location_label = VALUES(location_label),
|
||||||
|
severity = VALUES(severity),
|
||||||
|
latest_hazard_id = VALUES(latest_hazard_id),
|
||||||
|
last_seen_at = UTC_TIMESTAMP(),
|
||||||
|
ongoing = 1`,
|
||||||
|
[
|
||||||
|
location,
|
||||||
|
locationKey,
|
||||||
|
hazard.hazardType,
|
||||||
|
hazard.source,
|
||||||
|
hazard.severity ?? null,
|
||||||
|
hazard.id ?? null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return getHistory();
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
loadHistory,
|
|
||||||
saveHistory,
|
|
||||||
updateHistory,
|
|
||||||
getHistory,
|
|
||||||
formatLocation,
|
formatLocation,
|
||||||
generateKey,
|
getHistory,
|
||||||
MAX_HISTORY_ENTRIES,
|
MAX_HISTORY_ENTRIES,
|
||||||
|
updateHistory,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
66
src/mysql.mjs
Normal file
66
src/mysql.mjs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
|
||||||
|
let pool;
|
||||||
|
|
||||||
|
const getConfig = () => {
|
||||||
|
const {
|
||||||
|
WS4KP_MYSQL_HOST = '127.0.0.1',
|
||||||
|
WS4KP_MYSQL_PORT = '3306',
|
||||||
|
WS4KP_MYSQL_USER,
|
||||||
|
WS4KP_MYSQL_PASSWORD,
|
||||||
|
WS4KP_MYSQL_DATABASE,
|
||||||
|
WS4KP_MYSQL_SOCKET_PATH,
|
||||||
|
} = process.env;
|
||||||
|
|
||||||
|
if (!WS4KP_MYSQL_USER || !WS4KP_MYSQL_PASSWORD || !WS4KP_MYSQL_DATABASE) {
|
||||||
|
throw new Error('Missing MySQL configuration. Set WS4KP_MYSQL_USER, WS4KP_MYSQL_PASSWORD, and WS4KP_MYSQL_DATABASE.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
user: WS4KP_MYSQL_USER,
|
||||||
|
password: WS4KP_MYSQL_PASSWORD,
|
||||||
|
database: WS4KP_MYSQL_DATABASE,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (WS4KP_MYSQL_SOCKET_PATH) {
|
||||||
|
config.socketPath = WS4KP_MYSQL_SOCKET_PATH;
|
||||||
|
} else {
|
||||||
|
config.host = WS4KP_MYSQL_HOST;
|
||||||
|
config.port = Number(WS4KP_MYSQL_PORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPool = () => {
|
||||||
|
if (!pool) {
|
||||||
|
pool = mysql.createPool(getConfig());
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkHazardHistoryTable = async () => {
|
||||||
|
const config = getConfig();
|
||||||
|
const [rows] = await getPool().query(
|
||||||
|
`SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = ?
|
||||||
|
AND table_name = 'hazard_history'
|
||||||
|
LIMIT 1`,
|
||||||
|
[config.database],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new Error(`Hazard history database table 'hazard_history' is missing in database '${config.database}'. Run the documented CREATE TABLE statement before using Hazard List.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
checkHazardHistoryTable,
|
||||||
|
getPool,
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue