Merge remote-tracking branch 'upstream/main' into pr871-fix

This commit is contained in:
Said Turksever committed 2026-10-28 15:32:26 +00:00
commit f5e65fbf9e
24 files changed
+561 -113

No files matched your search

@@ -0,0 +1,5 @@
- Keep Directions route errors, the Local ADS-B receiver error and Global
Context cohort reasons English in layer data; the Layers panel, Radio card
and awareness panel still show them in the interface language. The route
proxy's "routing busy" and "route proxy error" refusals are now translated
too (daikaginza, #1209).
+3
View File
@@ -0,0 +1,3 @@
- Fire Perimeters and `get_fire_perimeters` say when WFIGS is unreachable and
the last snapshot is being shown (STALE, its age, "data may be stale"), and
that snapshot is served for at most 24 hours (quanticsoul4772, #1404).
+6 -1
View File
@@ -3865,7 +3865,7 @@ anchors, recovery, keyboard and mobile states; emergency repainting is covered
by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
@@ -6075,6 +6075,11 @@ reception does not interrupt internet radio.
Changes apply to the open receiver without reconnecting.
- **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard,
aircraft with a fresh position and the IQ level.
- **Error text.** A receiver error sets two fields (`errorState()` in
`src/sdr/controller.js`): `message` is the UI language for the Radio card,
`errorText` is the English text the Local ADS-B layer reports as its stats
`error` (`localAdsbStatus()`, falling back to `message` when a receiver sets
none); the Layers panel translates it where it shows it.
- **Devices.** An already-authorized receiver opens without the WebUSB
picker. ADS-B prefers a device whose product name matches ADS-B or 1090 (the
1090 MHz channel of a dual-channel board); FM avoids ADS-B/UAT channels.
+10 -1
View File
@@ -39,6 +39,8 @@ const API_URL =
// The service caps a single response at its maxRecordCount (2000); a peak
// season can exceed that, so follow exceededTransferLimit with offset pages.
const MAX_PAGES = 5;
/** Longest a failed refresh keeps serving the last perimeter snapshot, marked stale. */
const STALE_MAX_MS = 24 * 60 * 60 * 1000;
const exceededTransferLimit = (payload) =>
payload?.exceededTransferLimit === true ||
@@ -165,7 +167,14 @@ export function firePerimetersProxy({
});
return { value: await promise, stale: false };
} catch (error) {
if (previous) return { value: previous.value, stale: true };
// A failed refresh serves the last answer, marked stale. A perimeter
// snapshot only for a day: older than that, it is no picture of
// current fires. (InciWeb entries keep their previous behaviour.)
if (
previous &&
(key !== 'perimeters' || now() - previous.savedAt <= STALE_MAX_MS)
)
return { value: previous.value, stale: true };
throw error;
}
}
+7 -6
View File
@@ -1,6 +1,6 @@
/** Pure proximity helpers for Global Context. No capability modeling. */
import { t } from '../i18n/index.js';
import { msg, t } from '../i18n/index.js';
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
@@ -84,7 +84,8 @@ export function formatAwarenessLabel(value) {
/**
* Summarize sorted nearby results without promoting missing/stale feeds to a
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range
* claim in this MVP.
* claim in this MVP. `reason` is English data; the awareness panel shows it
* in the UI language.
*/
export function summarizeAwarenessCohort(
items,
@@ -95,14 +96,14 @@ export function summarizeAwarenessCohort(
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: t('feed unavailable'),
reason: msg('feed unavailable'),
};
if (stale)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: t('feed stale'),
reason: msg('feed stale'),
};
const normalized = (Array.isArray(items) ? items : [])
.filter(
@@ -119,8 +120,8 @@ export function summarizeAwarenessCohort(
count: normalized.length,
nearest: normalized.slice(0, limit),
reason: normalized.length
? t('observed or mapped nearby context')
: t('no observed or mapped objects in current feeds'),
? msg('observed or mapped nearby context')
: msg('no observed or mapped objects in current feeds'),
};
}
+20
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, translateKnown } from '../i18n/index.js';
import {
AWARENESS_RELATIONSHIP,
findByDoublingRadius,
@@ -47,6 +48,25 @@ test('unavailable and stale feeds remain unknown', () => {
assert.equal(summarizeAwarenessCohort([], { stale: true }).reason, 'feed stale');
});
test('cohort reasons stay English in data and translate where the panel shows them', (t) => {
t.after(() => setLocale('en'));
setLocale('ru');
const reasons = [
summarizeAwarenessCohort([], { available: false }).reason,
summarizeAwarenessCohort([], { stale: true }).reason,
summarizeAwarenessCohort([{ id: 'near', distanceM: 10 }]).reason,
summarizeAwarenessCohort([]).reason,
];
assert.deepEqual(reasons, [
'feed unavailable',
'feed stale',
'observed or mapped nearby context',
'no observed or mapped objects in current feeds',
]);
// The awareness panel shows a reason through translateKnown().
for (const reason of reasons) assert.notEqual(translateKnown(reason), reason);
});
test('navigation prioritizes nearby targets from the selected cohort', () => {
const cohorts = [
{ id: 'flights', summary: { nearest: [{ icao24: 'a1', distanceM: 1000 }, { icao24: 'a2', distanceM: 2000 }] } },
+3 -1
View File
@@ -3001,5 +3001,7 @@
"Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.",
"Voice replies appear here.": "Las respuestas de voz aparecen aquí.",
"Preview is off.": "La vista previa está desactivada.",
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz."
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.",
"routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez",
"route proxy error": "error del proxy de rutas"
}
+3 -1
View File
@@ -3070,5 +3070,7 @@
"Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.",
"Voice replies appear here.": "Здесь появляются голосовые ответы.",
"Preview is off.": "Предпросмотр отключён.",
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения."
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.",
"routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно",
"route proxy error": "ошибка прокси маршрутов"
}
+3 -1
View File
@@ -2863,5 +2863,7 @@
"{count} placemarks": {
"other": "{count} 个地标"
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。"
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。",
"routing busy — too many routes at once": "路线服务繁忙:同时请求的路线过多",
"route proxy error": "路线代理出错"
}
+3 -1
View File
@@ -2863,5 +2863,7 @@
"{count} placemarks": {
"other": "{count} 個地標"
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。"
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。",
"routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多",
"route proxy error": "路線代理出錯"
}
+19 -32
View File
@@ -38,33 +38,21 @@ export const DIRECTIONS_MODES = Object.freeze({
export const DEFAULT_DIRECTIONS_MODE = 'car';
/**
* The route proxy's refusal texts (server/providers/places/routes.js). They
* stay English on the wire and in the AI tools; the row shows them translated.
* The row error for a failed route request (createDirectionsSource errors),
* as English data; the Layers panel shows it translated (ROUTING_ERROR_PATTERNS
* in src/ui/statusText.js).
*/
const ROUTING_REFUSALS = new Set([
msg('rate limited'),
msg('routing service is rate limited'),
msg('route leg too long'),
msg('route too long'),
]);
/** A refusal text from the route proxy in the language; unknown ones as given. */
export function presentRoutingRefusal(text) {
return ROUTING_REFUSALS.has(text) ? t(text) : text;
}
/** The row text for a failed route request (createDirectionsSource errors). */
export function presentRoutingFailure(error) {
if (error?.status === 429) {
return error.refusal
? t('{error} — try again in a moment', {
error: presentRoutingRefusal(error.refusal),
})
: t('Routing is rate limited — try again in a moment');
? // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
`${error.refusal} — try again in a moment`
: msg('Routing is rate limited — try again in a moment');
}
if (Number.isFinite(error?.status) && error.status > 0)
return t('Routing unavailable (HTTP {status})', { status: error.status });
return error?.message || t('Routing unavailable');
// i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
return `Routing unavailable (HTTP ${error.status})`;
return error?.message || msg('Routing unavailable');
}
/** Route colour — the annotation palette's cyan, so voice routes and Directions match. */
@@ -659,7 +647,9 @@ export function directionsStats(state) {
return {
count: route?.steps.length || 0,
lastUpdate,
error: t('Another map tool is using clicks — close it, then SET A again'),
error: msg(
'Another map tool is using clicks — close it, then SET A again',
),
status: 'empty',
source,
};
@@ -678,7 +668,7 @@ export function directionsStats(state) {
return {
count: 0,
lastUpdate,
error: error || t('No route found'),
error: error || msg('No route found'),
status: 'empty',
source,
};
@@ -1160,13 +1150,10 @@ export function createDirectionsLayer({ services, source }) {
clearRouteGraphics();
_status = 'error';
_error =
payload?.error === 'no route found'
? t('No route found between A and B')
: payload?.error
? t('Routing failed: {error}', {
error: presentRoutingRefusal(payload.error),
})
: t('No route found between A and B');
// i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
payload?.error && payload.error !== 'no route found'
? `Routing failed: ${payload.error}`
: msg('No route found between A and B');
return;
}
_route = route;
@@ -1183,9 +1170,9 @@ export function createDirectionsLayer({ services, source }) {
// happened instead.
_error =
error?.name === 'AbortError'
? t('Routing timed out')
? msg('Routing timed out')
: error?.name === 'TypeError'
? t('Routing unavailable — no response from the routing service')
? msg('Routing unavailable — no response from the routing service')
: presentRoutingFailure(error);
} finally {
clearTimeout(timer);
+121
View File
@@ -0,0 +1,121 @@
// Directions reports its fixed errors as English data (getStats(), and so the
// layer snapshot the voice tools and the analyst read); the Layers panel row
// shows them in the UI language.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale, t as translate } from '../../i18n/index.js';
import { LayerPanel } from '../../ui/layerPanel.js';
import { directionsStats, presentRoutingFailure } from './index.js';
const idle = {
enabled: true,
mode: 'car',
armed: null,
a: null,
b: null,
status: 'idle',
error: null,
route: null,
lastUpdate: null,
};
const cases = [
[
{ ...idle, pointerBlocked: true },
'Another map tool is using clicks — close it, then SET A again',
],
[{ ...idle, status: 'error' }, 'No route found'],
[
{ ...idle, status: 'error', error: 'Routing timed out' },
'Routing timed out',
],
];
function rowFor(stats) {
return LayerPanel.prototype._buildMetaText({
id: 'directions',
enabled: true,
stats,
});
}
// Route request failures with the proxy's refusal or an HTTP status in them:
// [the request error, its English row error, the row text in the language]
const failures = () => [
[
{ status: 429, refusal: 'route leg too long' },
'route leg too long — try again in a moment',
translate('{error} — try again in a moment', {
error: translate('route leg too long'),
}),
],
[
{ status: 429, refusal: null },
'Routing is rate limited — try again in a moment',
translate('Routing is rate limited — try again in a moment'),
],
[
{ status: 503 },
'Routing unavailable (HTTP 503)',
translate('Routing unavailable (HTTP {status})', { status: 503 }),
],
[
{ status: 0, message: '' },
'Routing unavailable',
translate('Routing unavailable'),
],
];
for (const locale of ['es', 'ru']) {
test(`${locale}: Directions errors stay English in stats and read translated in the row`, (t) => {
t.after(() => setLocale('en'));
setLocale(locale);
for (const [state, english] of cases) {
const stats = directionsStats(state);
assert.equal(stats.error, english);
const row = rowFor(stats);
assert.ok(row.includes(translate(english)), `${locale}: ${row}`);
assert.ok(!row.includes(english), `${locale}: ${row}`);
}
});
}
for (const locale of ['es', 'ru']) {
test(`${locale}: route request failures stay English in stats and read translated in the row`, (t) => {
t.after(() => setLocale('en'));
setLocale(locale);
for (const [failure, english, shown] of failures()) {
const error = presentRoutingFailure(failure);
assert.equal(error, english);
const stats = directionsStats({ ...idle, status: 'error', error });
assert.equal(stats.error, english);
assert.ok(rowFor(stats).includes(shown), `${locale}: ${rowFor(stats)}`);
assert.notEqual(shown, english);
}
// The proxy's refusal after "Routing failed:", and a text it never sends.
for (const [refusal, shownRefusal] of [
['route too long', translate('route too long')],
['something else', 'something else'],
]) {
const row = rowFor(
directionsStats({
...idle,
status: 'error',
error: `Routing failed: ${refusal}`,
}),
);
assert.ok(
row.includes(
translate('Routing failed: {error}', { error: shownRefusal }),
),
`${locale}: ${row}`,
);
}
});
}
test('en: the Directions row shows the English error', () => {
setLocale('en');
const row = rowFor(directionsStats({ ...idle, status: 'error' }));
assert.match(row, /No route found/);
});
+5 -3
View File
@@ -56,10 +56,12 @@ const DEFAULT_DISPLAY = Object.freeze({
* Local ADS-B layer: draws aircraft heard by the user's own receivers.
*
* Two inputs feed it. The receiver is any object with `getState()` returning
* `{ connected, status, message, mode, webUsbSupported, aircraft, messagesPerSecond }`,
* `{ connected, status, message, errorText, mode, webUsbSupported, aircraft, messagesPerSecond }`,
* `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from
* `src/sources/adsbRecords.js`. The browser WebUSB session is one such
* receiver. The optional `feeds` session (`./feeds.js`) supplies records read
* `src/sources/adsbRecords.js`, and the optional `errorText` is the English
* text of an `error` status (`message`, the UI-language one, stands in for a
* receiver without it). The browser WebUSB session is one such receiver.
* The optional `feeds` session (`./feeds.js`) supplies records read
* by the server from local 1090/978 MHz decoders; it polls only while this
* layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`).
* Aircraft heard only on 978 MHz UAT carry a thin ring around the marker.
+1 -1
View File
@@ -51,7 +51,7 @@ function feedsConfigured(feedState) {
function webUsbStatus(receiver, heard, feedState) {
if (receiver.status === 'error')
return { status: 'error', error: receiver.message };
return { status: 'error', error: receiver.errorText ?? receiver.message };
if (receiver.status === 'connecting' || receiver.status === 'tuning')
return { loading: true, loadingLabel: msg('opening receiver') };
if (
+44 -1
View File
@@ -2,7 +2,8 @@
// "USB error", feed problems, even as the whole error) read in the UI language.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../../i18n/index.js';
import { setLocale, t } from '../../i18n/index.js';
import { SdrController } from '../../sdr/controller.js';
import { LayerPanel } from '../../ui/layerPanel.js';
import { localAdsbStatus } from './status.js';
@@ -66,3 +67,45 @@ for (const locale of ['es', 'ru']) {
});
}
}
// A receiver error is English data for the layer (stats, and what the voice
// tools and the analyst read); the Radio card and the row show it translated.
for (const locale of ['es', 'ru']) {
test(`${locale}: a WebUSB receiver error stays English in stats and reads translated`, async () => {
const receiver = new SdrController({
storage: null,
webUsb: {},
providerFactory: () => ({
get: async () => {
throw new Error('No device was selected.');
},
}),
});
setLocale(locale);
try {
assert.equal(await receiver.connect('adsb'), false);
const state = receiver.getState();
assert.equal(state.status, 'error');
assert.equal(state.errorText, 'No RTL-SDR device selected');
assert.equal(state.message, t('No RTL-SDR device selected'));
assert.notEqual(state.message, state.errorText);
const status = localAdsbStatus({
receiver: state,
feedState: null,
heard: 0,
});
assert.equal(status.error, 'No RTL-SDR device selected');
const row = rowFor(status, 0);
assert.ok(row.includes(state.message), row);
assert.doesNotMatch(row, /No RTL-SDR device selected/, row);
// The English text goes with the error: stop() clears it.
await receiver.stop();
assert.equal(receiver.getState().errorText, null);
} finally {
setLocale('en');
await receiver.destroy();
}
});
}
+22 -3
View File
@@ -55,6 +55,8 @@ export function createFirePerimetersLayer({
let _dataSource = null;
let _count = 0;
let _lastUpdate = null;
// The proxy's last snapshot, served while WFIGS is unreachable.
let _stale = false;
let _lastError = null;
let _enabled = false;
let _clickHandler = null;
@@ -260,6 +262,7 @@ export function createFirePerimetersLayer({
_count = 0;
_lastUpdate = null;
_lastError = null;
_stale = false;
_enabled = false;
console.log('[Data:FirePerimeters] Initialized');
},
@@ -287,9 +290,21 @@ export function createFirePerimetersLayer({
_request = request;
refreshInciwebIndex(request.signal);
try {
const rows = await source.getSnapshot({ signal: request.signal });
// The source contract is getSnapshot; a source that can also say how
// fresh its rows are (the WFIGS proxy) offers getSnapshotDetail.
const snapshot =
typeof source.getSnapshotDetail === 'function'
? await source.getSnapshotDetail({ signal: request.signal })
: { rows: await source.getSnapshot({ signal: request.signal }) };
if (request.signal.aborted || _request !== request || !_enabled)
return false;
const { rows } = snapshot;
_stale = snapshot.stale === true;
// A stale snapshot is as old as its fetch, not this poll.
const updatedAt =
_stale && Number.isFinite(snapshot.fetchedAt)
? snapshot.fetchedAt
: Date.now();
// Incident facts may change independently of the polygon's source clock.
const signature = JSON.stringify(
@@ -299,7 +314,7 @@ export function createFirePerimetersLayer({
);
if (signature === _snapshotSignature) {
if (_selectedId) publishSelectedCard();
_lastUpdate = Date.now();
_lastUpdate = updatedAt;
_lastError = null;
return true;
}
@@ -355,7 +370,7 @@ export function createFirePerimetersLayer({
// Refresh (or drop) the selected incident's card against the new feed.
if (_selectedId) publishSelectedCard();
_count = rows.length;
_lastUpdate = Date.now();
_lastUpdate = updatedAt;
_lastError = null;
console.log(
`[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`,
@@ -366,6 +381,8 @@ export function createFirePerimetersLayer({
return false;
console.warn('[Data:FirePerimeters] Fetch error:', e);
_lastError = e?.message || msg('Perimeter source unavailable');
// A failure now outranks the stale snapshot still drawn.
_stale = false;
return false;
} finally {
if (_request === request) _request = null;
@@ -391,6 +408,7 @@ export function createFirePerimetersLayer({
_count = 0;
_lastUpdate = null;
_lastError = null;
_stale = false;
},
/** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */
@@ -443,6 +461,7 @@ export function createFirePerimetersLayer({
count: _count,
lastUpdate: _lastUpdate,
error: _lastError,
stale: _stale,
};
},
};
+33
View File
@@ -552,3 +552,36 @@ for (const action of ['disable', 'destroy', 'selection']) {
if (action !== 'destroy') h.layer.destroy();
});
}
test('a stale snapshot reads stale, dated by its own fetch, and a fresh one clears it', async () => {
const fetchedAt = Date.now() - 3 * 60 * 60 * 1000;
let snapshot = { rows: [row], stale: true, fetchedAt };
const h = harness({
getSnapshot: async () => snapshot.rows,
getSnapshotDetail: async () => snapshot,
});
await h.layer.update(h.viewer);
assert.equal(h.layer.getStats().stale, true);
assert.equal(h.layer.getStats().lastUpdate, fetchedAt);
snapshot = { rows: [row], stale: false, fetchedAt: Date.now() };
await h.layer.update(h.viewer);
assert.equal(h.layer.getStats().stale, false);
assert.ok(h.layer.getStats().lastUpdate > fetchedAt);
});
test('a failed refresh after a stale snapshot reads as the failure, not stale', async () => {
let fail = false;
const h = harness({
getSnapshot: async () => [row],
getSnapshotDetail: async () => {
if (fail) throw new Error('WFIGS HTTP 502');
return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 };
},
});
await h.layer.update(h.viewer);
assert.equal(h.layer.getStats().stale, true);
fail = true;
await h.layer.update(h.viewer);
assert.equal(h.layer.getStats().stale, false);
assert.equal(h.layer.getStats().error, 'WFIGS HTTP 502');
});
+28 -13
View File
@@ -8,20 +8,35 @@ const msg = (text) => text;
export function createWfigsPerimeterSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
/**
* The rows with how fresh they are: the proxy answers from its last
* snapshot, marked stale, while WFIGS is unreachable.
* @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>}
*/
async function getSnapshotDetail({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/fire-perimeters', { signal });
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
80 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error(msg('Malformed perimeter snapshot'));
return {
rows: payload.rows,
stale: payload.stale === true,
fetchedAt: Number.isFinite(payload.fetchedAt) ? payload.fetchedAt : null,
};
}
return {
async getSnapshot({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/fire-perimeters', { signal });
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
80 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error(msg('Malformed perimeter snapshot'));
return payload.rows;
/** The perimeter rows (see getSnapshotDetail for staleness). */
async getSnapshot(options = {}) {
return (await getSnapshotDetail(options)).rows;
},
getSnapshotDetail,
};
}
+64
View File
@@ -115,3 +115,67 @@ test('response-body completion honors cancellation without replacing records', a
name: 'AbortError',
});
});
test('a failed refresh serves the last snapshot marked stale for a day, then refuses', async () => {
let clock = 1_000_000_000;
let up = true;
const plugin = firePerimetersProxy({
now: () => clock,
fetchImpl: async () => {
if (!up) throw new Error('WFIGS unreachable');
return Response.json(validPayload);
},
});
let handler;
plugin.configureServer({
middlewares: { use: (_path, callback) => (handler = callback) },
});
const ask = async () => {
const reply = {};
await handler(
{ url: '/', method: 'GET', socket: { remoteAddress: '127.0.0.1' } },
{
writeHead(status, headers) {
reply.status = status;
reply.staleHeader = headers?.['X-Data-Stale'] === 'true';
},
end(body) {
reply.body = JSON.parse(body);
},
},
);
return reply;
};
const fresh = await ask();
assert.equal(fresh.status, 200);
assert.equal(fresh.body.stale, undefined);
up = false;
clock += 23 * 60 * 60 * 1000;
const stale = await ask();
assert.equal(stale.status, 200);
assert.equal(stale.staleHeader, true);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt);
clock += 2 * 60 * 60 * 1000;
assert.equal((await ask()).status, 502, 'a day-old snapshot is not served');
});
test('the snapshot detail carries the proxy staleness and fetch time', async () => {
const fetchedAt = Date.UTC(2026, 9, 25, 12);
const source = createWfigsPerimeterSource({
fetchImpl: async () => Response.json({ rows: [], stale: true, fetchedAt }),
});
assert.deepEqual(await source.getSnapshotDetail(), {
rows: [],
stale: true,
fetchedAt,
});
assert.deepEqual(await source.getSnapshot(), []);
});
test('getSnapshot works when taken off its source object', async () => {
const { getSnapshot } = createWfigsPerimeterSource({
fetchImpl: async () => Response.json({ rows: [] }),
});
assert.deepEqual(await getSnapshot(), []);
});
+34 -34
View File
@@ -283,22 +283,33 @@ function clampFmFrequency(value) {
);
}
/** A failed connection in English; errorState() shows it translated. */
function describeConnectionError(error) {
const message = String(error?.message || error || '').trim();
if (/No device was selected|NotFoundError/i.test(message))
return t('No RTL-SDR device selected');
return msg('No RTL-SDR device selected');
if (
/does not support the WebUSB|usb/i.test(message) &&
/support/i.test(message)
) {
return t('WebUSB requires desktop Chrome or Edge on localhost/HTTPS');
return msg('WebUSB requires desktop Chrome or Edge on localhost/HTTPS');
}
if (/access|permission|claim|busy/i.test(message)) {
return t(
return msg(
'RTL-SDR access failed; close other SDR apps and check USB permissions',
);
}
return message ? translateKnown(message) : t('RTL-SDR connection failed');
return message || msg('RTL-SDR connection failed');
}
/**
* State fields for a receiver error: `message` in the UI language for the
* Radio card, `errorText` as English data for the Local ADS-B layer's stats.
* A new connection, a mode switch and stop() clear `errorText` again.
* @param {string} text English error text (ours or the browser's).
*/
function errorState(text) {
return { status: 'error', message: translateKnown(text), errorText: text };
}
function streamingMessage(mode) {
@@ -350,6 +361,7 @@ export class SdrController {
gain: this._gainByMode.fm,
seeking: false,
seekMessage: '',
errorText: null,
snr: null,
aircraft: [],
decodedMessages: 0,
@@ -555,10 +567,7 @@ export class SdrController {
// A failed worker (e.g. its module did not load) never recovers: drop
// it so the next session builds and configures a fresh one.
this._discardWorker();
this._setState({
status: 'error',
message: t('SDR decoder worker failed'),
});
this._setState(errorState(msg('SDR decoder worker failed')));
if (this._device) void this.stop({ preserveMessage: true });
};
return worker;
@@ -779,16 +788,16 @@ export class SdrController {
// Published before stopping; stop() preserves it, and nothing is set
// afterwards, so a reconnect started during teardown keeps its own state.
this._setState({
status: 'error',
...errorState(
reason === 'startup'
? msg('SDR decoder did not start; receiver stopped')
: reason === 'stalled'
? msg('SDR decoder stopped responding; receiver stopped')
: msg('SDR decoder fell behind; receiver stopped'),
),
connected: false,
decoderOverload: reason,
decoderOverloadSnapshot: snapshot,
message:
reason === 'startup'
? t('SDR decoder did not start; receiver stopped')
: reason === 'stalled'
? t('SDR decoder stopped responding; receiver stopped')
: t('SDR decoder fell behind; receiver stopped'),
});
void this.stop({ preserveMessage: true });
}
@@ -1075,6 +1084,7 @@ export class SdrController {
this._setState({
status: 'connecting',
message: t('Waiting for an RTL-SDR device…'),
errorText: null,
});
let acquisition = null;
try {
@@ -1085,7 +1095,7 @@ export class SdrController {
// consumes the click activation.
if (nextMode === 'fm' && !(await this._ensureAudio())) {
throw new Error(
t('Browser audio is suspended; press Connect again to resume it'),
msg('Browser audio is suspended; press Connect again to resume it'),
);
}
if (superseded()) return false;
@@ -1153,8 +1163,7 @@ export class SdrController {
if (superseded()) return false;
this._setState({
connected: false,
status: 'error',
message: describeConnectionError(error),
...errorState(describeConnectionError(error)),
});
return false;
}
@@ -1507,8 +1516,7 @@ export class SdrController {
console.warn('[SDR] Sample read failed:', error);
this._setState({
connected: false,
status: 'error',
message: t('RTL-SDR sample stream stopped'),
...errorState(msg('RTL-SDR sample stream stopped')),
});
await this.stop({ preserveMessage: true });
break;
@@ -1549,11 +1557,12 @@ export class SdrController {
message: t('Switching to {mode}…', {
mode: nextMode === 'adsb' ? 'ADS-B' : 'FM',
}),
errorText: null,
});
try {
if (nextMode === 'fm' && !(await this._ensureAudio())) {
throw new Error(
t('Browser audio is suspended; select FM again to resume it'),
msg('Browser audio is suspended; select FM again to resume it'),
);
}
// A stall teardown, stop() or reconnect during the switch supersedes
@@ -1578,10 +1587,7 @@ export class SdrController {
} catch (error) {
if (!this._owns(token, device)) return false;
console.warn('[SDR] Mode switch failed:', error);
this._setState({
status: 'error',
message: describeConnectionError(error),
});
this._setState(errorState(describeConnectionError(error)));
return false;
}
}
@@ -1622,10 +1628,7 @@ export class SdrController {
if (targetMode === 'adsb') this._setState(this._resetAdsbCounters());
} catch (error) {
console.warn('[SDR] Gain change failed:', error);
this._setState({
status: 'error',
message: t('RTL-SDR gain change failed'),
});
this._setState(errorState(msg('RTL-SDR gain change failed')));
return null;
}
return setting;
@@ -1679,10 +1682,7 @@ export class SdrController {
});
return true;
} catch (error) {
this._setState({
status: 'error',
message: describeConnectionError(error),
});
this._setState(errorState(describeConnectionError(error)));
return false;
}
}
@@ -1873,7 +1873,7 @@ export class SdrController {
status: preserveMessage ? this.state.status : idleStatus,
...(preserveMessage
? {}
: { message: message || t('RTL-SDR disconnected') }),
: { message: message || t('RTL-SDR disconnected'), errorText: null }),
snr: null,
samplesPerSecond: 0,
workerBlocks: 0,
+13 -2
View File
@@ -257,7 +257,14 @@ export const getFirePerimeters = defineTool({
requires: ['perimeters'],
async run(args, { services, signal }) {
const area = await resolveArea(args.area, { services, signal });
const rows = (await services.perimeters.getSnapshot({ signal }))
const snapshot =
typeof services.perimeters.getSnapshotDetail === 'function'
? await services.perimeters.getSnapshotDetail({ signal })
: { rows: await services.perimeters.getSnapshot({ signal }) };
// The proxy answers from its last snapshot, marked stale, while WFIGS is
// unreachable.
const stale = snapshot.stale === true;
const rows = snapshot.rows
.filter((fire) => polygonsTouchArea(fire.polygons, area))
.map((fire) => ({ fire, point: polygonCenter(fire.polygons) }))
.filter(({ point }) => point)
@@ -279,9 +286,13 @@ export const getFirePerimeters = defineTool({
}))
.sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0));
return {
summary: `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}.`,
summary:
`${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}` +
(stale ? ' (data may be stale).' : '.'),
data: {
view: suggestView(services, { area, layers: ['fire-perimeters'] }),
stale,
retrieved_at: isoTime(snapshot.fetchedAt),
...capRows(rows, args.limit),
},
};
+36
View File
@@ -871,3 +871,39 @@ test('installation names wait for the name pack, briefly', async (t) => {
assert.equal(slow.data.names_pending, true);
assert.match(slow.summary, /site names are still loading\)\.$/);
});
test('stale fire perimeters say so, with when they were fetched', async () => {
const fetchedAt = Date.UTC(2026, 9, 22, 6);
const ring = [
[
[-118.5, 35.2],
[-118.4, 35.2],
[-118.4, 35.3],
[-118.5, 35.2],
],
];
const perimeters = {
getSnapshot: async () => [],
getSnapshotDetail: async () => ({
rows: [
{
stableId: 'held',
name: 'Held Fire',
acres: 300,
polygons: [ring],
},
],
stale: true,
fetchedAt,
}),
};
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
area: { bbox: [-120, 34, -117, 36] },
});
assert.equal(
result.summary,
'1 mapped wildfire perimeter in the requested box (data may be stale).',
);
assert.equal(result.data.stale, true);
assert.equal(result.data.retrieved_at, new Date(fetchedAt).toISOString());
});
+35 -1
View File
@@ -112,12 +112,46 @@ const FEED_ERROR_PATTERNS = [
],
];
// Directions route failures (layers/directions/index.js), English data with
// the route proxy's refusal or an HTTP status in them. Ahead of the feed
// patterns, which would take "Routing failed: rate limited" for a source.
// The route proxy's refusal texts (server/providers/places/routes.js) show
// translated; any other text in {error} shows its catalog entry, or as given.
const ROUTING_REFUSALS = new Set([
msg('rate limited'),
msg('routing service is rate limited'),
msg('route leg too long'),
msg('route too long'),
msg('routing busy — too many routes at once'),
msg('route proxy error'),
]);
const routingRefusal = (text) =>
ROUTING_REFUSALS.has(text) ? t(text) : translateKnown(text);
const ROUTING_ERROR_PATTERNS = [
[
/^Routing unavailable \(HTTP (\d+)\)$/,
([, status]) => t('Routing unavailable (HTTP {status})', { status }),
],
[
/^Routing failed: (.+)$/,
([, error]) =>
t('Routing failed: {error}', { error: routingRefusal(error) }),
],
[
/^(.+) — try again in a moment$/,
([, error]) =>
t('{error} — try again in a moment', { error: routingRefusal(error) }),
],
];
const ERROR_PATTERNS = [...ROUTING_ERROR_PATTERNS, ...FEED_ERROR_PATTERNS];
/** One error text in the UI language, or null when nothing matches. */
function feedErrorText(text) {
if (FEED_ERRORS.has(text)) return t(text);
const known = translateKnown(text);
if (known !== text) return known;
for (const [pattern, format] of FEED_ERROR_PATTERNS) {
for (const [pattern, format] of ERROR_PATTERNS) {
const match = pattern.exec(text);
if (match) return format(match);
}
+43 -11
View File
@@ -7,10 +7,8 @@ import { setLocale, t, translateKnown } from '../i18n/index.js';
import { presentStreetLevelPanel } from './streetLevelPresentation.js';
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
import { createWeatherSource } from '../layers/weather/source.js';
import {
presentRoutingFailure,
presentRoutingRefusal,
} from '../layers/directions/index.js';
import { presentRoutingFailure } from '../layers/directions/index.js';
import { translateFeedError } from './statusText.js';
import { AIM_AT_GROUND_MESSAGE } from '../layers/streetLevel/policy.js';
const NO_IMAGERY = 'No street-level imagery within 50 m of that point';
@@ -53,9 +51,13 @@ for (const locale of ['es', 'ru']) {
test(`${locale}: the route proxy's refusals are shown translated`, () => {
setLocale(locale);
try {
const shown = t('Routing failed: {error}', {
error: presentRoutingRefusal('route leg too long'),
});
// Directions stores these in English; the Layers panel shows them
// through translateFeedError.
const shown = translateFeedError('Routing failed: route leg too long');
assert.equal(
shown,
t('Routing failed: {error}', { error: t('route leg too long') }),
);
assert.doesNotMatch(shown, /route leg too long/);
const limited = presentRoutingFailure(
Object.assign(new Error('rate limited — try again in a moment'), {
@@ -63,16 +65,46 @@ for (const locale of ['es', 'ru']) {
refusal: 'rate limited',
}),
);
assert.doesNotMatch(limited, /rate limited|try again in a moment/);
assert.equal(limited, 'rate limited — try again in a moment');
assert.doesNotMatch(
translateFeedError(limited),
/rate limited|try again in a moment/,
);
const http = presentRoutingFailure(
Object.assign(new Error('Routing unavailable (HTTP 503)'), {
status: 503,
}),
);
assert.notEqual(http, 'Routing unavailable (HTTP 503)');
assert.match(http, /503/);
assert.equal(http, 'Routing unavailable (HTTP 503)');
assert.notEqual(translateFeedError(http), http);
assert.match(translateFeedError(http), /503/);
// A text the proxy never sends is shown as given.
assert.equal(presentRoutingRefusal('something else'), 'something else');
assert.equal(
translateFeedError('Routing failed: something else'),
t('Routing failed: {error}', { error: 'something else' }),
);
// Every refusal the proxy sends (server/providers/places/routes.js),
// and "rate limited" after "Routing failed:", which the feed pattern
// "<source> rate limited" would otherwise take.
for (const [english, shown] of [
[
'routing busy — too many routes at once — try again in a moment',
t('{error} — try again in a moment', {
error: t('routing busy — too many routes at once'),
}),
],
[
'Routing failed: route proxy error',
t('Routing failed: {error}', { error: t('route proxy error') }),
],
[
'Routing failed: rate limited',
t('Routing failed: {error}', { error: t('rate limited') }),
],
]) {
assert.equal(translateFeedError(english), shown);
assert.doesNotMatch(shown, /routing busy|route proxy|rate limited/);
}
} finally {
setLocale('en');
}