v0.2.1! Adds instant Hazard List updates after hazard sync
Some checks are pending
build-docker / Build Image (push) Waiting to run

This commit is contained in:
mrkmntal 2026-04-19 13:50:02 -04:00
commit dbb32fd2f9
9 changed files with 362 additions and 5 deletions

View file

@ -2,6 +2,8 @@
`ws4kp-linhanced` is a Linux-focused fork of [`netbymatt/ws4kp`](https://github.com/netbymatt/ws4kp) by `markmental`.
Current release: `v0.2.1`
It keeps the stronger, more stable foundation of the original `ws4kp` project while selectively incorporating international weather support and global map ideas from [`mwood77/ws4kp-international`](https://github.com/mwood77/ws4kp-international). The goal is not to become a kitchen-sink weather platform. The goal is a leaner, maintainable, Linux-oriented WeatherStar fork with a clear identity.
This fork also explicitly embraces Slackware Linux / `weatherstar4k` branding as part of its mission. Broad platform neutrality is not a design goal here.
@ -47,6 +49,7 @@ Major features currently in this fork:
* global RainViewer radar on a cached world basemap
* global `Regional Observations` and nearby-city displays backed by expanded worldwide city coverage
* `Latest Observations` screen for nearby city temperatures, conditions, and wind
* `Hazard List` screen with MySQL-backed server history for encountered NOAA and derived hazards
* `Ground View` screen powered by nearby Windy webcams (requires API key, see below)
* Travel Forecast rebuilt around region buckets with a global fallback
* optional screen-specific audio playback for supported displays
@ -164,6 +167,10 @@ CREATE TABLE hazard_history (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
The database retains full hazard history. The `Hazard List` UI shows only the latest 7 entries and only the most recent row per stored location. Nearby same-label locations are reconciled during updates to reduce duplicate location spam.
In the current browser session, `Hazard List` updates immediately after new hazard history is synced.
## Running Modes
This fork supports two main runtime styles.
@ -182,6 +189,7 @@ This mode includes:
* proxying and caching for weather/map requests
* Fastfetch-backed Server Observations
* MySQL-backed Hazard List history
* automatic ongoing-hazard rechecks every 10 minutes for both NOAA and derived hazards
* better shared performance when multiple clients use the same instance
### Static Mode

View file

@ -23,6 +23,7 @@ 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';
import { startHazardHistoryWorker, stopHazardHistoryWorker } from './src/hazard-history-worker.mjs';
const execAsync = promisify(exec);
@ -204,12 +205,18 @@ 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) {
let hazardHistoryReady = false;
try {
await checkHazardHistoryTable();
hazardHistoryReady = true;
} catch (error) {
console.error(error.message);
}
if (hazardHistoryReady) {
startHazardHistoryWorker();
}
// Server info endpoint for fastfetch output (must be before /api/ weather proxy)
app.get('/api/server-info', async (req, res) => {
try {
@ -408,6 +415,7 @@ const server = app.listen(port, () => {
// graceful shutdown
const gracefulShutdown = () => {
stopHazardHistoryWorker();
server.close(() => {
console.log('Server closed');
process.exit(0);

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "ws4kp-linhanced",
"version": "0.2",
"version": "0.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ws4kp-linhanced",
"version": "0.1.1",
"version": "0.2.1",
"license": "MIT",
"dependencies": {
"dotenv": "^17.0.1",

View file

@ -1,6 +1,6 @@
{
"name": "ws4kp-linhanced",
"version": "0.2",
"version": "0.2.1",
"description": "WeatherStar 4000+: Linhanced - A Linux-focused fork of the WeatherStar 4000+ project",
"main": "index.mjs",
"type": "module",

View file

@ -8,11 +8,17 @@ class HazardList extends WeatherDisplay {
constructor(navId, elemId) {
super(navId, elemId, 'Hazard List', true);
this.history = [];
this.handleHazardHistoryUpdated = this.handleHazardHistoryUpdated.bind(this);
window.addEventListener('hazard-history-updated', this.handleHazardHistoryUpdated);
}
async getData(weatherParameters, refresh) {
const superResult = super.getData(weatherParameters, refresh);
await this.refreshHistoryNow();
return superResult;
}
async refreshHistoryNow() {
try {
// Fetch hazard history from backend
const response = await fetch(withBasePath('api/hazard-history'));
@ -37,8 +43,13 @@ class HazardList extends WeatherDisplay {
this.setStatus(STATUS.failed);
}
}
}
return superResult;
async handleHazardHistoryUpdated() {
await this.refreshHistoryNow();
if (this.active) {
this.drawCanvas();
}
}
async drawCanvas() {

View file

@ -290,13 +290,19 @@ class Hazards extends WeatherDisplay {
}));
// Send to backend
await fetch(withBasePath('api/hazard-history'), {
const response = await fetch(withBasePath('api/hazard-history'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ location, locationKey, hazards }),
});
if (!response.ok) {
throw new Error(`Hazard history sync failed with status ${response.status}`);
}
window.dispatchEvent(new CustomEvent('hazard-history-updated'));
} catch (error) {
// Silently fail - hazard history is non-critical
if (debugFlag('verbose-failures')) {

View file

@ -0,0 +1,187 @@
import deriveHazards from '../server/scripts/modules/utils/derived-hazards.mjs';
import {
getOngoingHazards,
markHazardEndedById,
touchHazardStillOngoing,
} from './hazard-history.mjs';
import { buildWeatherParametersForLocation, parseLocationKey } from './weather-parameters.mjs';
const HAZARD_HISTORY_CHECK_INTERVAL_MS = 10 * 60 * 1000;
const HAZARD_HISTORY_CHECK_CONCURRENCY = 3;
const USER_AGENT = 'WeatherStar 4000+: Linhanced; marky611@gmail.com';
let hazardHistoryWorkerHandle = null;
let isHazardHistoryWorkerRunning = false;
const buildHazardIdentity = (hazardType, source) => `${hazardType}::${source}`;
const groupHazardsByLocationKey = (rows) => rows.reduce((groups, row) => {
const existing = groups.get(row.locationKey) ?? [];
existing.push(row);
groups.set(row.locationKey, existing);
return groups;
}, new Map());
const fetchJson = async (url) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(url, {
headers: {
'User-Agent': USER_AGENT,
},
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Fetch error ${response.status} ${response.statusText} while fetching ${url}`);
}
return await response.json();
} finally {
clearTimeout(timeoutId);
}
};
const mapNoaaAlertsToActiveHazards = (alertsResponse) => (alertsResponse?.features ?? []).map((feature) => ({
hazardType: feature.properties?.event || 'Unknown',
latestHazardId: feature.id || null,
severity: feature.properties?.severity || null,
source: 'noaa',
}));
const mapDerivedHazardsToActiveHazards = (derivedHazards) => (derivedHazards ?? []).map((hazard) => ({
hazardType: hazard.properties?.event || 'Unknown',
latestHazardId: hazard.id || null,
severity: hazard.properties?.severity || null,
source: 'derived',
}));
const fetchActiveNoaaHazardsForLocation = async ({ latitude, longitude }) => {
const url = new URL('https://api.weather.gov/alerts/active');
url.searchParams.set('point', `${latitude},${longitude}`);
url.searchParams.set('status', 'actual');
const alerts = await fetchJson(url.toString());
return mapNoaaAlertsToActiveHazards(alerts);
};
const fetchActiveDerivedHazardsForLocation = async ({ locationKey, locationLabel }) => {
const weatherParameters = await buildWeatherParametersForLocation({ locationKey, locationLabel });
return mapDerivedHazardsToActiveHazards(deriveHazards(weatherParameters));
};
const runWithConcurrency = async (items, limit, worker) => {
const queue = [...items];
const runners = Array.from({ length: Math.min(limit, queue.length) }, async () => {
while (queue.length > 0) {
const next = queue.shift();
if (!next) return;
await worker(next);
}
});
await Promise.all(runners);
};
const reconcileHazardsForLocation = async ({ locationKey, rows }) => {
const coordinates = parseLocationKey(locationKey);
if (!coordinates) {
throw new Error(`Invalid location key '${locationKey}' in hazard history worker`);
}
const locationLabel = rows[0]?.locationLabel || 'Unknown';
const summary = {
refreshed: 0,
ended: 0,
};
const [activeNoaaHazards, activeDerivedHazards] = await Promise.all([
rows.some((row) => row.source === 'noaa')
? fetchActiveNoaaHazardsForLocation({ latitude: coordinates.lat, longitude: coordinates.lon })
: Promise.resolve([]),
rows.some((row) => row.source === 'derived')
? fetchActiveDerivedHazardsForLocation({ locationKey, locationLabel })
: Promise.resolve([]),
]);
const activeHazards = [...activeNoaaHazards, ...activeDerivedHazards];
const activeHazardMap = new Map(activeHazards.map((hazard) => [buildHazardIdentity(hazard.hazardType, hazard.source), hazard]));
for (const row of rows) {
const activeHazard = activeHazardMap.get(buildHazardIdentity(row.hazardType, row.source));
if (activeHazard) {
await touchHazardStillOngoing({
id: row.id,
severity: activeHazard.severity,
latestHazardId: activeHazard.latestHazardId,
});
summary.refreshed += 1;
} else {
await markHazardEndedById(row.id);
summary.ended += 1;
}
}
return summary;
};
const runHazardHistoryCheckOnce = async () => {
if (isHazardHistoryWorkerRunning) return;
isHazardHistoryWorkerRunning = true;
const totals = {
locationsChecked: 0,
refreshed: 0,
ended: 0,
failures: 0,
};
try {
const ongoingRows = await getOngoingHazards();
const grouped = [...groupHazardsByLocationKey(ongoingRows).entries()].map(([locationKey, rows]) => ({ locationKey, rows }));
await runWithConcurrency(grouped, HAZARD_HISTORY_CHECK_CONCURRENCY, async (group) => {
try {
const result = await reconcileHazardsForLocation(group);
totals.locationsChecked += 1;
totals.refreshed += result.refreshed;
totals.ended += result.ended;
} catch (error) {
totals.locationsChecked += 1;
totals.failures += 1;
console.warn(`Hazard worker location check failed for ${group.locationKey}: ${error.message}`);
}
});
console.log(`Hazard worker: checked ${totals.locationsChecked} locations, refreshed ${totals.refreshed} hazards, ended ${totals.ended} hazards, ${totals.failures} failure${totals.failures === 1 ? '' : 's'}`);
} finally {
isHazardHistoryWorkerRunning = false;
}
};
const startHazardHistoryWorker = () => {
if (hazardHistoryWorkerHandle) return;
runHazardHistoryCheckOnce().catch((error) => {
console.warn(`Hazard worker initial run failed: ${error.message}`);
});
hazardHistoryWorkerHandle = setInterval(() => {
runHazardHistoryCheckOnce().catch((error) => {
console.warn(`Hazard worker run failed: ${error.message}`);
});
}, HAZARD_HISTORY_CHECK_INTERVAL_MS);
if (typeof hazardHistoryWorkerHandle.unref === 'function') {
hazardHistoryWorkerHandle.unref();
}
};
const stopHazardHistoryWorker = () => {
if (!hazardHistoryWorkerHandle) return;
clearInterval(hazardHistoryWorkerHandle);
hazardHistoryWorkerHandle = null;
};
export {
runHazardHistoryCheckOnce,
startHazardHistoryWorker,
stopHazardHistoryWorker,
};

View file

@ -133,6 +133,54 @@ const getHistory = async () => {
return rows.map(mapRowToHistoryEntry);
};
const getOngoingHazards = async () => {
const [rows] = await getPool().query(
`SELECT
id,
location_label,
location_key,
hazard_type,
source,
severity,
latest_hazard_id
FROM hazard_history
WHERE ongoing = 1
ORDER BY last_seen_at DESC`,
);
return rows.map((row) => ({
id: row.id,
locationLabel: row.location_label,
locationKey: row.location_key,
hazardType: row.hazard_type,
source: row.source,
severity: row.severity,
latestHazardId: row.latest_hazard_id,
}));
};
const touchHazardStillOngoing = async ({ id, severity = null, latestHazardId = null }) => {
await getPool().execute(
`UPDATE hazard_history
SET severity = COALESCE(?, severity),
latest_hazard_id = COALESCE(?, latest_hazard_id),
last_seen_at = UTC_TIMESTAMP(),
ongoing = 1
WHERE id = ?`,
[severity, latestHazardId, id],
);
};
const markHazardEndedById = async (id) => {
await getPool().execute(
`UPDATE hazard_history
SET ongoing = 0,
last_seen_at = UTC_TIMESTAMP()
WHERE id = ?`,
[id],
);
};
const updateHistory = async (payload) => {
const { location, locationKey, hazards = [] } = payload;
const validHazards = hazards.filter((hazard) => hazard?.hazardType && hazard?.source);
@ -242,6 +290,9 @@ const updateHistory = async (payload) => {
export {
formatLocation,
getHistory,
getOngoingHazards,
MAX_HISTORY_ENTRIES,
markHazardEndedById,
touchHazardStillOngoing,
updateHistory,
};

View file

@ -0,0 +1,86 @@
import { aggregateWeatherForecastData } from '../server/scripts/modules/utils/weather.mjs';
const REQUEST_TIMEOUT_MS = 10000;
const USER_AGENT = 'WeatherStar 4000+: Linhanced; marky611@gmail.com';
const OPEN_METEO_FORECAST_PARAMETERS = [
'daily=temperature_2m_max,temperature_2m_min,uv_index_max',
'hourly=temperature_2m,relative_humidity_2m,dew_point_2m,apparent_temperature,precipitation_probability,precipitation,rain,showers,snowfall,snow_depth,weather_code,pressure_msl,surface_pressure,cloud_cover,visibility,uv_index,is_day,sunshine_duration,wind_speed_10m,wind_direction_10m,wind_gusts_10m',
'timezone=auto',
'models=best_match',
].join('&');
const parseLocationKey = (locationKey) => {
if (typeof locationKey !== 'string') return null;
const [latText, lonText] = locationKey.split(',');
const lat = Number.parseFloat(latText);
const lon = Number.parseFloat(lonText);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return { lat, lon };
};
const splitLocationLabel = (locationLabel = '') => {
const [cityPart = 'Unknown', regionPart = ''] = locationLabel.split(',').map((part) => part.trim());
const isUsStyleState = /^[A-Z]{2}$/.test(regionPart);
return {
city: cityPart || 'Unknown',
state: isUsStyleState ? regionPart : '',
country: isUsStyleState ? 'United States' : regionPart,
countryCode: isUsStyleState ? 'US' : '',
};
};
const fetchJson = async (url) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: {
'User-Agent': USER_AGENT,
},
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Fetch error ${response.status} ${response.statusText} while fetching ${url}`);
}
return await response.json();
} finally {
clearTimeout(timeoutId);
}
};
const buildWeatherParametersForLocation = async ({ locationKey, locationLabel }) => {
const coordinates = parseLocationKey(locationKey);
if (!coordinates) {
throw new Error(`Invalid location key '${locationKey}' for derived hazard refresh`);
}
const { lat, lon } = coordinates;
const locationParts = splitLocationLabel(locationLabel);
const forecast = await fetchJson(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&${OPEN_METEO_FORECAST_PARAMETERS}`);
const aggregatedForecast = aggregateWeatherForecastData(forecast);
if (!aggregatedForecast) {
throw new Error(`Unable to aggregate forecast for ${locationKey}`);
}
return {
latitude: lat,
longitude: lon,
city: locationParts.city,
state: locationParts.state,
country: locationParts.country,
countryCode: locationParts.countryCode,
timeZone: forecast?.timezone || 'UTC',
forecast: aggregatedForecast,
supportsNoaaAlerts: false,
primaryForecastSource: 'open-meteo',
};
};
export {
buildWeatherParametersForLocation,
parseLocationKey,
splitLocationLabel,
};