v0.2.1! Adds instant Hazard List updates after hazard sync
Some checks are pending
build-docker / Build Image (push) Waiting to run
Some checks are pending
build-docker / Build Image (push) Waiting to run
This commit is contained in:
parent
0f2d64b908
commit
dbb32fd2f9
9 changed files with 362 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
187
src/hazard-history-worker.mjs
Normal file
187
src/hazard-history-worker.mjs
Normal 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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
86
src/weather-parameters.mjs
Normal file
86
src/weather-parameters.mjs
Normal 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,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue