Compare commits

...

12 commits

20 changed files with 978 additions and 96 deletions

1
.gitignore vendored
View file

@ -18,3 +18,4 @@ dist/*
.env
nohup.out
windy-*.txt
data/

View file

@ -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

View file

@ -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
View file

@ -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",

View file

@ -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"
}
}

View file

@ -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';

View file

@ -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
),
};
}

View 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;

View file

@ -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) => {

View file

@ -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;

View file

@ -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,

View file

@ -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);
}
};

View 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;
}
}
}
}
}

View file

@ -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;

View file

@ -17,4 +17,5 @@
@use 'spc-outlook';
@use 'server-observations';
@use 'linux-news';
@use 'hazard-list';
@use 'shared/scanlines';

File diff suppressed because one or more lines are too long

143
src/hazard-history.mjs Normal file
View 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
View 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,
};

View file

@ -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>

View 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>