Compare commits
12 commits
587c9d4d62
...
732887ee93
| Author | SHA1 | Date | |
|---|---|---|---|
| 732887ee93 | |||
| 8880fc2f10 | |||
| 2e97e3746b | |||
| bbaa2cb1a4 | |||
| 5998d2583a | |||
| 9aae190f74 | |||
| dac15405fa | |||
| 92822e2ddc | |||
| ab0b915249 | |||
| 763352317f | |||
| 2243bc4c9d | |||
| d917bba357 |
20 changed files with 978 additions and 96 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,3 +18,4 @@ dist/*
|
|||
.env
|
||||
nohup.out
|
||||
windy-*.txt
|
||||
data/
|
||||
|
|
|
|||
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).
|
||||
|
||||
## 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
|
||||
|
||||
This fork supports two main runtime styles.
|
||||
|
|
@ -137,6 +179,7 @@ This mode includes:
|
|||
* Express server entry point
|
||||
* proxying and caching for weather/map requests
|
||||
* Fastfetch-backed Server Observations
|
||||
* MySQL-backed Hazard List history
|
||||
* better shared performance when multiple clients use the same instance
|
||||
|
||||
### Static Mode
|
||||
|
|
@ -154,9 +197,9 @@ Or upload the generated `dist/` directory to your web server after running:
|
|||
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
|
||||
|
||||
|
|
|
|||
53
index.mjs
53
index.mjs
|
|
@ -21,6 +21,8 @@ import cache from './proxy/cache.mjs';
|
|||
import devTools from './src/com.chrome.devtools.mjs';
|
||||
import { discoverThemes } from './src/theme-discovery.mjs';
|
||||
import { findNearestWindyWebcam, loadWindyApiKey } from './src/windy-webcams.mjs';
|
||||
import { checkHazardHistoryTable } from './src/mysql.mjs';
|
||||
import { getHistory, updateHistory } from './src/hazard-history.mjs';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
|
|
@ -97,6 +99,9 @@ const { themes, themeAssets } = await discoverThemes();
|
|||
const app = express();
|
||||
const port = process.env.WS4KP_PORT ?? 8080;
|
||||
|
||||
// Enable JSON parsing for API endpoints
|
||||
app.use(express.json());
|
||||
|
||||
// Set X-Weatherstar header globally for playlist fallback detection
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Weatherstar', 'true');
|
||||
|
|
@ -199,6 +204,12 @@ const staticOptions = {
|
|||
// Weather.gov API proxy (catch-all for any Weather.gov API endpoint)
|
||||
// Skip setting up routes for the caching proxy server in static mode
|
||||
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)
|
||||
app.get('/api/server-info', async (req, res) => {
|
||||
try {
|
||||
|
|
@ -284,6 +295,48 @@ if (!process.env?.STATIC) {
|
|||
}
|
||||
});
|
||||
|
||||
// Hazard History API endpoints
|
||||
app.get('/api/hazard-history', async (req, res) => {
|
||||
try {
|
||||
const history = await getHistory();
|
||||
res.json({
|
||||
success: true,
|
||||
history,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
history: [],
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/hazard-history', async (req, res) => {
|
||||
try {
|
||||
const { location, locationKey, hazards } = req.body;
|
||||
|
||||
if (!location || !locationKey || !Array.isArray(hazards)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing or invalid location/locationKey/hazards',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const history = await updateHistory({ location, locationKey, hazards });
|
||||
res.json({
|
||||
success: true,
|
||||
history,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api/', weatherProxy);
|
||||
|
||||
// Cache management DELETE endpoint to allow "uncaching" specific URLs
|
||||
|
|
|
|||
110
package-lock.json
generated
110
package-lock.json
generated
|
|
@ -1,17 +1,18 @@
|
|||
{
|
||||
"name": "ws4kp-linhanced",
|
||||
"version": "0.1",
|
||||
"version": "0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ws4kp-linhanced",
|
||||
"version": "0.1",
|
||||
"version": "0.1.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dotenv": "^17.0.1",
|
||||
"ejs": "^5.0.1",
|
||||
"express": "^5.1.0"
|
||||
"express": "^5.1.0",
|
||||
"mysql2": "^3.22.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
|
|
@ -3071,6 +3072,15 @@
|
|||
"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": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
|
||||
|
|
@ -3829,6 +3839,15 @@
|
|||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
|
|
@ -5094,6 +5113,15 @@
|
|||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
||||
|
|
@ -6421,6 +6449,12 @@
|
|||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||
|
|
@ -6861,6 +6895,12 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
||||
|
|
@ -6871,6 +6911,21 @@
|
|||
"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": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
|
|
@ -7037,6 +7092,40 @@
|
|||
"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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
|
|
@ -8379,6 +8468,21 @@
|
|||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "ws4kp-linhanced",
|
||||
"version": "0.1",
|
||||
"version": "0.2",
|
||||
"description": "WeatherStar 4000+: Linhanced - A Linux-focused fork of the WeatherStar 4000+ project",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
|
|
@ -56,6 +56,7 @@
|
|||
"dependencies": {
|
||||
"dotenv": "^17.0.1",
|
||||
"ejs": "^5.0.1",
|
||||
"express": "^5.1.0"
|
||||
"express": "^5.1.0",
|
||||
"mysql2": "^3.22.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import settings from './modules/settings.mjs';
|
|||
import './modules/utils/theme.mjs';
|
||||
import './modules/latestobservations.mjs';
|
||||
import './modules/groundview.mjs';
|
||||
import './modules/hazard-list.mjs';
|
||||
import AutoComplete from './modules/autocomplete.mjs';
|
||||
import { loadAllData } from './modules/utils/data-loader.mjs';
|
||||
import { debugFlag } from './modules/utils/debug.mjs';
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ class CurrentWeather extends WeatherDisplay {
|
|||
dewpoint: this.data.DewPoint + String.fromCharCode(176),
|
||||
ceiling: this.data.Ceiling === 0 ? 'Unlimited' : `${this.data.Ceiling}${this.data.CeilingUnit}`,
|
||||
visibility: `${this.data.Visibility}${this.data.VisibilityUnit}`,
|
||||
pressure: `${this.data.Pressure} ${this.data.PressureDirection}`,
|
||||
pressure: this.data.PressureDirection ? `${this.data.Pressure} ${this.data.PressureDirection}` : this.data.Pressure,
|
||||
icon: { type: 'img', src: this.data.Icon },
|
||||
};
|
||||
|
||||
|
|
@ -121,10 +121,15 @@ const getCurrentWeatherByHourFromTime = (data) => {
|
|||
return currDiff < prevDiff ? curr : prev;
|
||||
}, availableTimes[0]);
|
||||
|
||||
const diff = (closestTime.pressure_msl ?? 0) - (previousHour.pressure_msl ?? 0);
|
||||
let pressureTrend = 'Steady';
|
||||
if (diff > 0.5) pressureTrend = 'Rising';
|
||||
if (diff < -0.5) pressureTrend = 'Falling';
|
||||
const currentPressure = closestTime.pressure_msl;
|
||||
const previousPressure = previousHour.pressure_msl;
|
||||
let pressureTrend = '';
|
||||
if (Number.isFinite(currentPressure) && Number.isFinite(previousPressure)) {
|
||||
const diff = currentPressure - previousPressure;
|
||||
pressureTrend = 'Steady';
|
||||
if (diff > 0.5) pressureTrend = 'Rising';
|
||||
if (diff < -0.5) pressureTrend = 'Falling';
|
||||
}
|
||||
closestTime.pressureTrend = pressureTrend;
|
||||
closestTime.uv_index_max = data.forecast[currentDateKey]?.uv_index_max ?? closestTime.uv_index ?? 0;
|
||||
return closestTime;
|
||||
|
|
@ -144,6 +149,7 @@ const parseData = async (weatherParameters) => {
|
|||
const visibilityConverter = distanceKilometers();
|
||||
const ceilingMeters = Math.max(0, ((observation.temperature ?? 0) - (observation.dewPoint ?? 0)) * 68);
|
||||
const pressureValue = observation.pressure ?? currentForecast.pressure_msl ?? null;
|
||||
const resolvedWindGust = observation.windGust ?? currentForecast.wind_gusts_10m ?? 0;
|
||||
return {
|
||||
city: weatherParameters.city,
|
||||
timeZone: weatherParameters.timeZone,
|
||||
|
|
@ -158,17 +164,17 @@ const parseData = async (weatherParameters) => {
|
|||
WindSpeedRaw: observation.windSpeed,
|
||||
WindDirection: directionToNSEW(observation.windDirection ?? 0),
|
||||
Pressure: pressureValue === null ? '-' : pressureConverter(pressureValue * 100),
|
||||
PressureDirection: currentForecast.pressureTrend ?? 'Steady',
|
||||
PressureDirection: pressureValue === null ? '' : (currentForecast.pressureTrend ?? ''),
|
||||
Humidity: Math.round(observation.relativeHumidity ?? currentForecast.relative_humidity_2m ?? 0),
|
||||
WindGust: windConverter(observation.windGust),
|
||||
WindGustRaw: observation.windGust,
|
||||
WindGust: windConverter(resolvedWindGust),
|
||||
WindGustRaw: resolvedWindGust,
|
||||
WindUnit: windConverter.units,
|
||||
TextConditions: Number(observation.weatherCode ?? 0),
|
||||
Icon: getLargeIconFromWmoCodeWithWind(
|
||||
observation.weatherCode,
|
||||
Boolean(observation.isDay),
|
||||
observation.windSpeed,
|
||||
observation.windGust
|
||||
resolvedWindGust
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
137
server/scripts/modules/hazard-list.mjs
Normal file
137
server/scripts/modules/hazard-list.mjs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// Hazard List display - shows the last 7 hazard alerts encountered by this server
|
||||
import STATUS from './status.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
|
||||
class HazardList extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
super(navId, elemId, 'Hazard List', true);
|
||||
this.history = [];
|
||||
}
|
||||
|
||||
async getData(weatherParameters, refresh) {
|
||||
const superResult = super.getData(weatherParameters, refresh);
|
||||
|
||||
try {
|
||||
// Fetch hazard history from backend
|
||||
const response = await fetch('/api/hazard-history');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.data = result.history || [];
|
||||
this.setStatus(STATUS.loaded);
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to load hazard history');
|
||||
}
|
||||
} catch (error) {
|
||||
// In static mode or if backend is unavailable, show empty state
|
||||
this.data = [];
|
||||
if (error.message.includes('404') || error.message.includes('Failed to fetch')) {
|
||||
// Backend not available (static mode) - disable this display
|
||||
this.setStatus(STATUS.disabled);
|
||||
} else {
|
||||
this.setStatus(STATUS.failed);
|
||||
}
|
||||
}
|
||||
|
||||
return superResult;
|
||||
}
|
||||
|
||||
async drawCanvas() {
|
||||
super.drawCanvas();
|
||||
if (!this.data || this.data.length === 0) {
|
||||
this.showEmptyState();
|
||||
this.finishDraw();
|
||||
return;
|
||||
}
|
||||
|
||||
// Templates are extracted by WeatherDisplay.loadTemplates()
|
||||
const container = this.elem.querySelector('.hazard-list-rows');
|
||||
container.innerHTML = '';
|
||||
|
||||
// Add hazard rows
|
||||
this.data.forEach((hazard) => {
|
||||
const row = this.fillTemplate('hazard-list-row', {
|
||||
location: hazard.location,
|
||||
hazard: this.abbreviateHazardType(hazard.hazardType),
|
||||
date: this.formatDate(hazard.encounteredAt),
|
||||
ongoing: hazard.ongoing ? 'YES' : 'NO',
|
||||
});
|
||||
if (row) container.appendChild(row);
|
||||
});
|
||||
|
||||
this.finishDraw();
|
||||
}
|
||||
|
||||
showEmptyState() {
|
||||
const container = this.elem.querySelector('.hazard-list-rows');
|
||||
container.innerHTML = '';
|
||||
|
||||
const emptyRow = this.fillTemplate('hazard-list-row', {
|
||||
location: 'No hazard history available',
|
||||
hazard: '',
|
||||
date: '',
|
||||
ongoing: '',
|
||||
});
|
||||
if (emptyRow) container.appendChild(emptyRow);
|
||||
}
|
||||
|
||||
// Format ISO date to MM/DD
|
||||
formatDate(isoDate) {
|
||||
if (!isoDate) return '--/--';
|
||||
try {
|
||||
const date = new Date(isoDate);
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
return `${month}/${day}`;
|
||||
} catch {
|
||||
return '--/--';
|
||||
}
|
||||
}
|
||||
|
||||
// Abbreviate hazard type to 8 characters max with weather-specific shortcuts
|
||||
abbreviateHazardType(type) {
|
||||
if (!type) return '';
|
||||
|
||||
// Apply weather-specific abbreviations
|
||||
let abbreviated = type
|
||||
.replace(/Thunderstorm/g, 'T-storm')
|
||||
.replace(/Warning/g, 'Warn')
|
||||
.replace(/Advisory/g, 'Adv')
|
||||
.replace(/Visibility/g, 'Vis')
|
||||
.replace(/Reduced/g, 'Red')
|
||||
.replace(/Severe/g, 'Sev')
|
||||
.replace(/Extreme/g, 'Ext')
|
||||
.replace(/Weather/g, 'Wx')
|
||||
.replace(/Condition/g, 'Cond')
|
||||
.replace(/Temperature/g, 'Temp')
|
||||
.replace(/Precipitation/g, 'Precip')
|
||||
.replace(/Tornado/g, 'Torn')
|
||||
.replace(/Hurricane/g, 'Hurr')
|
||||
.replace(/Tropical/g, 'Trop')
|
||||
.replace(/Storm/g, 'Stm')
|
||||
.replace(/Wind/g, 'Wnd')
|
||||
.replace(/Snow/g, 'Snw')
|
||||
.replace(/Rain/g, 'Rn')
|
||||
.replace(/Fog/g, 'Fg')
|
||||
.replace(/Hail/g, 'Hl')
|
||||
.replace(/Freezing/g, 'Frz')
|
||||
.replace(/ Dense/g, 'Dns');
|
||||
|
||||
// Hard truncate to 8 characters if still too long
|
||||
if (abbreviated.length > 8) {
|
||||
abbreviated = abbreviated.substring(0, 8);
|
||||
}
|
||||
|
||||
return abbreviated;
|
||||
}
|
||||
}
|
||||
|
||||
// register display
|
||||
const display = new HazardList(15, 'hazard-list');
|
||||
registerDisplay(display);
|
||||
|
||||
export default display;
|
||||
|
|
@ -129,6 +129,9 @@ class Hazards extends WeatherDisplay {
|
|||
if (this.isEnabled) {
|
||||
this.drawLongCanvas();
|
||||
}
|
||||
|
||||
// Sync hazards to backend history
|
||||
await this.syncHazardHistory();
|
||||
} catch (error) {
|
||||
console.error(`Unexpected Active Alerts error: ${error.message}`);
|
||||
stopAlertTone();
|
||||
|
|
@ -263,6 +266,58 @@ class Hazards extends WeatherDisplay {
|
|||
this.getDataCallbacks.push(() => resolve(this.data));
|
||||
});
|
||||
}
|
||||
|
||||
// Sync current hazards to backend history
|
||||
async syncHazardHistory() {
|
||||
try {
|
||||
// Skip if no weather parameters available
|
||||
if (!this.weatherParameters) return;
|
||||
|
||||
// Format location
|
||||
const { city, state, country, countryCode, latitude, longitude } = this.weatherParameters;
|
||||
const location = this.formatLocationLabel(city, state, country, countryCode);
|
||||
|
||||
// Create stable location key from lat/lon (rounded to 3 decimal places ~111m precision)
|
||||
const locationKey = `${parseFloat(latitude).toFixed(3)},${parseFloat(longitude).toFixed(3)}`;
|
||||
|
||||
// Normalize hazards for the API
|
||||
const hazards = (this.data || []).map((hazard) => ({
|
||||
id: hazard.id,
|
||||
hazardType: hazard.properties?.event || 'Unknown',
|
||||
severity: hazard.properties?.severity || 'Unknown',
|
||||
source: String(hazard.id || '').startsWith('derived-') ? 'derived' : 'noaa',
|
||||
}));
|
||||
|
||||
// Send to backend
|
||||
await fetch('/api/hazard-history', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ location, locationKey, hazards }),
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - hazard history is non-critical
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn('Failed to sync hazard history:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format location label for history
|
||||
formatLocationLabel(city, state, country, countryCode) {
|
||||
const cleanCity = city?.trim() || 'Unknown';
|
||||
|
||||
// US locations: "City, State"
|
||||
if (countryCode === 'US' || countryCode === 'USA') {
|
||||
const cleanState = state?.trim();
|
||||
return cleanState ? `${cleanCity}, ${cleanState}` : cleanCity;
|
||||
}
|
||||
|
||||
// Non-US locations: "City, Country"
|
||||
const cleanCountry = country?.trim();
|
||||
return cleanCountry ? `${cleanCity}, ${cleanCountry}` : cleanCity;
|
||||
}
|
||||
}
|
||||
|
||||
const calcSeverity = (severity, event) => {
|
||||
|
|
|
|||
|
|
@ -55,12 +55,9 @@ const getSmallIconFromWmoCode = (code, isDaytime = true) => smallIcon(buildSynth
|
|||
const getWeatherGovTokenFromWmoCodeWithWind = (code, windSpeedKmh, windGustsKmh) => {
|
||||
const baseToken = getWeatherGovTokenFromWmoCode(code);
|
||||
const windDesc = getWindDescriptor(windSpeedKmh, windGustsKmh);
|
||||
const windCapableTokens = new Set(['skc', 'few', 'sct', 'bkn', 'ovc']);
|
||||
|
||||
// Only use wind icon for non-precipitation conditions
|
||||
// Precipitation codes: drizzle, rain, freezing rain, snow, sleet, thunderstorms
|
||||
const precipitationCodes = [51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 71, 73, 75, 77, 80, 81, 82, 85, 86, 95, 96, 99];
|
||||
|
||||
if (windDesc && !precipitationCodes.includes(Number(code))) {
|
||||
if (windDesc && windCapableTokens.has(baseToken)) {
|
||||
return `wind_${baseToken}`;
|
||||
}
|
||||
return baseToken;
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ const SEVERITY_RANK = {
|
|||
};
|
||||
|
||||
const RULE_PRIORITY = {
|
||||
tropical: 6,
|
||||
thunderstorm: 5,
|
||||
tropical: 7,
|
||||
thunderstorm: 6,
|
||||
fog: 5,
|
||||
freezing: 4,
|
||||
snow: 3,
|
||||
rain: 2,
|
||||
|
|
@ -23,11 +24,13 @@ const WEATHER_CODES = {
|
|||
snow: new Set([71, 73, 75, 77, 85, 86]),
|
||||
thunderstorm: new Set([95, 96, 99]),
|
||||
rain: new Set([51, 53, 55, 61, 63, 65, 80, 81, 82]),
|
||||
fog: new Set([45, 48]),
|
||||
};
|
||||
|
||||
const thresholds = {
|
||||
lowVisibilitySevere: 5 * METERS_PER_MILE,
|
||||
lowVisibilityExtreme: 2 * METERS_PER_MILE,
|
||||
denseFogVisibility: 1000,
|
||||
gustSevere: 20 * KPH_PER_MPH,
|
||||
gustExtreme: 35 * KPH_PER_MPH,
|
||||
highWindSevere: 40 * KPH_PER_MPH,
|
||||
|
|
@ -85,15 +88,22 @@ const getWorstHour = (hours, evaluator) => hours.reduce((worst, hour) => {
|
|||
const evaluateThunderstorm = (hours) => getWorstHour(hours, (hour) => {
|
||||
const code = Number(hour.weather_code ?? 0);
|
||||
if (!WEATHER_CODES.thunderstorm.has(code)) return null;
|
||||
const visibility = hour.visibility ?? Number.POSITIVE_INFINITY;
|
||||
const lowVisibility = visibility < thresholds.lowVisibilitySevere;
|
||||
|
||||
if (code === 96 || code === 99) {
|
||||
return {
|
||||
severity: 'Extreme',
|
||||
description: 'Thunderstorms with hail are possible in the next several hours and may create dangerous outdoor conditions.',
|
||||
description: lowVisibility
|
||||
? 'Thunderstorms with hail and very low visibility are expected in the next several hours and may create dangerous outdoor and travel conditions.'
|
||||
: 'Thunderstorms with hail are possible in the next several hours and may create dangerous outdoor conditions.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
severity: 'Severe',
|
||||
description: 'Thunderstorms are possible in the next several hours and may create hazardous outdoor conditions.',
|
||||
description: lowVisibility
|
||||
? 'Thunderstorms with reduced visibility are expected in the next several hours and may create hazardous outdoor and travel conditions.'
|
||||
: 'Thunderstorms are possible in the next several hours and may create hazardous outdoor conditions.',
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -204,11 +214,34 @@ const evaluateWind = (hours) => getWorstHour(hours, (hour) => {
|
|||
return null;
|
||||
});
|
||||
|
||||
const evaluateFog = (hours) => getWorstHour(hours, (hour) => {
|
||||
const code = Number(hour.weather_code ?? 0);
|
||||
if (!WEATHER_CODES.fog.has(code)) return null;
|
||||
const visibility = hour.visibility ?? Number.POSITIVE_INFINITY;
|
||||
|
||||
if (visibility <= thresholds.denseFogVisibility) {
|
||||
return {
|
||||
severity: 'Extreme',
|
||||
description: 'Dense fog with very low visibility is expected in the next several hours and may create dangerous travel conditions.',
|
||||
event: 'Dense Fog Warning',
|
||||
};
|
||||
}
|
||||
if (visibility < thresholds.lowVisibilitySevere) {
|
||||
return {
|
||||
severity: 'Severe',
|
||||
description: 'Reduced visibility with mist or low cloud is expected in the next several hours and may create hazardous travel conditions.',
|
||||
event: 'Reduced Visibility Advisory',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const deriveHazards = (weatherParameters) => {
|
||||
const upcomingHours = getUpcomingHours(weatherParameters);
|
||||
if (upcomingHours.length === 0) return [];
|
||||
const tropicalCandidate = evaluateTropical(upcomingHours);
|
||||
const thunderstormCandidate = evaluateThunderstorm(upcomingHours);
|
||||
const fogCandidate = evaluateFog(upcomingHours);
|
||||
const freezingCandidate = evaluateFreezing(upcomingHours);
|
||||
const snowCandidate = evaluateSnow(upcomingHours);
|
||||
const rainCandidate = evaluateRain(upcomingHours);
|
||||
|
|
@ -226,6 +259,11 @@ const deriveHazards = (weatherParameters) => {
|
|||
priority: RULE_PRIORITY.thunderstorm,
|
||||
...thunderstormCandidate,
|
||||
}),
|
||||
fogCandidate && buildDerivedHazard({
|
||||
id: 'derived-severe-weather-alert-fog',
|
||||
priority: RULE_PRIORITY.fog,
|
||||
...fogCandidate,
|
||||
}),
|
||||
freezingCandidate && buildDerivedHazard({
|
||||
id: 'derived-severe-weather-alert-freezing',
|
||||
priority: RULE_PRIORITY.freezing,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { rewriteUrl } from './url-rewrite.mjs';
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT = 15000; // For example, with 3 retries: 15s+1s+15s+2s+15s+5s+15s = 68s
|
||||
const inflightRequests = new Map();
|
||||
const responseCache = new Map();
|
||||
|
||||
// Centralized utilities for handling errors in Promise contexts
|
||||
const safeJson = async (url, params) => {
|
||||
|
|
@ -79,8 +81,81 @@ const USER_AGENT_EXCLUDED_HOSTS = [
|
|||
'services.arcgis.com',
|
||||
];
|
||||
|
||||
const classifyRequest = (_url) => {
|
||||
const url = new URL(_url, window.location.origin);
|
||||
if (url.hostname.includes('api.weather.gov')) {
|
||||
if (url.pathname.includes('/alerts/active')) return 'weatherGovAlerts';
|
||||
return 'weatherGovGeneral';
|
||||
}
|
||||
if (url.hostname.includes('api.open-meteo.com')) {
|
||||
return 'openMeteo';
|
||||
}
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getRequestPolicy = (requestClass, providedParams = {}) => {
|
||||
const defaults = {
|
||||
default: {
|
||||
timeout: DEFAULT_REQUEST_TIMEOUT,
|
||||
retryCount: 3,
|
||||
cacheTtlMs: 0,
|
||||
},
|
||||
weatherGovAlerts: {
|
||||
timeout: 8000,
|
||||
retryCount: 1,
|
||||
cacheTtlMs: 30000,
|
||||
},
|
||||
weatherGovGeneral: {
|
||||
timeout: 10000,
|
||||
retryCount: 2,
|
||||
cacheTtlMs: 60000,
|
||||
},
|
||||
openMeteo: {
|
||||
timeout: 8000,
|
||||
retryCount: 1,
|
||||
cacheTtlMs: 60000,
|
||||
},
|
||||
};
|
||||
|
||||
const policy = defaults[requestClass] ?? defaults.default;
|
||||
return {
|
||||
timeout: providedParams.timeout ?? policy.timeout,
|
||||
retryCount: providedParams.retryCount ?? policy.retryCount,
|
||||
cacheTtlMs: providedParams.cacheTtlMs ?? policy.cacheTtlMs,
|
||||
};
|
||||
};
|
||||
|
||||
const buildRequestKey = (url, responseType, params) => `${(params.method ?? 'GET').toUpperCase()}:${responseType}:${url.toString()}`;
|
||||
|
||||
const getCachedResponse = (key) => {
|
||||
const cached = responseCache.get(key);
|
||||
if (!cached) return null;
|
||||
if (Date.now() >= cached.expiresAt) {
|
||||
responseCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return cached.data;
|
||||
};
|
||||
|
||||
const setCachedResponse = (key, data, ttlMs) => {
|
||||
if (!ttlMs || ttlMs <= 0) return;
|
||||
responseCache.set(key, {
|
||||
data,
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
});
|
||||
};
|
||||
|
||||
const isTransientError = (error) => error?.name === 'TimeoutError'
|
||||
|| error?.message?.includes('429')
|
||||
|| error?.message?.includes('500')
|
||||
|| error?.message?.includes('502')
|
||||
|| error?.message?.includes('503')
|
||||
|| error?.message?.includes('504');
|
||||
|
||||
const fetchAsync = async (_url, responseType, _params = {}) => {
|
||||
const headers = {};
|
||||
const requestClass = classifyRequest(_url);
|
||||
const policy = getRequestPolicy(requestClass, _params);
|
||||
|
||||
const checkUrl = new URL(_url, window.location.origin);
|
||||
const shouldExcludeUserAgent = USER_AGENT_EXCLUDED_HOSTS.some((host) => checkUrl.hostname.includes(host));
|
||||
|
|
@ -98,10 +173,12 @@ const fetchAsync = async (_url, responseType, _params = {}) => {
|
|||
method: 'GET',
|
||||
mode: 'cors',
|
||||
type: 'GET',
|
||||
retryCount: 3, // Default to 3 retries for any failed requests (timeout or 5xx server errors)
|
||||
timeout: DEFAULT_REQUEST_TIMEOUT,
|
||||
retryCount: policy.retryCount,
|
||||
timeout: policy.timeout,
|
||||
cacheTtlMs: policy.cacheTtlMs,
|
||||
..._params,
|
||||
headers,
|
||||
requestClass,
|
||||
};
|
||||
|
||||
// rewrite URLs for various services to use the backend proxy server for proper caching (and request logging)
|
||||
|
|
@ -118,66 +195,91 @@ const fetchAsync = async (_url, responseType, _params = {}) => {
|
|||
});
|
||||
}
|
||||
|
||||
// make the request
|
||||
try {
|
||||
const response = await doFetch(url, params);
|
||||
|
||||
// check for ok response
|
||||
if (!response.ok) throw new Error(`Fetch error ${response.status} ${response.statusText} while fetching ${response.url}`);
|
||||
// process the response based on type
|
||||
let result;
|
||||
switch (responseType) {
|
||||
case 'json':
|
||||
result = await response.json();
|
||||
break;
|
||||
case 'text':
|
||||
result = await response.text();
|
||||
break;
|
||||
case 'blob':
|
||||
result = await response.blob();
|
||||
break;
|
||||
default:
|
||||
result = response;
|
||||
}
|
||||
|
||||
// Return both data and URL if requested
|
||||
if (params.returnUrl) {
|
||||
return {
|
||||
data: result,
|
||||
url: response.url,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Enhanced error handling for different error types
|
||||
if (error.name === 'AbortError') {
|
||||
// AbortError always happens in the browser, regardless of server vs static mode
|
||||
// Most likely causes include background tab throttling, user navigation, or client timeout
|
||||
console.log(`🛑 Fetch aborted for ${_url} (background tab throttling?)`);
|
||||
return null; // Always return null for AbortError instead of throwing
|
||||
} if (error.name === 'TimeoutError') {
|
||||
console.warn(`⏱️ Request timeout for ${_url} (${error.message})`);
|
||||
} else if (error.message.includes('502')) {
|
||||
console.warn(`🚪 Bad Gateway error for ${_url}`);
|
||||
} else if (error.message.includes('503')) {
|
||||
console.warn(`⌛ Temporarily unavailable for ${_url}`);
|
||||
} else if (error.message.includes('504')) {
|
||||
console.warn(`⏱️ Gateway Timeout for ${_url}`);
|
||||
} else if (error.message.includes('500')) {
|
||||
console.warn(`💥 Internal Server Error for ${_url}`);
|
||||
} else if (error.message.includes('CORS') || error.message.includes('Access-Control')) {
|
||||
console.warn(`🔒 CORS or Access Control error for ${_url}`);
|
||||
} else {
|
||||
console.warn(`❌ Fetch failed for ${_url} (${error.message})`);
|
||||
}
|
||||
|
||||
// Add standard error properties that calling code expects
|
||||
if (!error.status) error.status = 0;
|
||||
if (!error.responseJSON) error.responseJSON = null;
|
||||
|
||||
throw error;
|
||||
const shouldUseTransportCache = params.method.toUpperCase() === 'GET' && !params.returnUrl;
|
||||
const requestKey = shouldUseTransportCache ? buildRequestKey(url, responseType, params) : null;
|
||||
const cachedData = shouldUseTransportCache ? getCachedResponse(requestKey) : null;
|
||||
if (cachedData !== null) return cachedData;
|
||||
if (shouldUseTransportCache && inflightRequests.has(requestKey)) {
|
||||
return inflightRequests.get(requestKey);
|
||||
}
|
||||
|
||||
const executeFetch = async () => {
|
||||
// make the request
|
||||
try {
|
||||
const response = await doFetch(url, params);
|
||||
|
||||
// check for ok response
|
||||
if (!response.ok) throw new Error(`Fetch error ${response.status} ${response.statusText} while fetching ${response.url}`);
|
||||
// process the response based on type
|
||||
let result;
|
||||
switch (responseType) {
|
||||
case 'json':
|
||||
result = await response.json();
|
||||
break;
|
||||
case 'text':
|
||||
result = await response.text();
|
||||
break;
|
||||
case 'blob':
|
||||
result = await response.blob();
|
||||
break;
|
||||
default:
|
||||
result = response;
|
||||
}
|
||||
|
||||
if (shouldUseTransportCache) {
|
||||
setCachedResponse(requestKey, result, params.cacheTtlMs);
|
||||
}
|
||||
|
||||
// Return both data and URL if requested
|
||||
if (params.returnUrl) {
|
||||
return {
|
||||
data: result,
|
||||
url: response.url,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (shouldUseTransportCache && cachedData !== null && isTransientError(error)) {
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
// Enhanced error handling for different error types
|
||||
if (error.name === 'AbortError') {
|
||||
console.log(`🛑 Fetch aborted for ${_url} (background tab throttling?)`);
|
||||
return null;
|
||||
} if (error.name === 'TimeoutError') {
|
||||
console.warn(`⏱️ Request timeout for ${_url} (${error.message})`);
|
||||
} else if (error.message.includes('429')) {
|
||||
console.warn(`🐢 Rate limited for ${_url}`);
|
||||
} else if (error.message.includes('502')) {
|
||||
console.warn(`🚪 Bad Gateway error for ${_url}`);
|
||||
} else if (error.message.includes('503')) {
|
||||
console.warn(`⌛ Temporarily unavailable for ${_url}`);
|
||||
} else if (error.message.includes('504')) {
|
||||
console.warn(`⏱️ Gateway Timeout for ${_url}`);
|
||||
} else if (error.message.includes('500')) {
|
||||
console.warn(`💥 Internal Server Error for ${_url}`);
|
||||
} else if (error.message.includes('CORS') || error.message.includes('Access-Control')) {
|
||||
console.warn(`🔒 CORS or Access Control error for ${_url}`);
|
||||
} else {
|
||||
console.warn(`❌ Fetch failed for ${_url} (${error.message})`);
|
||||
}
|
||||
|
||||
if (!error.status) error.status = 0;
|
||||
if (!error.responseJSON) error.responseJSON = null;
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (!shouldUseTransportCache) return executeFetch();
|
||||
|
||||
const inflightPromise = executeFetch().finally(() => {
|
||||
inflightRequests.delete(requestKey);
|
||||
});
|
||||
inflightRequests.set(requestKey, inflightPromise);
|
||||
return inflightPromise;
|
||||
};
|
||||
|
||||
// fetch with retry and back-off
|
||||
|
|
@ -199,7 +301,7 @@ const doFetch = (url, params, originalRetryCount = null) => new Promise((resolve
|
|||
};
|
||||
|
||||
// Shared retry logic to avoid duplication
|
||||
const attemptRetry = (reason) => {
|
||||
const attemptRetry = (reason, retryAfterMs = null) => {
|
||||
// Safety check for params
|
||||
if (!params || typeof params.retryCount !== 'number') {
|
||||
console.error(`❌ Invalid params for retry: ${url}`);
|
||||
|
|
@ -208,7 +310,7 @@ const doFetch = (url, params, originalRetryCount = null) => new Promise((resolve
|
|||
|
||||
const retryAttempt = initialRetryCount - params.retryCount + 1;
|
||||
const remainingRetries = params.retryCount - 1;
|
||||
const delayMs = retryDelay(retryAttempt);
|
||||
const delayMs = retryDelay(retryAttempt, params.requestClass, retryAfterMs);
|
||||
|
||||
console.warn(`🔄 Retry ${retryAttempt}/${initialRetryCount} for ${url} - ${reason} (retrying in ${delayMs}ms, ${remainingRetries} retr${remainingRetries === 1 ? 'y' : 'ies'} left)`);
|
||||
|
||||
|
|
@ -235,6 +337,13 @@ const doFetch = (url, params, originalRetryCount = null) => new Promise((resolve
|
|||
fetch(url, fetchParams).then((response) => {
|
||||
clearTimeout(timeoutId); // Clear timeout on successful response
|
||||
|
||||
if (params && params.retryCount > 0 && response.status === 429) {
|
||||
const retryAfterHeader = response.headers.get('Retry-After');
|
||||
const retryAfterSeconds = Number.parseInt(retryAfterHeader, 10);
|
||||
const retryAfterMs = Number.isFinite(retryAfterSeconds) ? retryAfterSeconds * 1000 : null;
|
||||
return attemptRetry(`Rate limited 429 ${response.statusText}`, retryAfterMs);
|
||||
}
|
||||
|
||||
// Retry 500 status codes if we have retries left
|
||||
if (params && params.retryCount > 0 && response.status >= 500 && response.status <= 599) {
|
||||
let errorType = 'Server error';
|
||||
|
|
@ -289,13 +398,21 @@ const doFetch = (url, params, originalRetryCount = null) => new Promise((resolve
|
|||
});
|
||||
});
|
||||
|
||||
const retryDelay = (retryNumber) => {
|
||||
const retryDelay = (retryNumber, requestClass = 'default', retryAfterMs = null) => {
|
||||
if (retryAfterMs !== null) {
|
||||
return retryAfterMs + Math.floor(Math.random() * 400);
|
||||
}
|
||||
|
||||
if (requestClass === 'openMeteo') {
|
||||
return 5000 + Math.floor(Math.random() * 400);
|
||||
}
|
||||
|
||||
switch (retryNumber) {
|
||||
case 1: return 1000;
|
||||
case 2: return 2000;
|
||||
case 3: return 5000;
|
||||
case 4: return 10_000;
|
||||
default: return 30_000;
|
||||
case 1: return 1000 + Math.floor(Math.random() * 400);
|
||||
case 2: return 2000 + Math.floor(Math.random() * 400);
|
||||
case 3: return 5000 + Math.floor(Math.random() * 600);
|
||||
case 4: return 10_000 + Math.floor(Math.random() * 1000);
|
||||
default: return 30_000 + Math.floor(Math.random() * 1000);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
89
server/styles/scss/_hazard-list.scss
Normal file
89
server/styles/scss/_hazard-list.scss
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
@use 'shared/_colors' as c;
|
||||
@use 'shared/_utils' as u;
|
||||
|
||||
.weather-display .main.hazard-list {
|
||||
&.main {
|
||||
padding-top: 18px;
|
||||
|
||||
.column-headers {
|
||||
display: flex;
|
||||
font-family: 'Star4000';
|
||||
font-size: 14pt;
|
||||
font-weight: bold;
|
||||
color: #ff0;
|
||||
width: 70%;
|
||||
margin: 4px auto 2px;
|
||||
padding-top: 20px;
|
||||
text-shadow: 3px 3px 0 #000,-1.5px -1.5px 0 #000,0 -1.5px 0 #000,1.5px -1.5px 0 #000,1.5px 0 0 #000,1.5px 1.5px 0 #000,0 1.5px 0 #000,-1.5px 1.5px 0 #000,-1.5px 0 0 #000;
|
||||
|
||||
.location {
|
||||
width: 34%;
|
||||
}
|
||||
|
||||
.hazard {
|
||||
width: 30%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.date {
|
||||
width: 22%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ongoing {
|
||||
width: 14%;
|
||||
text-align: center;
|
||||
padding-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.hazard-list-rows {
|
||||
width: 70%;
|
||||
margin: 0 auto;
|
||||
|
||||
.hazard-list-row {
|
||||
display: flex;
|
||||
font-family: 'Star4000';
|
||||
font-size: 14pt;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
|
||||
@include u.text-shadow();
|
||||
|
||||
&.template {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.location {
|
||||
width: 34%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hazard {
|
||||
width: 30%;
|
||||
text-align: center;
|
||||
color: c.$title-color;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date {
|
||||
width: 22%;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ongoing {
|
||||
width: 14%;
|
||||
text-align: center;
|
||||
padding-right: 4px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
|
||||
.title {
|
||||
color: #eebe4b;
|
||||
text-shadow: 3px 3px 0 #000,-1.5px -1.5px 0 #000,0 -1.5px 0 #000,1.5px -1.5px 0 #000,1.5px 0 0 #000,1.5px 1.5px 0 #000,0 1.5px 0 #000,-1.5px 1.5px 0 #000,-1.5px 0 0 #000;
|
||||
text-shadow: -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000, 2px 2px 0 #000, 0px -2px 0 #000, 0px 2px 0 #000, -2px 0px 0 #000, 2px 0px 0 #000, 3px 3px 0px #000, 4px 4px 0px #000, 5px 5px 2px rgba(0, 0, 0, 0.8);
|
||||
font-family: 'Star4000';
|
||||
font-size: 24pt;
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -17,4 +17,5 @@
|
|||
@use 'spc-outlook';
|
||||
@use 'server-observations';
|
||||
@use 'linux-news';
|
||||
@use 'hazard-list';
|
||||
@use 'shared/scanlines';
|
||||
|
|
|
|||
2
server/styles/ws.min.css
vendored
2
server/styles/ws.min.css
vendored
File diff suppressed because one or more lines are too long
143
src/hazard-history.mjs
Normal file
143
src/hazard-history.mjs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { getPool } from './mysql.mjs';
|
||||
|
||||
const MAX_HISTORY_ENTRIES = 7;
|
||||
|
||||
const toIsoString = (value) => {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
};
|
||||
|
||||
const mapRowToHistoryEntry = (row) => ({
|
||||
location: row.location_label,
|
||||
locationKey: row.location_key,
|
||||
hazardType: row.hazard_type,
|
||||
source: row.source,
|
||||
severity: row.severity,
|
||||
latestHazardId: row.latest_hazard_id,
|
||||
encounteredAt: toIsoString(row.encountered_at),
|
||||
lastSeenAt: toIsoString(row.last_seen_at),
|
||||
ongoing: Boolean(row.ongoing),
|
||||
});
|
||||
|
||||
/**
|
||||
* Format location label from weather parameters
|
||||
* @param {string} city - City name
|
||||
* @param {string} state - State name
|
||||
* @param {string} country - Country name
|
||||
* @param {string} countryCode - Country code
|
||||
* @returns {string} Formatted location label
|
||||
*/
|
||||
const formatLocation = (city, state, country, countryCode) => {
|
||||
const cleanCity = city?.trim() || 'Unknown';
|
||||
|
||||
if (countryCode === 'US' || countryCode === 'USA') {
|
||||
const cleanState = state?.trim();
|
||||
return cleanState ? `${cleanCity}, ${cleanState}` : cleanCity;
|
||||
}
|
||||
|
||||
const cleanCountry = country?.trim();
|
||||
return cleanCountry ? `${cleanCity}, ${cleanCountry}` : cleanCity;
|
||||
};
|
||||
|
||||
const getHistory = async () => {
|
||||
const [rows] = await getPool().query(
|
||||
`SELECT
|
||||
location_label,
|
||||
location_key,
|
||||
hazard_type,
|
||||
source,
|
||||
severity,
|
||||
latest_hazard_id,
|
||||
encountered_at,
|
||||
last_seen_at,
|
||||
ongoing
|
||||
FROM hazard_history
|
||||
ORDER BY last_seen_at DESC
|
||||
LIMIT ?`,
|
||||
[MAX_HISTORY_ENTRIES],
|
||||
);
|
||||
|
||||
return rows.map(mapRowToHistoryEntry);
|
||||
};
|
||||
|
||||
const updateHistory = async (payload) => {
|
||||
const { location, locationKey, hazards = [] } = payload;
|
||||
const validHazards = hazards.filter((hazard) => hazard?.hazardType && hazard?.source);
|
||||
const pool = getPool();
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
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 {
|
||||
formatLocation,
|
||||
getHistory,
|
||||
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,
|
||||
};
|
||||
|
|
@ -151,6 +151,9 @@
|
|||
<div id="linux-news-html" class="weather-display">
|
||||
<%- include('partials/linux-news.ejs') %>
|
||||
</div>
|
||||
<div id="hazard-list-html" class="weather-display">
|
||||
<%- include('partials/hazard-list.ejs') %>
|
||||
</div>
|
||||
<%- include('partials/scroll.ejs') %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
27
views/partials/hazard-list.ejs
Normal file
27
views/partials/hazard-list.ejs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<div class="header">
|
||||
<div class="logo">
|
||||
<img class="theme-logo" src="images/logos/logo-corner.png" />
|
||||
</div>
|
||||
<div class="title dual">
|
||||
<div class="top">Hazard</div>
|
||||
<div class="bottom">List</div>
|
||||
</div>
|
||||
<div class="date-time date"></div>
|
||||
<div class="date-time time"></div>
|
||||
</div>
|
||||
<div class="main has-scroll hazard-list">
|
||||
<div class="column-headers">
|
||||
<div class="location">LOCATION</div>
|
||||
<div class="hazard">HAZARD</div>
|
||||
<div class="date">DATE</div>
|
||||
<div class="ongoing">ONGOING</div>
|
||||
</div>
|
||||
<div class="hazard-list-rows">
|
||||
<div class="hazard-list-row template">
|
||||
<div class="location"></div>
|
||||
<div class="hazard"></div>
|
||||
<div class="date"></div>
|
||||
<div class="ongoing"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue