Merge branch 'main' into upstream/wind-stale

This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-10-22 19:18:02 -07:00
commit ba2dda5beb
54 files changed
+3990 -169

No files matched your search

+10
View File
@@ -177,6 +177,16 @@ Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in,
When you widen the publish, also set `GEV_KEY_SETUP_TRUSTED_PEERS=none` so the launcher trusts no gateway address. When you widen the publish, also set `GEV_KEY_SETUP_TRUSTED_PEERS=none` so the launcher trusts no gateway address.
```bash
GEV_KEY_SETUP_TRUSTED_PEERS=none docker compose up
```
Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from your
shell or the project's `.env`; an exported shell value takes precedence. You can
also put `GEV_KEY_SETUP_TRUSTED_PEERS=none` in `.env` for a persistent setting.
Unset or blank trusted peers retain automatic gateway discovery; `none` explicitly
disables it. The startup log confirms that no additional peers are trusted.
<details> <details>
<summary>Running on Linux, a NAS, or behind a reverse proxy</summary> <summary>Running on Linux, a NAS, or behind a reverse proxy</summary>
+5
View File
@@ -100,6 +100,11 @@ The dev server is a **key broker**: every server-side key above is spendable by
`compose.yaml` publishes on `127.0.0.1`: **widen that mapping and every LAN `compose.yaml` publishes on `127.0.0.1`: **widen that mapping and every LAN
client arrives from the same gateway address, which hands them Provider client arrives from the same gateway address, which hands them Provider
Settings too.** Treat publishing beyond loopback as the LAN opt-in above. Settings too.** Treat publishing beyond loopback as the LAN opt-in above.
Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from
the shell or project `.env` before the launcher runs. Set trusted peers to
`none` when widening the publish: it becomes an explicit empty list before
Vite loads configuration, so the gateway cannot regain Provider Settings
access. Unset or blank values preserve automatic gateway discovery.
- **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider. - **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider.
- **Pinokio LAN and Cloudflare sharing are refused.** The current supported - **Pinokio LAN and Cloudflare sharing are refused.** The current supported
Pinokio release re-reads sharing state when an app registers its Open URL and Pinokio release re-reads sharing state when an app registers its Open URL and
+3
View File
@@ -0,0 +1,3 @@
- Honor Docker Compose trusted-peer and allowed-host settings from the shell
or project `.env`, and let `GEV_KEY_SETUP_TRUSTED_PEERS=none` disable gateway
access to Provider Settings before Vite starts (asturksever, #1265).
+1
View File
@@ -0,0 +1 @@
- Coalesce refreshes and cool down opensky failures (asturksever, #1269).
+1
View File
@@ -0,0 +1 @@
- Reject inherited style keys during restore (asturksever, #1271).
+3
View File
@@ -0,0 +1,3 @@
- Preserve enable during first-load replacement (asturksever, #1275).
- Release disabled weather camera listeners and make teardown resource leaks fail the browser probe (asturksever, #1275).
- Keep visible-page weather teardown probes focused through the browser testing API (asturksever, #1275).
+1
View File
@@ -0,0 +1 @@
- Bound each height request to one upstream chunk (asturksever, #1276).
+4
View File
@@ -0,0 +1,4 @@
- Add Simplified Chinese (zh-CN) to the language picker, with localized dates
and readable Chinese labels. Based on translations by @abiyeenzo (#914)
and @EDIT-Aeterna (#979).
(German, @gekh, #1298).
+5
View File
@@ -5,6 +5,11 @@ services:
# lands in the bind-mounted repo, so run as you, not root. UID/GID are # lands in the bind-mounted repo, so run as you, not root. UID/GID are
# unset on Docker Desktop, where the 1000 fallback is ignored anyway. # unset on Docker Desktop, where the 1000 fallback is ignored anyway.
user: "${UID:-1000}:${GID:-1000}" user: "${UID:-1000}:${GID:-1000}"
# Forward explicit shell/.env policy before the launcher discovers a gateway.
# Unset values stay unset so the default loopback-only install still works.
environment:
- GEV_KEY_SETUP_TRUSTED_PEERS
- GEV_ALLOWED_HOSTS
ports: ports:
# Publish to loopback only. Docker's default ("4173:4173") would bind # Publish to loopback only. Docker's default ("4173:4173") would bind
# every host interface, which quietly turns the LAN warning in the README # every host interface, which quietly turns the LAN warning in the README
+9 -2
View File
@@ -9,6 +9,11 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled
lookups preserve the previous analyst cohort and boundary source. Navigation lookups preserve the previous analyst cohort and boundary source. Navigation
search keeps a successful geocode, with no search or view-based region recovery. search keeps a successful geocode, with no search or view-based region recovery.
Terrain height requests accept at most 64 points, matching the browser service's
existing batch size and one bounded upstream chunk. Larger requests return HTTP
413 before disk-cache loading or upstream work; malformed coordinates remain 400.
The browser continues to split larger workloads into sequential 64-point requests.
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
canonical; `greatCircleMeters` is its exact metre conversion. Missing, canonical; `greatCircleMeters` is its exact metre conversion. Missing,
@@ -270,6 +275,7 @@ catalog construction owns each instance.
Wind animates one forecast without advancing forecast time. Separate Weather Wind animates one forecast without advancing forecast time. Separate Weather
observation layers provide radar and satellite history; none claims measured cloud volume. observation layers provide radar and satellite history; none claims measured cloud volume.
Weather imagery releases its camera move listener on disable and registers it once on re-enable. Disabling also clears active imagery work.
Weather imagery drapes onto the globe basemap. On photorealistic 3D Tiles each Weather imagery drapes onto the globe basemap. On photorealistic 3D Tiles each
observed product is instead a raised, translucent shell: one rectangle over the observed product is instead a raised, translucent shell: one rectangle over the
product bounds at a fixed height (global infrared 5.5 km, regional infrared product bounds at a fixed height (global infrared 5.5 km, regional infrared
@@ -3538,7 +3544,7 @@ test:track` 43 tracking invariants · headless QA harnesses under
### Interface localization (October 2026) ### Interface localization (October 2026)
Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives in Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The code lives in
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow: `src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
[`docs/TRANSLATORS.md`](./TRANSLATORS.md). [`docs/TRANSLATORS.md`](./TRANSLATORS.md).
@@ -3549,7 +3555,8 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives
value). English has no catalog; every other locale is value). English has no catalog; every other locale is
`src/i18n/locales/<code>.json`, and a missing entry falls back to English. `src/i18n/locales/<code>.json`, and a missing entry falls back to English.
- **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → - **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) →
`navigator.languages` → English. `?lang=` is not stored, and share links drop `navigator.languages` → English. Chinese tags map to `zh-CN`; tags containing
`Hant`, `TW`, `HK` or `MO` are skipped. `?lang=` is not stored, and share links drop
it, so a recipient keeps their own language. The globe picker in the it, so a recipient keeps their own language. The globe picker in the
bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops
`?lang=` (keeps it only when storage is blocked) and reloads. It asks first `?lang=` (keeps it only when storage is blocked) and reloads. It asks first
+2 -2
View File
@@ -1,6 +1,6 @@
# Translating God's Eye View # Translating God's Eye View
Shipped languages: **English** (source and fallback), **Spanish** (`es`) and **Russian** (`ru`). Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`) and **Simplified Chinese** (`zh-CN`).
## How it works ## How it works
@@ -63,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
## Language notes ## Language notes
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md). Read it before translating, and add a term there when you settle on one. Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md), [Simplified Chinese](./translations/zh-CN.md). Read it before translating, and add a term there when you settle on one.
+77
View File
@@ -0,0 +1,77 @@
# Simplified Chinese (`zh-CN`)
Catalog: `src/i18n/locales/zh-CN.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation sources: abiyeenzo (#914) and EDIT-Aeterna (#979).
## Style
- Mainland usage and Simplified characters. Address the user neutrally, without 您/你 where a plain imperative works: 「点击地图」, 「重试」.
- Chinese has no case: an UPPERCASE English HUD label becomes the same short Chinese label. Keep labels as short as the English: 「实时」, 「过期」, 「关」.
- Full-width punctuation in Chinese text: ,。:;?!()“”. A dash is 「——」 without spaces; keep `·` as the HUD separator and `…` for ellipsis.
- One space between Chinese and Latin letters or digits: 「AIS 船舶」, 「24 小时」, 「{count} 个」. No space next to full-width punctuation.
- Units: km, m, km/h, m/s, kn, ft, MW, °, % stay as written; time words are 小时, 分钟, 秒 after a number (「5 分钟前」).
- Product names, provider names and codes stay as they are: Google, Cesium, CelesTrak, NOAA, FIRMS, AIS, ADS-B, GNSS, CCTV, HUD, CRT, NVG, FLIR, WHOT/BHOT, Ironbow, CONUS.
## Plurals
`Intl.PluralRules('zh-CN')` has one category, `other`: a `tn()` entry has only that form. Use a measure word next to the number where Chinese needs one: 「{count} 个密钥」, 「{count} 架飞机」, 「{count} 艘船」.
## Glossary
| English | 中文 |
| ------------------------------------- | --------------------------- |
| layer / data layer | 图层 / 数据图层 |
| feed | 数据源 |
| source / data source | 来源 / 数据源 |
| LIVE (status) / live data | 实时 / 实时数据 |
| SIMULATED | 模拟 |
| stale / degraded / unavailable | 过期 / 降级 / 不可用 |
| retry | 重试 |
| key (API key) / POWER UP | 密钥(API 密钥)/ 增强 |
| Provider Settings | 服务商设置 |
| globe / map / map source / basemap | 地球 / 地图 / 地图源 / 底图 |
| aircraft / flight / military flights | 飞机 / 航班 / 军用航班 |
| vessel, ship | 船舶 |
| satellite | 卫星 |
| launch (rocket) / space mission | 发射 / 太空任务 |
| contact (tracked object) | 目标 |
| installation, site | 设施 |
| camera / CCTV camera | 摄像头 / CCTV 摄像头 |
| earthquake / active fire | 地震 / 活跃火点 |
| scene / shot | 场景 / 镜头 |
| briefing / panel / dock | 简报 / 面板 / 停靠栏 |
| preset / visual preset | 预设 / 视觉预设 |
| Display / HUD layout | 显示 / HUD 布局 |
| Tactical / Operator / Minimal / Cyber | 战术 / 操作员 / 精简 / 赛博 |
| Scope (circular viewport mask) / keyhole | 视域 |
| Clean UI | 纯净界面 |
| cockpit | 座舱 |
| heading / altitude / ground speed | 航向 / 高度 / 地速 |
| map heading / North up | 方位 / 正北朝上 |
| Street Level / Street View (Google) | 街景 / 街景(Google) |
| Recent Imagery | 近期影像 |
| Global Context / Context (panel) | 全局态势 / 态势 |
| radio (station) | 电台 |
| transit / bikeshare | 公共交通 / 共享单车 |
| Previous / Next | 上一个 / 下一个 |
| compass points (N, NNE, …) | 北, 北东北, … |
| weather field / FIELD (selector) | 气象场 / 要素 |
| weather model / forecast lead hours | 模式 / 预报时效 |
| valid (forecast time) | 有效时间 |
| scene cloud / mosaic | 整景云量 / 镶嵌图 |
| pin (Recent Imagery) | 固定 |
| THIS (voice pointer) | 所指 |
| outline / trace an outline | 轮廓 / 绘制轮廓 |
| mark (map annotation) | 标注 |
| camera badge | 摄像头标记 |
| provider / embedded provider controls | 服务商 / 平台控件 |
| share (a view or link) | 分享 |
| AI agent | AI 智能体 |
| stage (rocket) / downrange | 子级 / 航程 |
| capture shot / camera move | 保存镜头 / 运镜 |
| story beat / evidence beat | 事件节点 / 证据节点 |
| camera pose | 位姿 |
| forecast uncertainty cone | 预报不确定范围 |
| seed (catalog data) | 预置 |
| frame (bring into view) | 将…纳入视野 |
+8 -4
View File
@@ -48,7 +48,8 @@ export function defaultGatewayFromRouteTable(text) {
/** /**
* Decide this launch's trusted-peer list. An operator value always wins, so a * Decide this launch's trusted-peer list. An operator value always wins, so a
* compose override stays authoritative; otherwise the gateway alone is * compose override stays authoritative; `none` trusts no additional peers.
* An unset or blank value means the gateway alone is
* trusted. No gateway trusts nothing and Provider Settings stays loopback-only, * trusted. No gateway trusts nothing and Provider Settings stays loopback-only,
* exactly as under `npm run dev`. * exactly as under `npm run dev`.
* @param {{env?: NodeJS.ProcessEnv, routeTable?: string}} input * @param {{env?: NodeJS.ProcessEnv, routeTable?: string}} input
@@ -56,6 +57,7 @@ export function defaultGatewayFromRouteTable(text) {
*/ */
export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) { export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) {
const explicit = String(env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(); const explicit = String(env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim();
if (explicit.toLowerCase() === 'none') return '';
if (explicit) return explicit; if (explicit) return explicit;
return defaultGatewayFromRouteTable(routeTable) ?? ''; return defaultGatewayFromRouteTable(routeTable) ?? '';
} }
@@ -109,15 +111,17 @@ async function start() {
String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(), String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(),
); );
const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() }); const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() });
if (trustedPeers) { // Set even an empty list before Vite loads .env: the resolved launcher policy
// Set before Vite is imported: the dev server snapshots process.env during // must remain authoritative when there is no trusted gateway.
// configuration, and the admission gate reads it per request.
process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers; process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers;
if (trustedPeers) {
console.log( console.log(
fromOperator fromOperator
? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).` ? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).`
: `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`, : `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`,
); );
} else if (fromOperator) {
console.log('[Docker] Provider Settings trusts no additional peers (GEV_KEY_SETUP_TRUSTED_PEERS=none).');
} else { } else {
console.log('[Docker] No default gateway; Provider Settings stays loopback-only.'); console.log('[Docker] No default gateway; Provider Settings stays loopback-only.');
} }
+2 -1
View File
@@ -1087,5 +1087,6 @@
"src/ui/styles/status-badge.css", "src/ui/styles/status-badge.css",
"src/ui/statusBadge.test.mjs", "src/ui/statusBadge.test.mjs",
"src/ui/cctvBadges.test.mjs", "src/ui/cctvBadges.test.mjs",
"scripts/qa-status-badges.mjs" "scripts/qa-status-badges.mjs",
"src/dockerTrustedPeers.test.mjs"
] ]
+65 -22
View File
@@ -437,7 +437,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": [] "external": []
}, },
@@ -462,7 +463,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -478,7 +480,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": [] "external": []
}, },
@@ -507,6 +510,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/ui/keyGuidance.js", "src/ui/keyGuidance.js",
"src/ui/statusText.js", "src/ui/statusText.js",
@@ -526,7 +530,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": [] "external": []
}, },
@@ -541,7 +546,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": [] "external": []
}, },
@@ -560,6 +566,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/ui/statusBadge.js" "src/ui/statusBadge.js"
], ],
@@ -585,6 +592,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/layerNames.js" "src/ui/layerNames.js"
], ],
"external": [] "external": []
@@ -618,6 +626,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -633,6 +642,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": [] "external": []
@@ -785,6 +795,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/ui/keyGuidance.js", "src/ui/keyGuidance.js",
"src/ui/layerNames.js", "src/ui/layerNames.js",
@@ -904,6 +915,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -969,6 +981,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -1014,7 +1027,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1074,7 +1088,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1103,6 +1118,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/layers/recentImagery/display.js" "src/layers/recentImagery/display.js"
], ],
@@ -1131,7 +1147,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1162,6 +1179,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium", "satellite.js"] "external": ["cesium", "satellite.js"]
@@ -1193,7 +1211,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1235,6 +1254,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/layers/installations/classLabel.js" "src/layers/installations/classLabel.js"
], ],
"external": ["cesium", "@mapbox/vector-tile", "pbf"] "external": ["cesium", "@mapbox/vector-tile", "pbf"]
@@ -1269,6 +1289,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -1317,7 +1338,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": ["cesium", "pbf", "@mapbox/vector-tile"] "external": ["cesium", "pbf", "@mapbox/vector-tile"]
}, },
@@ -1345,7 +1367,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1392,7 +1415,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": ["hls.js", "cesium"] "external": ["hls.js", "cesium"]
}, },
@@ -1453,6 +1477,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/layers/streetLevel/providers/mapillary/policy.js" "src/layers/streetLevel/providers/mapillary/policy.js"
], ],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
@@ -1480,7 +1505,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": ["cesium", "@mapbox/vector-tile", "pbf"] "external": ["cesium", "@mapbox/vector-tile", "pbf"]
}, },
@@ -1519,7 +1545,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1540,7 +1567,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1595,7 +1623,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1651,6 +1680,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/voice/providerCommands.js", "src/voice/providerCommands.js",
"src/voice/providerCapabilities.js", "src/voice/providerCapabilities.js",
@@ -2512,6 +2542,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/layers/recentImagery/display.js", "src/layers/recentImagery/display.js",
"src/ui/keyGuidance.js", "src/ui/keyGuidance.js",
@@ -3087,6 +3118,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/layers/recentImagery/display.js", "src/layers/recentImagery/display.js",
"src/layers/installations/classLabel.js", "src/layers/installations/classLabel.js",
@@ -3193,6 +3225,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/index.js" "src/i18n/index.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -3213,7 +3246,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": [] "external": []
}, },
@@ -3247,7 +3281,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -3270,7 +3305,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": [] "external": []
}, },
@@ -3361,6 +3397,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -3415,6 +3452,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/voice/providerCapabilities.js" "src/voice/providerCapabilities.js"
], ],
@@ -3667,7 +3705,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": ["cesium", "egm96-universal", "pbf"] "external": ["cesium", "egm96-universal", "pbf"]
}, },
@@ -3709,7 +3748,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": ["cesium", "pbf"] "external": ["cesium", "pbf"]
}, },
@@ -3761,7 +3801,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -3777,6 +3818,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/ui/layerNames.js" "src/ui/layerNames.js"
], ],
@@ -3969,6 +4011,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/ui/layerNames.js" "src/ui/layerNames.js"
], ],
+28 -4
View File
@@ -61,6 +61,10 @@ const browser = await puppeteer.launch({
const boot = async () => { const boot = async () => {
const page = await browser.newPage(); const page = await browser.newPage();
// This probe measures active rendering; an OS-minimized window must not
// silently exercise the application's intentional hidden-page pause.
const focus = await page.createCDPSession();
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 }); await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 });
const errors = []; const errors = [];
page.on('pageerror', (e) => page.on('pageerror', (e) =>
@@ -141,7 +145,7 @@ const snapshot = (page) =>
), ),
}, },
weather, weather,
windCells: wind.gpu?.pathCount ?? wind.paths ?? null, windCells: wind.gpu?.pathCount ?? wind.paths ?? 0,
enabled: [...dm.layers].filter(([, e]) => e.enabled).map(([id]) => id), enabled: [...dm.layers].filter(([, e]) => e.enabled).map(([id]) => id),
}; };
}); });
@@ -220,7 +224,7 @@ try {
const baseline = await snapshot(a.page); const baseline = await snapshot(a.page);
console.log('baseline', JSON.stringify(baseline)); console.log('baseline', JSON.stringify(baseline));
for (const id of WEATHER) await setLayer(a.page, id, true); for (const id of WEATHER) await setLayer(a.page, id, true);
await a.page const weatherReady = await a.page
.waitForFunction( .waitForFunction(
() => () =>
['weather-radar', 'weather-satellite', 'weather-lightning'].every( ['weather-radar', 'weather-satellite', 'weather-lightning'].every(
@@ -233,7 +237,13 @@ try {
), ),
{ timeout: 120_000 }, { timeout: 120_000 },
) )
.catch(() => console.log(' !! weather not ready')); .then(
() => true,
() => false,
);
results.weatherReady = weatherReady;
if (!weatherReady)
console.log(' !! weather not ready — feature coverage incomplete');
await sleep(2000); await sleep(2000);
// exercise: history playback, wind reading, storm selection, product switch // exercise: history playback, wind reading, storm selection, product switch
await a.page.evaluate(() => { await a.page.evaluate(() => {
@@ -331,11 +341,25 @@ try {
'errors (control run):', 'errors (control run):',
results.errorsB.length ? results.errorsB : 'none', results.errorsB.length ? results.errorsB : 'none',
); );
// Resource ownership is an acceptance check; provider availability remains
// separately reported diagnostic evidence in both runs.
if (
!weatherReady ||
Object.keys(leak).length ||
[...a.errors, ...b.errors].some((e) => e.startsWith('pageerror:'))
) {
process.exitCode = 1;
}
} catch (error) {
results.failure = { name: error.name, message: error.message };
throw error;
} finally {
// Preserve completed resource evidence even if a later provider or browser
// phase aborts. A partial run still exits nonzero.
writeFileSync( writeFileSync(
path.join(outDir, 'results.json'), path.join(outDir, 'results.json'),
JSON.stringify(results, null, 2), JSON.stringify(results, null, 2),
); );
console.log(`wrote ${path.join(outDir, 'results.json')}`); console.log(`wrote ${path.join(outDir, 'results.json')}`);
} finally {
await browser.close(); await browser.close();
} }
+24 -7
View File
@@ -1,4 +1,7 @@
import { readResponseTextCapped } from '../common/http.js'; import {
coalesceProxyRequest,
readResponseTextCapped,
} from '../common/http.js';
/** /**
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache. * Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
@@ -19,6 +22,7 @@ import { readResponseTextCapped } from '../common/http.js';
export function adsbLolProxy() { export function adsbLolProxy() {
/** @type {string|null} Cached upstream JSON body. */ /** @type {string|null} Cached upstream JSON body. */
let _cache = null; let _cache = null;
const inFlight = new Map();
/** @type {number} Epoch-ms when the cache was populated. */ /** @type {number} Epoch-ms when the cache was populated. */
let _cacheAt = 0; let _cacheAt = 0;
/** @type {number} Epoch-ms until which upstream is not contacted. */ /** @type {number} Epoch-ms until which upstream is not contacted. */
@@ -104,6 +108,13 @@ export function adsbLolProxy() {
); );
return; return;
} }
const refresh = coalesceProxyRequest(inFlight, 'global', async () => {
const result = (status, body, cacheStatus, extra = {}) => ({
status,
body,
cacheStatus,
extra,
});
const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS); const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS);
const upstream = await fetch('https://api.adsb.lol/v2/mil', { const upstream = await fetch('https://api.adsb.lol/v2/mil', {
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' }, headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' },
@@ -119,8 +130,7 @@ export function adsbLolProxy() {
_cacheAt = Date.now(); _cacheAt = Date.now();
_cooldownUntil = 0; _cooldownUntil = 0;
_cooldownStatus = 0; _cooldownStatus = 0;
serve(res, 200, body, 'MISS'); return result(200, body, 'MISS');
return;
} }
if (upstream.status === 429 || upstream.status >= 500) { if (upstream.status === 429 || upstream.status >= 500) {
const failedAt = Date.now(); const failedAt = Date.now();
@@ -132,13 +142,12 @@ export function adsbLolProxy() {
if (_cache) { if (_cache) {
// Do not wait for a failing response body before serving usable data. // Do not wait for a failing response body before serving usable data.
upstream.body?.cancel().catch(() => {}); upstream.body?.cancel().catch(() => {});
serve(res, 200, _cache, 'STALE', { return result(200, _cache, 'STALE', {
'X-ADS-B-Upstream-Status': String(upstream.status), 'X-ADS-B-Upstream-Status': String(upstream.status),
'X-ADS-B-Retry-After-Seconds': String( 'X-ADS-B-Retry-After-Seconds': String(
Math.ceil((_cooldownUntil - Date.now()) / 1000), Math.ceil((_cooldownUntil - Date.now()) / 1000),
), ),
}); });
return;
} }
} }
const body = await readResponseTextCapped( const body = await readResponseTextCapped(
@@ -146,8 +155,7 @@ export function adsbLolProxy() {
ADSBLOL_MIL_MAX_RESPONSE_BYTES, ADSBLOL_MIL_MAX_RESPONSE_BYTES,
signal, signal,
); );
serve( return result(
res,
upstream.status, upstream.status,
body, body,
'MISS', 'MISS',
@@ -159,6 +167,15 @@ export function adsbLolProxy() {
} }
: {}, : {},
); );
});
const response = await refresh.promise;
serve(
res,
response.status,
response.body,
response.cacheStatus,
response.extra,
);
} catch (e) { } catch (e) {
console.error('[adsb.lol Proxy]', e.message); console.error('[adsb.lol Proxy]', e.message);
if (_cache) { if (_cache) {
+87 -57
View File
@@ -67,15 +67,24 @@ async function fetchOpenSkyStates(headers) {
try { try {
const signal = AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS); const signal = AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS);
const upstream = await fetch(OPENSKY_STATES_URL, { headers, signal }); const upstream = await fetch(OPENSKY_STATES_URL, { headers, signal });
if (upstream.status === 429) { if (upstream.status === 429 || upstream.status >= 500) {
// Start the cooldown from the headers and drop the rejected body // Start the cooldown from the headers and drop the rejected body
// unread, so a stalled or oversized 429 body cannot skip it. // unread, so a stalled or oversized 429 body cannot skip it.
_openskyCooldownUntil = _openskyCooldownUntil =
Date.now() + openSkyRateLimitCooldownMs(upstream); Date.now() +
(upstream.status === 429
? openSkyRateLimitCooldownMs(upstream)
: 15000);
_openskyCooldownStatus = upstream.status;
void upstream.body?.cancel().catch(() => {}); void upstream.body?.cancel().catch(() => {});
return { return {
upstream, upstream,
body: JSON.stringify({ error: 'OpenSky rate limited.' }), body: JSON.stringify({
error:
upstream.status === 429
? 'OpenSky rate limited.'
: 'OpenSky upstream unavailable.',
}),
}; };
} }
const body = await readResponseTextCapped( const body = await readResponseTextCapped(
@@ -109,6 +118,8 @@ async function fetchOpenSkyStates(headers) {
let _openskyTtlMs = OPENSKY_CACHE_MS; let _openskyTtlMs = OPENSKY_CACHE_MS;
/** @type {number} Epoch-ms before which no upstream fetch is attempted. */ /** @type {number} Epoch-ms before which no upstream fetch is attempted. */
let _openskyCooldownUntil = 0; let _openskyCooldownUntil = 0;
let _openskyCooldownStatus = 429;
const _openskySnapshotInFlight = new Map();
/** /**
* Picks the cache TTL from the remaining daily credit budget. * Picks the cache TTL from the remaining daily credit budget.
* Client polls every 30 s, so tiers ≤30 s cost the same 480 credits/h; the * Client polls every 30 s, so tiers ≤30 s cost the same 480 credits/h; the
@@ -460,7 +471,9 @@ export function openSkyProxy() {
requestedMode: cachedMeta.requestedMode || requestedMode, requestedMode: cachedMeta.requestedMode || requestedMode,
usedMode: cachedMeta.usedMode || 'unknown', usedMode: cachedMeta.usedMode || 'unknown',
reason: isStale reason: isStale
? _openskyCooldownStatus === 429
? 'rate_limited_serving_stale' ? 'rate_limited_serving_stale'
: `upstream_http_${_openskyCooldownStatus}_serving_stale`
: cachedMeta.reason || 'cached', : cachedMeta.reason || 'cached',
staleSeconds: isStale staleSeconds: isStale
? (now - _openskyCacheTime) / 1000 ? (now - _openskyCacheTime) / 1000
@@ -487,23 +500,30 @@ export function openSkyProxy() {
) )
return; return;
res.writeHead( res.writeHead(
429, _openskyCooldownStatus,
buildOpenSkyHeaders({ buildOpenSkyHeaders({
cacheStatus: 'COOLDOWN', cacheStatus: 'COOLDOWN',
requestedMode, requestedMode,
usedMode: 'none', usedMode: 'none',
reason: 'rate_limited', reason:
_openskyCooldownStatus === 429
? 'rate_limited'
: `upstream_http_${_openskyCooldownStatus}`,
retryAfterSeconds: (_openskyCooldownUntil - now) / 1000, retryAfterSeconds: (_openskyCooldownUntil - now) / 1000,
}), }),
); );
res.end( res.end(
JSON.stringify({ JSON.stringify({
error: 'OpenSky rate limited; proxy cooling down.', error: 'OpenSky upstream unavailable; proxy cooling down.',
}), }),
); );
return; return;
} }
const refresh = coalesceProxyRequest(
_openskySnapshotInFlight,
'global',
async () => {
const basicUser = process.env.OPENSKY_USERNAME || ''; const basicUser = process.env.OPENSKY_USERNAME || '';
const basicPass = process.env.OPENSKY_PASSWORD || ''; const basicPass = process.env.OPENSKY_PASSWORD || '';
const hasBasicCreds = Boolean(basicUser && basicPass); const hasBasicCreds = Boolean(basicUser && basicPass);
@@ -560,7 +580,60 @@ export function openSkyProxy() {
reason = 'oauth_rejected_fallback_basic'; reason = 'oauth_rejected_fallback_basic';
} }
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null; const sourceEpochMs = upstream.ok
? openSkySourceEpochMs(body)
: null;
// Refine the reason string to reflect the actual outcome
if (upstream.ok && reason === 'forced_anonymous') {
reason = 'anonymous_ok';
} else if (
upstream.ok &&
usedMode === 'basic' &&
reason === 'basic_credentials'
) {
reason = 'basic_ok';
} else if (
upstream.ok &&
usedMode === 'oauth' &&
reason === 'oauth_token'
) {
reason = 'oauth_ok';
}
// Only cache successful responses — error responses (401/403/429/5xx)
// should not be served from cache on subsequent requests
if (upstream.ok) {
_openskyCacheBody = body;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
_openskyCacheMeta = {
requestedMode,
usedMode,
reason,
};
// Credit governor: adapt the cache TTL to the remaining daily
// budget so a continuously-open app stretches its polls instead of
// exhausting the quota mid-day. Success also clears any cooldown.
const remaining = Number(
upstream.headers.get('x-rate-limit-remaining'),
);
_openskyTtlMs = openskyAdaptiveTtlMs(remaining);
_openskyCooldownUntil = 0;
}
return {
upstream,
body,
usedMode,
reason,
hasBasicCreds,
sourceEpochMs,
};
},
);
let { upstream, body, usedMode, reason, hasBasicCreds, sourceEpochMs } =
await refresh.promise;
if ( if (
upstream.ok && upstream.ok &&
openSkySourceIsStale(sourceEpochMs, now) && openSkySourceIsStale(sourceEpochMs, now) &&
@@ -571,20 +644,16 @@ export function openSkyProxy() {
'opensky_snapshot_stale_regional_fallback', 'opensky_snapshot_stale_regional_fallback',
)) ))
) { ) {
// Keep the last global snapshot available as a fail-soft cache,
// but do not label or render it as a fresh live result.
_openskyCacheBody = body;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
_openskyCacheMeta = { requestedMode, usedMode, reason };
return; return;
} }
if (upstream.status === 429) { if (upstream.status === 429 || upstream.status >= 500) {
reason = 'rate_limited'; reason =
upstream.status === 429
? 'rate_limited'
: `upstream_http_${upstream.status}`;
// Credit governor: fetchOpenSkyStates already started the cooldown // Credit governor: fetchOpenSkyStates already started the cooldown
// from OpenSky's retry-after — no upstream attempts until then. // from OpenSky's retry-after — no upstream attempts until then.
const cooldownMs = openSkyRateLimitCooldownMs(upstream); const cooldownMs = Math.max(0, _openskyCooldownUntil - Date.now());
// Serve the last-good body instead of the 429 when we have one — // Serve the last-good body instead of the 429 when we have one —
// the layer keeps rendering (STALE-cued) instead of dying. // the layer keeps rendering (STALE-cued) instead of dying.
if (_openskyCacheBody && _openskyCacheStatus === 200) { if (_openskyCacheBody && _openskyCacheStatus === 200) {
@@ -594,7 +663,7 @@ export function openSkyProxy() {
cacheStatus: 'STALE', cacheStatus: 'STALE',
requestedMode, requestedMode,
usedMode, usedMode,
reason: 'rate_limited_serving_stale', reason: `${reason}_serving_stale`,
staleSeconds: (now - _openskyCacheTime) / 1000, staleSeconds: (now - _openskyCacheTime) / 1000,
retryAfterSeconds: cooldownMs / 1000, retryAfterSeconds: cooldownMs / 1000,
}), }),
@@ -652,45 +721,6 @@ export function openSkyProxy() {
} }
} }
// Refine the reason string to reflect the actual outcome
if (upstream.ok && reason === 'forced_anonymous') {
reason = 'anonymous_ok';
} else if (
upstream.ok &&
usedMode === 'basic' &&
reason === 'basic_credentials'
) {
reason = 'basic_ok';
} else if (
upstream.ok &&
usedMode === 'oauth' &&
reason === 'oauth_token'
) {
reason = 'oauth_ok';
}
// Only cache successful responses — error responses (401/403/429/5xx)
// should not be served from cache on subsequent requests
if (upstream.ok) {
_openskyCacheBody = body;
_openskyCacheStatus = upstream.status;
_openskyCacheTime = now;
_openskyCacheSourceEpochMs = sourceEpochMs;
_openskyCacheMeta = {
requestedMode,
usedMode,
reason,
};
// Credit governor: adapt the cache TTL to the remaining daily
// budget so a continuously-open app stretches its polls instead of
// exhausting the quota mid-day. Success also clears any cooldown.
const remaining = Number(
upstream.headers.get('x-rate-limit-remaining'),
);
_openskyTtlMs = openskyAdaptiveTtlMs(remaining);
_openskyCooldownUntil = 0;
}
res.writeHead( res.writeHead(
upstream.status, upstream.status,
buildOpenSkyHeaders({ buildOpenSkyHeaders({
+5 -5
View File
@@ -20,8 +20,8 @@ import { admitSameSiteKeyless } from './common/same-site.js';
* cache and serve-stale shape. Cache entries and stale fallback are keyed per * cache and serve-stale shape. Cache entries and stale fallback are keyed per
* 5dp point, so reordered and partially overlapping batches reuse prior work. * 5dp point, so reordered and partially overlapping batches reuse prior work.
* Only missing/stale points go upstream; the response is rebuilt in exact * Only missing/stale points go upstream; the response is rebuilt in exact
* request order. Larger requests are chunked sequentially, and one failing * request order. Each request is bounded to one 64-point upstream chunk;
* chunk does not discard the chunks that resolved. * larger client workloads are split by the browser terrain service.
* *
* Bounds, because every distinct 5dp point is its own key: coordinates must be * Bounds, because every distinct 5dp point is its own key: coordinates must be
* in WGS-84 range (rejected at parse), the point cache has an entry ceiling * in WGS-84 range (rejected at parse), the point cache has an entry ceiling
@@ -55,7 +55,7 @@ export function terrainHeightsProxy(options = {}) {
// 2026-08-21). 64 points costs 5.6-12 s, inside the timeout at both ends of // 2026-08-21). 64 points costs 5.6-12 s, inside the timeout at both ends of
// that range. // that range.
const UPSTREAM_CHUNK = 64; const UPSTREAM_CHUNK = 64;
const MAX_POINTS = 2000; const MAX_POINTS = UPSTREAM_CHUNK;
/** /**
* Entry ceiling for the point cache, and with it the file written from it. * Entry ceiling for the point cache, and with it the file written from it.
* *
@@ -255,7 +255,6 @@ export function terrainHeightsProxy(options = {}) {
res.end(JSON.stringify({ error: 'Rate limit exceeded' })); res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
return; return;
} }
await loadDiskOnce();
const parsedUrl = new URL(req.url || '', 'http://internal'); const parsedUrl = new URL(req.url || '', 'http://internal');
const rawPoints = parsedUrl.searchParams.get('points'); const rawPoints = parsedUrl.searchParams.get('points');
const points = parseTerrainPoints(rawPoints); const points = parseTerrainPoints(rawPoints);
@@ -267,12 +266,13 @@ export function terrainHeightsProxy(options = {}) {
return; return;
} }
if (points.length > MAX_POINTS) { if (points.length > MAX_POINTS) {
send(500, { send(413, {
error: `too many points (${points.length}); max ${MAX_POINTS} per request`, error: `too many points (${points.length}); max ${MAX_POINTS} per request`,
}); });
return; return;
} }
await loadDiskOnce();
const outcome = await resolveTerrainHeightRequest({ const outcome = await resolveTerrainHeightRequest({
points, points,
cache: mem, cache: mem,
+7 -3
View File
@@ -412,14 +412,18 @@ const IMAGERY_LABEL_NAMES = Object.freeze([
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')], [/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
]); ]);
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */ /** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021, 2021年10月16日. */
function imageryDateText(observedAt) { function imageryDateText(observedAt) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || '')); const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
if (!match) return ''; if (!match) return '';
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3])); const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
if (Number.isNaN(date.getTime())) return ''; if (Number.isNaN(date.getTime())) return '';
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options }); const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase(); const year = part({ year: 'numeric' });
// Year-first languages (2021年10月16日) keep their own order and units.
const whole = part({ day: 'numeric', month: 'short', year: 'numeric' });
if (whole.startsWith(year)) return whole;
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${year}`.toUpperCase();
} }
/** /**
@@ -1029,7 +1033,7 @@ function createPanel(event, handlers) {
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span> <span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button> <button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
</div> </div>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p> <p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note">${escapeMarkup(t(' Clouds are preserved from the source imagery.'))}</span></p>
`; `;
const refs = collectPanelReferences(panel); const refs = collectPanelReferences(panel);
+2 -1
View File
@@ -72,6 +72,7 @@ test('the Docker install path keeps its loopback-only, launcher-owned contract',
const compose = readFileSync(new URL('../compose.yaml', import.meta.url), 'utf8'); const compose = readFileSync(new URL('../compose.yaml', import.meta.url), 'utf8');
assert.match(compose, /^\s+- "127\.0\.0\.1:4173:4173"$/m, 'the port is published on the host loopback only'); assert.match(compose, /^\s+- "127\.0\.0\.1:4173:4173"$/m, 'the port is published on the host loopback only');
assert.doesNotMatch(compose, /GEV_KEY_SETUP_TRUSTED_PEERS/, 'trust is derived at launch, never hard-coded'); assert.match(compose, /^\s+- GEV_KEY_SETUP_TRUSTED_PEERS$/m, 'the operator policy reaches the launcher without a hard-coded address');
assert.match(compose, /^\s+- GEV_ALLOWED_HOSTS$/m, 'the host allowlist reaches Vite');
assert.doesNotMatch(compose, /network_mode/, 'the container keeps its own network namespace'); assert.doesNotMatch(compose, /network_mode/, 'the container keeps its own network namespace');
}); });
+179
View File
@@ -0,0 +1,179 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
import { resolveTrustedPeers } from '../scripts/docker-start.mjs';
const ROUTES = 'Iface Destination Gateway Flags\neth0 00000000 0100A8C0 0003\n';
const launcher = fileURLToPath(
new URL('../scripts/docker-start.mjs', import.meta.url),
);
const gateUrl = new URL('./keySetupCore.mjs', import.meta.url).href;
test('none resolves to an empty trust list even with a valid container gateway', () => {
for (const value of ['none', ' NONE ', 'None']) {
assert.equal(
resolveTrustedPeers({
env: { GEV_KEY_SETUP_TRUSTED_PEERS: value },
routeTable: ROUTES,
}),
'',
);
}
});
function launchWithPolicy(value, routeTable = ROUTES) {
// Run the real entry point and routing-table reader. Replace only the Linux
// route file and Vite's listening server so this works on macOS and CI too.
const fakeVite = `
import { admitKeySetupRequest } from ${JSON.stringify(gateUrl)};
const trustedAtImport = process.env.GEV_KEY_SETUP_TRUSTED_PEERS;
export async function createServer() {
const request = {
method: 'POST', hostHeader: 'localhost:4173',
origin: 'http://localhost:4173', contentType: 'application/json',
env: { GEV_KEY_SETUP_TRUSTED_PEERS: process.env.GEV_KEY_SETUP_TRUSTED_PEERS }
};
console.log('POLICY ' + JSON.stringify({
trustedAtImport,
allowedHosts: process.env.GEV_ALLOWED_HOSTS,
gateway: admitKeySetupRequest({ ...request, remoteAddress: '192.168.0.1' }),
loopback: admitKeySetupRequest({ ...request, remoteAddress: '127.0.0.1' }),
foreignOrigin: admitKeySetupRequest({ ...request, remoteAddress: '192.168.0.1', origin: 'https://foreign.example' })
}));
return { async listen() {}, printUrls() {}, async close() {} };
}
`;
const preload = `
import fs from 'node:fs';
import { registerHooks, syncBuiltinESMExports } from 'node:module';
const originalRead = fs.readFileSync;
fs.readFileSync = (file, ...args) => file === '/proc/net/route'
? ${JSON.stringify(routeTable)} : originalRead(file, ...args);
syncBuiltinESMExports();
registerHooks({ resolve(specifier, context, next) {
if (specifier === 'vite') return {
url: ${JSON.stringify(`data:text/javascript,${encodeURIComponent(fakeVite)}`)}, shortCircuit: true
};
return next(specifier, context);
}});
`;
const env = { ...process.env, GEV_ALLOWED_HOSTS: 'globe.example.lan' };
delete env.GEV_KEY_SETUP_TRUSTED_PEERS;
if (value !== undefined) env.GEV_KEY_SETUP_TRUSTED_PEERS = value;
const child = spawnSync(
process.execPath,
[
'--import',
`data:text/javascript,${encodeURIComponent(preload)}`,
launcher,
],
{ env, encoding: 'utf8', timeout: 10000 },
);
assert.ifError(child.error);
assert.equal(child.status, 0, child.stderr);
const line = child.stdout
.split('\n')
.find((line) => line.startsWith('POLICY '));
assert.ok(line, child.stdout);
return { ...JSON.parse(line.slice(7)), output: child.stdout };
}
test('the real Docker entry point seals none before importing Vite and the setup gate refuses the gateway', () => {
const result = launchWithPolicy('none');
assert.equal(result.trustedAtImport, '');
assert.equal(result.gateway.status, 403);
assert.equal(result.gateway.code, 'not-loopback');
assert.equal(result.loopback.ok, true);
assert.equal(result.allowedHosts, 'globe.example.lan');
assert.match(result.output, /trusts no additional peers/);
assert.doesNotMatch(result.output, /No default gateway/);
});
test('unset, blank and explicit peers retain gateway admission without weakening Origin checks', () => {
for (const value of [undefined, '', ' ', '192.168.0.1']) {
const result = launchWithPolicy(value);
assert.equal(result.trustedAtImport, '192.168.0.1');
assert.equal(result.gateway.ok, true);
assert.equal(result.foreignOrigin.ok, false);
}
const result = launchWithPolicy(undefined, '');
assert.equal(result.trustedAtImport, '');
assert.equal(result.gateway.status, 403);
assert.match(result.output, /No default gateway/);
});
test('Compose resolves shell and project dotenv policy before the launcher starts', (t) => {
const version = spawnSync('docker', ['compose', 'version'], {
encoding: 'utf8',
timeout: 10000,
});
if (version.error || version.status !== 0)
return t.skip('Docker Compose CLI is not installed');
const root = mkdtempSync(path.join(tmpdir(), 'gev-compose-policy-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
writeFileSync(
path.join(root, 'compose.yaml'),
readFileSync(new URL('../compose.yaml', import.meta.url)),
);
const baseEnv = { ...process.env };
for (const key of Object.keys(baseEnv)) {
if (
key.startsWith('COMPOSE_') ||
['GEV_KEY_SETUP_TRUSTED_PEERS', 'GEV_ALLOWED_HOSTS'].includes(key)
)
delete baseEnv[key];
}
function config(dotenv = '', overrides = {}) {
writeFileSync(path.join(root, '.env'), dotenv);
const child = spawnSync(
'docker',
[
'compose',
'-f',
'compose.yaml',
'--env-file',
'.env',
'config',
'--format',
'json',
],
{
cwd: root,
env: { ...baseEnv, ...overrides },
encoding: 'utf8',
timeout: 10000,
},
);
assert.ifError(child.error);
assert.equal(child.status, 0, child.stderr);
return JSON.parse(child.stdout).services['gods-eye-view'].environment || {};
}
const unset = config();
assert.ok(unset.GEV_KEY_SETUP_TRUSTED_PEERS == null);
assert.ok(unset.GEV_ALLOWED_HOSTS == null);
const dotenv =
'GEV_KEY_SETUP_TRUSTED_PEERS=none\nGEV_ALLOWED_HOSTS=globe.example.lan\n';
assert.deepEqual(config(dotenv), {
GEV_KEY_SETUP_TRUSTED_PEERS: 'none',
GEV_ALLOWED_HOSTS: 'globe.example.lan',
});
assert.equal(
config(dotenv, { GEV_KEY_SETUP_TRUSTED_PEERS: '10.9.8.7' })
.GEV_KEY_SETUP_TRUSTED_PEERS,
'10.9.8.7',
);
assert.equal(
config('', { GEV_KEY_SETUP_TRUSTED_PEERS: 'none' })
.GEV_KEY_SETUP_TRUSTED_PEERS,
'none',
);
assert.equal(
config(dotenv, { GEV_KEY_SETUP_TRUSTED_PEERS: '' })
.GEV_KEY_SETUP_TRUSTED_PEERS,
'',
);
});
+14 -3
View File
@@ -15,7 +15,9 @@ export function hudQuantity(value, unit, digits = 0) {
minimumFractionDigits: digits, minimumFractionDigits: digits,
maximumFractionDigits: digits, maximumFractionDigits: digits,
}); });
return `${number}${unitLabel(unit).toUpperCase()}`; const label = unitLabel(unit);
if (getLocale() === 'zh-CN') return label ? `${number} ${label}` : number;
return `${number}${label.toUpperCase()}`;
} }
/** One hemisphere-tagged coordinate, e.g. `21.33N` / `21,33С`. */ /** One hemisphere-tagged coordinate, e.g. `21.33N` / `21,33С`. */
@@ -35,7 +37,16 @@ export const LOCALIZED_LOCALITY = Object.freeze({
}), }),
sector: ({ latDeg, lonDeg }) => sector: ({ latDeg, lonDeg }) =>
t('SECTOR {lat} {lon}', { t('SECTOR {lat} {lon}', {
lat: hudCoordinate(latDeg, t('N'), t('S')), // Hemisphere letters, not compass words: 21.33N, never 21.33北.
lon: hudCoordinate(lonDeg, t('E'), t('W')), lat: hudCoordinate(
latDeg,
t('N', { context: 'hemisphere' }),
t('S', { context: 'hemisphere' }),
),
lon: hudCoordinate(
lonDeg,
t('E', { context: 'hemisphere' }),
t('W', { context: 'hemisphere' }),
),
}), }),
}); });
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getLocale, setLocale } from './index.js';
import { createModel } from '../layers/firms/model.js';
import { createQueries } from '../layers/firms/queries.js';
const cases = [
[0, '不到 1 分钟前', '<1m ago'],
[120000, '2 分钟前', '2m ago'],
[7200000, '2 小时前', '2h ago'],
];
for (const [age, zh, en] of cases) {
test(`FIRMS live age ${age} ms has one relative-time suffix`, (context) => {
const now = 1800000000000;
context.mock.method(Date, 'now', () => now);
const previousLocale = getLocale();
const layerState = {
_loading: false,
_stale: false,
_keyRequired: false,
_error: null,
_lastUpdate: now - age,
_count: 1,
_cellCount: 0,
_fires: [],
};
const model = createModel({ services: { anchors: {} } });
const { methods } = createQueries({
layerState,
components: { model },
config: {},
});
try {
setLocale('en');
assert.equal(methods.getStats().loadingLabel, `LIVE · updated ${en}`);
setLocale('zh-CN');
assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`);
} finally {
setLocale(previousLocale);
}
});
}
+6
View File
@@ -35,6 +35,12 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () =>
assert.equal(normalizeLocale(' RU '), 'ru'); assert.equal(normalizeLocale(' RU '), 'ru');
assert.equal(normalizeLocale('en_GB'), 'en'); assert.equal(normalizeLocale('en_GB'), 'en');
assert.equal(normalizeLocale('xx'), null); assert.equal(normalizeLocale('xx'), null);
// Simplified Chinese by default; Traditional tags wait for zh-TW.
assert.equal(normalizeLocale('zh'), 'zh-CN');
assert.equal(normalizeLocale('zh-CN'), 'zh-CN');
assert.equal(normalizeLocale('zh-Hans-SG'), 'zh-CN');
assert.equal(normalizeLocale('zh-TW'), null);
assert.equal(normalizeLocale('zh-Hant-HK'), null);
assert.equal(normalizeLocale(undefined), null); assert.equal(normalizeLocale(undefined), null);
}); });
+2 -1
View File
@@ -18,8 +18,9 @@ import {
} from './locale.js'; } from './locale.js';
import es from './locales/es.json' with { type: 'json' }; import es from './locales/es.json' with { type: 'json' };
import ru from './locales/ru.json' with { type: 'json' }; import ru from './locales/ru.json' with { type: 'json' };
import zhCN from './locales/zh-CN.json' with { type: 'json' };
const CATALOGS = Object.freeze({ es, ru }); const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN });
let currentLocale = DEFAULT_LOCALE; let currentLocale = DEFAULT_LOCALE;
+10 -3
View File
@@ -8,25 +8,32 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
export const DEFAULT_LOCALE = 'en'; export const DEFAULT_LOCALE = 'en';
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */ /** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
export const LOCALES = Object.freeze(['en', 'es', 'ru']); export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN']);
/** Each locale named in its own language, for the selector. */ /** Each locale named in its own language, for the selector. */
export const LOCALE_NAMES = Object.freeze({ export const LOCALE_NAMES = Object.freeze({
en: 'English', en: 'English',
es: 'Español', es: 'Español',
ru: 'Русский', ru: 'Русский',
'zh-CN': '简体中文',
}); });
/** Right-to-left locales. */ /** Right-to-left locales. */
const RTL_LOCALES = Object.freeze([]); const RTL_LOCALES = Object.freeze([]);
/** /**
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru'. * Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'.
* @returns {string|null} * @returns {string|null}
*/ */
export function normalizeLocale(candidate) { export function normalizeLocale(candidate) {
if (typeof candidate !== 'string') return null; if (typeof candidate !== 'string') return null;
const primary = candidate.trim().toLowerCase().split(/[-_]/)[0]; const [primary, ...rest] = candidate.trim().toLowerCase().split(/[-_]/);
// Traditional Chinese readers are not given Simplified: no zh-TW yet.
if (primary === 'zh')
return rest.some((part) => ['hant', 'tw', 'hk', 'mo'].includes(part))
? null
: 'zh-CN';
return LOCALES.includes(primary) ? primary : null; return LOCALES.includes(primary) ? primary : null;
} }
+8 -2
View File
@@ -1086,7 +1086,6 @@
"FIELD REPORTS · {count}": "INFORMES DE CAMPO · {count}", "FIELD REPORTS · {count}": "INFORMES DE CAMPO · {count}",
"GEOLOCATIONS · {credit}": "GEOLOCALIZACIONES · {credit}", "GEOLOCATIONS · {credit}": "GEOLOCALIZACIONES · {credit}",
"↗ OPEN PUBLIC MAP": "↗ ABRIR MAPA PÚBLICO", "↗ OPEN PUBLIC MAP": "↗ ABRIR MAPA PÚBLICO",
"Clouds are preserved from the source imagery.": "Las nubes provienen de las imágenes de origen.",
"Show the Nepal context callout without a border or country fill": "Muestra la etiqueta de contexto de Nepal sin frontera ni relleno del país", "Show the Nepal context callout without a border or country fill": "Muestra la etiqueta de contexto de Nepal sin frontera ni relleno del país",
"Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Presenta Nepal, dibuja su frontera y luego revela el incidente del Bhote Koshi", "Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Presenta Nepal, dibuja su frontera y luego revela el incidente del Bhote Koshi",
"CITIES": "CIUDADES", "CITIES": "CIUDADES",
@@ -2937,5 +2936,12 @@
"Gemini access was refused. Check the API key and model access.": "Se denegó el acceso a Gemini. Comprueba la clave de API y el acceso al modelo.", "Gemini access was refused. Check the API key and model access.": "Se denegó el acceso a Gemini. Comprueba la clave de API y el acceso al modelo.",
"Gemini Live is unavailable for the configured model.": "Gemini Live no está disponible para el modelo configurado.", "Gemini Live is unavailable for the configured model.": "Gemini Live no está disponible para el modelo configurado.",
"Token request is too large": "La solicitud de credencial es demasiado grande", "Token request is too large": "La solicitud de credencial es demasiado grande",
"Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío" "Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío",
" Clouds are preserved from the source imagery.": " Las nubes provienen de las imágenes de origen.",
"hemisphere|N": "N",
"hemisphere|S": "S",
"hemisphere|E": "E",
"hemisphere|W": "O",
"OK": "OK",
"{category} ({count})": "{category} ({count})"
} }
+8 -2
View File
@@ -1100,7 +1100,6 @@
"FIELD REPORTS · {count}": "СООБЩЕНИЯ С МЕСТА · {count}", "FIELD REPORTS · {count}": "СООБЩЕНИЯ С МЕСТА · {count}",
"GEOLOCATIONS · {credit}": "ГЕОЛОКАЦИИ · {credit}", "GEOLOCATIONS · {credit}": "ГЕОЛОКАЦИИ · {credit}",
"↗ OPEN PUBLIC MAP": "↗ ОТКРЫТЬ ПУБЛИЧНУЮ КАРТУ", "↗ OPEN PUBLIC MAP": "↗ ОТКРЫТЬ ПУБЛИЧНУЮ КАРТУ",
"Clouds are preserved from the source imagery.": "Облака оставлены такими, как на исходных снимках.",
"Show the Nepal context callout without a border or country fill": "Показать выноску с контекстом Непала без границы и заливки страны", "Show the Nepal context callout without a border or country fill": "Показать выноску с контекстом Непала без границы и заливки страны",
"Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Представить Непал, нарисовать его границу, затем показать инцидент на Bhote Koshi", "Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Представить Непал, нарисовать его границу, затем показать инцидент на Bhote Koshi",
"CITIES": "ГОРОДА", "CITIES": "ГОРОДА",
@@ -3004,5 +3003,12 @@
"Gemini access was refused. Check the API key and model access.": "Доступ к Gemini запрещён. Проверьте ключ API и доступ к модели.", "Gemini access was refused. Check the API key and model access.": "Доступ к Gemini запрещён. Проверьте ключ API и доступ к модели.",
"Gemini Live is unavailable for the configured model.": "Gemini Live недоступен для выбранной модели.", "Gemini Live is unavailable for the configured model.": "Gemini Live недоступен для выбранной модели.",
"Token request is too large": "Запрос токена слишком велик", "Token request is too large": "Запрос токена слишком велик",
"Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON" "Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON",
" Clouds are preserved from the source imagery.": " Облака оставлены такими, как на исходных снимках.",
"hemisphere|N": "С",
"hemisphere|S": "Ю",
"hemisphere|E": "В",
"hemisphere|W": "З",
"OK": "НОРМА",
"{category} ({count})": "{category} ({count})"
} }
File diff suppressed because it is too large. Load diff
+59
View File
@@ -63,6 +63,7 @@
"NVG", "NVG",
"Noir", "Noir",
"Normal", "Normal",
"OK",
"POS: {time}Z", "POS: {time}Z",
"Plan", "Plan",
"Pop", "Pop",
@@ -98,6 +99,9 @@
"fire confidence|nominal", "fire confidence|nominal",
"ft", "ft",
"hPa", "hPa",
"hemisphere|E",
"hemisphere|N",
"hemisphere|S",
"installation class|BASE", "installation class|BASE",
"km", "km",
"km/h", "km/h",
@@ -133,5 +137,60 @@
"REC", "REC",
"VS", "VS",
"WHOT/BHOT" "WHOT/BHOT"
],
"zh-CN": [
"1090 MHz",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
"BRG {value}°",
"BRG —",
"CAL {value}",
"CAL · {badge}",
"CCTV",
"CRS {value}°",
"CRT",
"FLIR",
"FOV",
"FOV {value}°",
"FT",
"GEO",
"HDG",
"HDG {value}°",
"House",
"Ironbow",
"KTS",
"Landsat 8/9",
"MM",
"NVG",
"REC",
"SRC {value}",
"Sentinel-2",
"Techno",
"Trance",
"USB {rate} msg/s",
"WHOT/BHOT",
"dB",
"ft",
"hPa",
"hemisphere|E",
"hemisphere|N",
"hemisphere|S",
"hemisphere|W",
"km",
"km/h",
"kt",
"kts",
"mph",
"{city} CCTV",
"{rate} msg/s",
"{source} HTTP {status}",
"{speed} kn",
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM/S",
"ΔE",
"ΔN",
"▲ {frp} MW"
] ]
} }
+106
View File
@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, t } from './index.js';
import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js';
import { formatCockpitDistance } from '../ui/cockpitPresentation.js';
import { cctvSourceStatusLabel } from '../ui/cctvFrames.js';
import { READOUT_WORDS } from '../layers/recentImagery/display.js';
import { createModel } from '../layers/firms/model.js';
import { createQueries } from '../layers/firms/queries.js';
import { renderRadioState } from '../ui/radioPresentation.js';
test('Chinese HUD and cockpit units keep their case without spacing coordinates', () => {
try {
setLocale('zh-CN');
assert.equal(hudQuantity(0, 'km'), '0 km');
assert.equal(hudQuantity(2069, 'km', 1), '2069.0 km');
assert.equal(formatCockpitDistance(14000), '14\u00a0km');
assert.equal(LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }),
'扇区 21.33N 77.50W');
setLocale('en');
assert.equal(hudQuantity(0, 'km'), '0KM');
assert.equal(hudQuantity(2069, 'km', 1), '2069.0KM');
assert.equal(formatCockpitDistance(14000), '14\u00a0KM');
} finally {
setLocale('en');
}
});
test('FIRMS stale cache age uses localized durations while English stays compact', () => {
const now = Date.now();
const layerState = { _stale: true };
const components = {
model: createModel({ services: { anchors: {} } }),
};
const { methods } = createQueries({ layerState, components, config: {} });
try {
for (const [hours, zh, en] of [[0.5, '不到 1 小时', '<1h'], [2, '2 小时', '2h'], [72, '3 天', '3d']]) {
layerState._lastUpdate = now - hours * 3600000;
setLocale('zh-CN');
const expected = t('STALE · cached {age}', { age: zh });
assert.equal(methods.getStats().loadingLabel, expected);
assert.equal(methods.getStats().error, expected);
setLocale('en');
assert.equal(methods.getStats().loadingLabel, `STALE · cached ${en}`);
}
} finally {
setLocale('en');
}
});
test('Recent Imagery spaces numeric Chinese ages and retains natural relative dates', () => {
try {
setLocale('zh-CN');
assert.equal(READOUT_WORDS.daysAgo(9), '9 天前');
assert.equal(READOUT_WORDS.daysAgo(0), '今天');
assert.equal(READOUT_WORDS.daysAgo(1), '昨天');
setLocale('en');
assert.equal(READOUT_WORDS.daysAgo(9), '9 days ago');
assert.equal(READOUT_WORDS.daysAgo(0), 'today');
} finally {
setLocale('en');
}
});
test('CCTV translates the healthy status and preserves unknown provider codes', () => {
try {
for (const [locale, expected] of [['zh-CN', '正常'], ['ru', 'НОРМА'], ['es', 'OK'], ['en', 'OK']]) {
setLocale(locale);
assert.equal(cctvSourceStatusLabel('ok'), expected);
assert.equal(cctvSourceStatusLabel('CUSTOM'), 'CUSTOM');
}
} finally {
setLocale('en');
}
});
test('radio option text and accessible name use the same localized parentheses', () => {
const originalDocument = globalThis.document;
const element = () => ({
style: {}, dataset: {}, attributes: {},
classList: { contains: () => true, toggle() {} },
setAttribute(name, value) { this.attributes[name] = value; },
replaceChildren(...children) { this.children = children; },
});
globalThis.document = { createElement: element, getElementById: () => null };
try {
for (const [locale, label, expected] of [['zh-CN', '全部', '全部(750)'], ['en', 'All', 'All (750)']]) {
setLocale(locale);
const host = {
_radioPanel: element(), _radioFilter: element(),
_syncContextRadioLauncherState() {},
actions: { getLifecycle: () => null, scheduleLayout() {} },
};
renderRadioState.call(host, {
enabled: true, categories: [{ id: 'all', label, count: 750, color: '' }],
filter: 'all', filteredCount: 750, stationCount: 750, volume: 1,
});
const option = host._radioFilter.children[0];
assert.equal(option.textContent, `● ${expected}`);
assert.equal(option.attributes['aria-label'], expected);
}
} finally {
globalThis.document = originalDocument;
setLocale('en');
}
});
+1
View File
@@ -591,6 +591,7 @@ export function createModel({
formatLatLon, formatLatLon,
formatFrp, formatFrp,
formatAge, formatAge,
formatAgeLabel,
formatAgoMinutes, formatAgoMinutes,
confidenceBucket, confidenceBucket,
}; };
+2 -1
View File
@@ -64,7 +64,8 @@ export function createQueries({
const staleText = layerState._lastUpdate const staleText = layerState._lastUpdate
? t('STALE · cached {age}', { ? t('STALE · cached {age}', {
age: age:
components.model.formatAge(now - layerState._lastUpdate) || '<1h', components.model.formatAgeLabel(now - layerState._lastUpdate) ||
t('<1h', { context: 'age' }),
}) })
: t('STALE'); : t('STALE');
let loadingLabel = ''; let loadingLabel = '';
+10 -2
View File
@@ -3,7 +3,12 @@
* day's readout. The model (`model.js`) stays English and i18n-free because AI * day's readout. The model (`model.js`) stays English and i18n-free because AI
* tool services import it outside the browser; this module is page-only. * tool services import it outside the browser; this module is page-only.
*/ */
import { formatDate, formatRelativeTime, t } from '../../i18n/index.js'; import {
formatDate,
formatRelativeTime,
getLocale,
t,
} from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js'; import { formatQuantity } from '../../ui/unitLabels.js';
import { dayTime } from './model.js'; import { dayTime } from './model.js';
@@ -26,7 +31,10 @@ export const displayMetres = (value) => formatQuantity(value, 'm');
/** The readout's words in the page's language; the model keeps them English. */ /** The readout's words in the page's language; the model keeps them English. */
export const READOUT_WORDS = Object.freeze({ export const READOUT_WORDS = Object.freeze({
date: (day) => displayDay(day, LONG_DAY), date: (day) => displayDay(day, LONG_DAY),
daysAgo: (delta) => formatRelativeTime(delta > 0 ? -delta : 0, 'day'), daysAgo: (delta) => {
const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day');
return getLocale() === 'zh-CN' ? text.replace(/(\d)(?=天)/g, '$1 ') : text;
},
// "Sentinel-2" never breaks at its hyphen: a word joiner follows it // "Sentinel-2" never breaks at its hyphen: a word joiner follows it
// (JetBrains Mono has no U+2011 non-breaking hyphen). // (JetBrains Mono has no U+2011 non-breaking hyphen).
sensor: (sensor) => sensor: (sensor) =>
+19 -8
View File
@@ -296,10 +296,6 @@ export function createWeatherLayer({
motion?.addEventListener?.('change', onVisibility); motion?.addEventListener?.('change', onVisibility);
documentRef?.addEventListener?.('visibilitychange', onVisibility); documentRef?.addEventListener?.('visibilitychange', onVisibility);
registerClock(); registerClock();
removeCamera = viewer.camera.moveEnd?.addEventListener(() => {
checkHost();
if (enabled) notify();
});
}, },
attachShellServices(services) { attachShellServices(services) {
imageryHost = imageryHost =
@@ -315,10 +311,18 @@ export function createWeatherLayer({
}, },
enable() { enable() {
enabled = true; enabled = true;
if (!removeCamera) {
removeCamera = viewer.camera.moveEnd?.addEventListener(() => {
checkHost();
notify();
});
}
registerClock(); registerClock();
}, },
disable() { disable() {
enabled = false; enabled = false;
removeCamera?.();
removeCamera = null;
unregisterClock?.(); unregisterClock?.();
unregisterClock = null; unregisterClock = null;
++generation; ++generation;
@@ -357,7 +361,7 @@ export function createWeatherLayer({
signal: controller.signal, signal: controller.signal,
}); });
if (!enabled || controller.signal.aborted || request !== controller) if (!enabled || controller.signal.aborted || request !== controller)
return false; return enabled && !signal?.aborted && request !== controller;
if (snapshot.unavailable) { if (snapshot.unavailable) {
error = msg( error = msg(
'Weather source unavailable; previous observation retained', 'Weather source unavailable; previous observation retained',
@@ -376,7 +380,9 @@ export function createWeatherLayer({
) )
await show(snapshot.latest, controller.signal); await show(snapshot.latest, controller.signal);
return ( return (
!controller.signal.aborted && request === controller && enabled enabled &&
!signal?.aborted &&
(!controller.signal.aborted || request !== controller)
); );
} }
const time = const time =
@@ -388,9 +394,14 @@ export function createWeatherLayer({
(satellite && rendering.getDiagnostics().infrared !== infrared) (satellite && rendering.getDiagnostics().infrared !== infrared)
) )
await show(time, controller.signal); await show(time, controller.signal);
return !controller.signal.aborted && request === controller && enabled; return (
enabled &&
!signal?.aborted &&
(!controller.signal.aborted || request !== controller)
);
} catch (cause) { } catch (cause) {
if (controller.signal.aborted || request !== controller) return false; if (controller.signal.aborted || request !== controller)
return enabled && !signal?.aborted && request !== controller;
error = cause?.message || msg('Weather unavailable'); error = cause?.message || msg('Weather unavailable');
stop(); stop();
return true; return true;
+46 -1
View File
@@ -694,7 +694,11 @@ test('superseding a pending stage preserves the newer manifest loading state', a
await flush(); await flush();
const newer = h.layer.update(); const newer = h.layer.update();
await flush(); await flush();
assert.equal(await old, false); assert.equal(
await old,
true,
'replacement is accepted by the enable lifecycle',
);
assert.equal( assert.equal(
h.layer.getStats().loading, h.layer.getStats().loading,
true, true,
@@ -2311,3 +2315,44 @@ for (const product of ['radar', 'clouds-regional', 'lightning']) {
assert.equal(h.viewer.camera.moveEnd.size, 0); assert.equal(h.viewer.camera.moveEnd.size, 0);
}); });
} }
test('a product replacement accepts supersession but never paints the obsolete first response', async () => {
const pending = [];
const h = layerHarness({
id: 'weather-satellite',
feed: {
getSnapshot: (args) => {
const task = deferred();
pending.push({ ...args, ...task });
return task.promise;
},
},
});
const first = h.layer.update();
h.layer.setParams({ product: 'clouds' });
assert.equal(pending.length, 2);
pending[0].resolve({ ...snapshot, product: 'clouds-regional' });
assert.equal(await first, true);
assert.equal(h.stages.length, 0);
pending[1].resolve({ ...snapshot, product: 'clouds' });
await flush();
h.stages.at(-1).finish();
await flush();
assert.equal(h.layer.getParams().product, 'clouds');
h.layer.destroy();
});
test('disabled weather releases its camera listener and re-enabling binds only once', () => {
const h = layerHarness();
h.layer.disable();
assert.equal(h.moveEnd.size, 0, 'disabled layers own no active camera work');
h.layer.enable();
h.layer.enable();
assert.equal(h.moveEnd.size, 1);
h.layer.disable();
assert.equal(h.moveEnd.size, 0);
h.layer.enable();
assert.equal(h.moveEnd.size, 1);
h.layer.destroy();
assert.equal(h.moveEnd.size, 0);
});
+3 -2
View File
@@ -180,7 +180,7 @@ export function createWindLayer({
overlay: requestedScalar(), overlay: requestedScalar(),
}); });
if (!enabled || controller.signal.aborted || request !== controller) if (!enabled || controller.signal.aborted || request !== controller)
return false; return enabled && !signal?.aborted && request !== controller;
manifest = snapshot; manifest = snapshot;
error = null; error = null;
if (sampledPosition) sample(sampledPosition); if (sampledPosition) sample(sampledPosition);
@@ -194,7 +194,8 @@ export function createWindLayer({
} }
return true; return true;
} catch (cause) { } catch (cause) {
if (controller.signal.aborted || request !== controller) return false; if (controller.signal.aborted || request !== controller)
return enabled && !signal?.aborted && request !== controller;
error = cause?.message || msg('Wind source unavailable'); error = cause?.message || msg('Wind source unavailable');
return true; return true;
} finally { } finally {
+33
View File
@@ -412,3 +412,36 @@ test('wind unit chips appear only alongside a speed legend, including canvas tra
for (const overlay of ['pressure', 'temperature', 'none']) { layer.setParams({ overlay }); assert.deepEqual(unitChips(), []); } for (const overlay of ['pressure', 'temperature', 'none']) { layer.setParams({ overlay }); assert.deepEqual(unitChips(), []); }
layer.destroy(); layer.destroy();
}); });
test('a parameter replacement during manager enable does not roll the layer back', async () => {
const { LayerLifecycle } = await import('../../data/lifecycle.js');
const pending = [];
const rendering = Object.fromEntries(
['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map((name) => [
name,
() => {},
]),
);
const layer = createWindLayer({
feed: {
getSnapshot: (args) =>
new Promise((resolve) => pending.push({ ...args, resolve })),
},
createRendering: () => rendering,
});
const manager = new LayerLifecycle({ container: {} });
manager.register(layer);
const enabled = manager.setEnabled('wind', true);
for (let i = 0; i < 20 && !pending.length; i++) await Promise.resolve();
layer.setParams({ model: 'ifs' });
await Promise.resolve();
pending[0].resolve(snapshot('gfs'));
assert.equal(await enabled, true);
assert.equal(manager.isEnabled('wind'), true);
assert.equal(layer.getStats().loading, true);
pending[1].resolve(snapshot('ifs'));
for (let i = 0; i < 10; i++) await Promise.resolve();
assert.equal(layer.getStats().model, 'IFS');
assert.equal(layer.getStats().count, 4);
await manager.destroyAll();
});
+17
View File
@@ -778,3 +778,20 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
assert.match(VisualSettings.prototype.stop.toString(), /this\._styleParameters\?\.destroy\(\)/); assert.match(VisualSettings.prototype.stop.toString(), /this\._styleParameters\?\.destroy\(\)/);
assert.match(VisualSettings.prototype.stop.toString(), /this\._visualEffects\.stop\(\)/); assert.match(VisualSettings.prototype.stop.toString(), /this\._visualEffects\.stop\(\)/);
}); });
test('share restore rejects inherited styles while continuing later fields', () => {
for (const style of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'unknown']) {
const restored = makeManager(`#lat=10&lon=20&style=${style}&cr=1`).parseInitialHash();
assert.equal(restored.style, 'normal');
assert.equal(restored.celestialRing, true);
}
});
test('programmatic invalid styles are ignored before changing visual state', () => {
const target = { activeStyle: 'normal' };
for (const value of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'unknown', {}, null, () => {}]) {
assert.doesNotThrow(() => VisualSettings.prototype.setStyle.call(target, value));
assert.equal(target.activeStyle, 'normal');
}
});
+3 -1
View File
@@ -175,7 +175,9 @@ export class ShareLinkManager {
parseOr(params.get('dd'), 50), parseOr(params.get('dd'), 50),
50, 50,
); );
const style = URL_TO_STYLE[params.get('style')] || 'normal'; const style = Object.hasOwn(URL_TO_STYLE, params.get('style'))
? URL_TO_STYLE[params.get('style')]
: 'normal';
const decodedLayerState = decodeLayerStateParams(params); const decodedLayerState = decodeLayerStateParams(params);
const state = { const state = {
lat, lat,
+21
View File
@@ -366,3 +366,24 @@ test('a second client keeps its own quota', async (t) => {
}); });
assert.notEqual(neighbour.statusCode, 429); assert.notEqual(neighbour.statusCode, 429);
}); });
test('one 64-point chunk is accepted; larger batches fail before cache or upstream work', async (t) => {
const root = await mkdtemp(path.join(tmpdir(), 'terrain-batch-limit-'));
t.after(() => rm(root, { recursive: true, force: true }));
const asked = stubTerrainUpstream(t);
const route = install(
terrainHeightsProxy({ cachePath: path.join(root, 'cache.json') }),
).get('/api/terrain/heights');
const points = (count) =>
Array.from({ length: count }, (_, i) => `${i / 1000},30`).join(';');
for (const count of [65, 2001]) {
const response = await request(route, { url: `/?points=${points(count)}` });
assert.equal(response.statusCode, 413);
assert.match(response.body.error, /max 64/);
assert.equal(asked.length, 0);
}
const response = await request(route, { url: `/?points=${points(64)}` });
assert.equal(response.statusCode, 200);
assert.equal(response.body.results.length, 64);
assert.equal(asked.length, 64);
});
+22 -19
View File
@@ -98,7 +98,7 @@ test('standalone composition mounts every extracted provider exactly once withou
assert.equal(plugins.filter((p) => p.name === factory().name).length, 1); assert.equal(plugins.filter((p) => p.name === factory().name).length, 1);
}); });
test('terrain middleware chunks missing points and reconstructs repeated/reordered requests from cache', async (t) => { test('terrain middleware bounds missing points and reconstructs repeated/reordered requests from cache', async (t) => {
isolate(t); isolate(t);
let calls = 0; let calls = 0;
const sizes = []; const sizes = [];
@@ -116,25 +116,23 @@ test('terrain middleware chunks missing points and reconstructs repeated/reorder
}); });
}); });
const request = install(terrainHeightsProxy()); const request = install(terrainHeightsProxy());
const points = Array.from({ length: 257 }, (_, i) => `${i / 100},1`); const points = Array.from({ length: 64 }, (_, i) => `${i / 100},1`);
const res = await request('/?points=' + points.join(';')); const res = await request('/?points=' + points.join(';'));
assert.equal(res.status, 200); assert.equal(res.status, 200);
// Tracks UPSTREAM_CHUNK in server/providers/terrain.js (64): 257 points assert.deepEqual(sizes, [64]);
// split into four full chunks and a remainder. const reordered = await request('/?points=0.63,1;0,1;0.63,1');
assert.deepEqual(sizes, [64, 64, 64, 64, 1]);
const reordered = await request('/?points=2.56,1;0,1;2.56,1');
assert.deepEqual(json(reordered), { assert.deepEqual(json(reordered), {
results: [{ ellipsoid: 102.56 }, { ellipsoid: 100 }, { ellipsoid: 102.56 }], results: [{ ellipsoid: 100.63 }, { ellipsoid: 100 }, { ellipsoid: 100.63 }],
}); });
// Five chunks for the first batch; the reordered request is fully cached. // One bounded batch; the reordered request is fully cached.
assert.equal(calls, 5); assert.equal(calls, 1);
assert.equal((await request('/?points=invalid')).status, 400); assert.equal((await request('/?points=invalid')).status, 400);
assert.equal( assert.equal(
(await request('/?points=' + Array(2001).fill('0,1').join(';'))).status, (await request('/?points=' + Array(2001).fill('0,1').join(';'))).status,
500, 413,
); );
// Rejected requests add no upstream calls: still the five from the first batch. // Rejected requests add no upstream calls.
assert.equal(calls, 5); assert.equal(calls, 1);
}); });
test('terrain middleware migrates valid legacy disk points without fabricating omitted heights', async (t) => { test('terrain middleware migrates valid legacy disk points without fabricating omitted heights', async (t) => {
@@ -388,21 +386,26 @@ test('terrain middleware retains successful chunks around a failure and retries
}); });
const request = install(terrainHeightsProxy()); const request = install(terrainHeightsProxy());
const points = Array.from({ length: 130 }, (_, i) => `${i / 100},1`); const points = Array.from({ length: 130 }, (_, i) => `${i / 100},1`);
const url = '/?points=' + points.join(';'); const urls = [0, 64, 128].map(
assert.equal((await request(url)).status, 502); (start) => '/?points=' + points.slice(start, start + 64).join(';'),
);
assert.deepEqual( assert.deepEqual(
requests.map((p) => p.length), await Promise.all(urls.map(async (url) => (await request(url)).status)),
[64, 64, 2], [200, 502, 200],
);
assert.deepEqual(
requests.map((p) => p.length).sort((a, b) => a - b),
[2, 64, 64],
); );
fail = false; fail = false;
const recovered = await request(url); const recovered = await Promise.all(urls.map((url) => request(url)));
assert.equal(recovered.status, 200); assert.ok(recovered.every((response) => response.status === 200));
assert.deepEqual( assert.deepEqual(
requests[3], requests[3],
Array.from({ length: 64 }, (_, i) => [(i + 64) / 100, 1]), Array.from({ length: 64 }, (_, i) => [(i + 64) / 100, 1]),
); );
assert.deepEqual( assert.deepEqual(
json(recovered).results, recovered.flatMap((response) => json(response).results),
Array.from({ length: 130 }, (_, i) => ({ ellipsoid: i / 100 + 100 })), Array.from({ length: 130 }, (_, i) => ({ ellipsoid: i / 100 + 100 })),
); );
assert.equal(requests.length, 4); assert.equal(requests.length, 4);
+121
View File
@@ -583,3 +583,124 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
assert.equal(res2.statusCode, 502); assert.equal(res2.statusCode, 502);
assert.equal(callCount, 1); assert.equal(callCount, 1);
}); });
test('global aircraft snapshots coalesce cold and expired callers and clear rejected flights', async (t) => {
environment(t, { OPENSKY_AUTH_MODE: 'anon' });
t.mock.method(console, 'warn', () => {});
t.mock.method(console, 'error', () => {});
let now = Date.now();
t.mock.method(Date, 'now', () => now);
for (const kind of ['opensky', 'military']) {
const module =
kind === 'opensky'
? await import(
`../../server/providers/aircraft/opensky.js?coalesce=${Math.random()}`
)
: await import('../../server/providers/aircraft/adsb-lol.js');
const request = install(
kind === 'opensky' ? module.openSkyProxy() : module.adsbLolProxy(),
);
const route = kind === 'opensky' ? '/api/flights' : '/api/military';
let calls = 0;
let release;
let blocked;
let fail = false;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
await blocked;
if (fail) throw new Error('fixture connection failed');
return Response.json(
{ time: now / 1000, states: [], ac: [] },
{ headers: { 'x-rate-limit-remaining': '5000' } },
);
});
for (const phase of ['cold', 'expired', 'failed', 'recovered']) {
now += 13000;
fail = phase === 'failed';
blocked = new Promise((resolve) => {
release = resolve;
});
const before = calls;
const pending = Array.from({ length: 5 }, () => request(route));
await new Promise(setImmediate);
assert.equal(calls - before, 1, `${kind} ${phase}: one refresh starts`);
release();
const responses = await Promise.all(pending);
assert.equal(calls - before, fail && kind === 'opensky' ? 2 : 1);
assert.ok(responses.every((res) => res.statusCode === 200));
assert.ok(responses.every((res) => res.body === responses[0].body));
}
}
});
test('OpenSky 5xx retains dated last-good data, cancels error bodies and cools down before recovery', async (t) => {
environment(t, { OPENSKY_AUTH_MODE: 'anon' });
let now = Date.now();
t.mock.method(Date, 'now', () => now);
for (const status of [502, 503, 504]) {
const module = await import(
`../../server/providers/aircraft/opensky.js?server-error=${status}`
);
const request = install(module.openSkyProxy());
let fail = false;
let calls = 0;
let cancelled = false;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
if (fail)
return new Response(
new ReadableStream({
cancel() {
cancelled = true;
},
}),
{ status },
);
return Response.json(
{ time: now / 1000, states: [] },
{ headers: { 'x-rate-limit-remaining': '5000' } },
);
});
const good = await request('/api/flights');
now += 10000;
fail = true;
const stale = await request('/api/flights');
assert.equal(stale.statusCode, 200);
assert.equal(stale.body, good.body);
assert.equal(stale.headers['x-opensky-cache'], 'STALE');
assert.equal(
stale.headers['x-opensky-auth-reason'],
`upstream_http_${status}_serving_stale`,
);
assert.equal(stale.headers['x-opensky-stale-seconds'], '10');
assert.equal(cancelled, true);
const cooling = await request('/api/flights');
assert.equal(cooling.body, good.body);
assert.equal(
cooling.headers['x-opensky-auth-reason'],
`upstream_http_${status}_serving_stale`,
);
assert.equal(calls, 2);
now += 16000;
fail = false;
assert.equal(
(await request('/api/flights')).headers['x-opensky-cache'],
'MISS',
);
assert.equal(calls, 3);
}
const coldModule =
await import('../../server/providers/aircraft/opensky.js?cold-server-error');
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
return new Response('<html>failure</html>', { status: 503 });
});
const cold = install(coldModule.openSkyProxy());
for (let i = 0; i < 2; i++) {
const response = await cold('/api/flights');
assert.equal(response.statusCode, 503);
assert.match(JSON.parse(response.body).error, /OpenSky/);
}
assert.equal(calls, 1);
});
+2 -1
View File
@@ -20,6 +20,7 @@ const SOURCE_KIND_LABELS = Object.freeze({
}); });
const SOURCE_STATUS_LABELS = Object.freeze({ const SOURCE_STATUS_LABELS = Object.freeze({
ok: msg('OK'),
degraded: msg('DEGRADED'), degraded: msg('DEGRADED'),
error: msg('ERROR'), error: msg('ERROR'),
unknown: msg('UNKNOWN'), unknown: msg('UNKNOWN'),
@@ -52,7 +53,7 @@ export function cctvSourceKindLabel(kind) {
: key.toUpperCase(); : key.toUpperCase();
} }
/** "DEGRADED" / "ПЕРЕБОИ"; OK and unknown codes stay as uppercase codes. */ /** "DEGRADED" / "ПЕРЕБОИ"; unknown codes stay as uppercase codes. */
export function cctvSourceStatusLabel(status) { export function cctvSourceStatusLabel(status) {
const key = String(status || 'unknown').toLowerCase(); const key = String(status || 'unknown').toLowerCase();
return SOURCE_STATUS_LABELS[key] return SOURCE_STATUS_LABELS[key]
+2 -2
View File
@@ -1,6 +1,6 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */ /** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { formatNumber, t, tn } from '../i18n/index.js'; import { formatNumber, getLocale, t, tn } from '../i18n/index.js';
import { unitLabel } from './unitLabels.js'; import { unitLabel } from './unitLabels.js';
import { import {
bearingBetweenCoordinates, bearingBetweenCoordinates,
@@ -15,7 +15,7 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
window: ({ label, radiusKm }) => window: ({ label, radiusKm }) =>
t('{label} · {radius} AIR/SEA WINDOW', { t('{label} · {radius} AIR/SEA WINDOW', {
label, label,
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${unitLabel('km').toUpperCase()}`, radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale() === 'zh-CN' ? unitLabel('km') : unitLabel('km').toUpperCase()}`,
}), }),
installations: ({ scope, coverage }) => installations: ({ scope, coverage }) =>
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }), t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
+4 -3
View File
@@ -1,5 +1,5 @@
/** Pure formatting and fixed presentation constants used by Cockpit components. */ /** Pure formatting and fixed presentation constants used by Cockpit components. */
import { formatNumber, t, msg } from '../i18n/index.js'; import { formatNumber, getLocale, t, msg } from '../i18n/index.js';
import { formatQuantity } from './unitLabels.js'; import { formatQuantity } from './unitLabels.js';
export const COCKPIT_HEADING_SLEW_DPS = 28; export const COCKPIT_HEADING_SLEW_DPS = 28;
@@ -145,10 +145,11 @@ export function formatCockpitCoordinate(value, positive, negative) {
*/ */
export function formatCockpitDistance(distanceM) { export function formatCockpitDistance(distanceM) {
const digits = distanceM < 10000 ? 1 : 0; const digits = distanceM < 10000 ? 1 : 0;
return formatQuantity(distanceM / 1000, 'km', { const distance = formatQuantity(distanceM / 1000, 'km', {
minimumFractionDigits: digits, minimumFractionDigits: digits,
maximumFractionDigits: digits, maximumFractionDigits: digits,
}).toUpperCase(); });
return getLocale() === 'zh-CN' ? distance : distance.toUpperCase();
} }
export function formatCockpitWindDirection(value) { export function formatCockpitWindDirection(value) {
+4 -4
View File
@@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES, nativeNames: LOCALE_NAMES,
}), }),
), ),
['ru', 'en', 'es'], ['ru', 'en', 'es', 'zh-CN'],
); );
assert.deepEqual( assert.deepEqual(
codes( codes(
@@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES, nativeNames: LOCALE_NAMES,
}), }),
), ),
['es', 'en', 'ru'], ['es', 'en', 'ru', 'zh-CN'],
); );
assert.deepEqual( assert.deepEqual(
codes( codes(
@@ -60,7 +60,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES, nativeNames: LOCALE_NAMES,
}), }),
), ),
['en', 'es', 'ru'], ['en', 'es', 'ru', 'zh-CN'],
); );
const rows = buildLocaleOptions({ const rows = buildLocaleOptions({
locales: MANY, locales: MANY,
@@ -263,7 +263,7 @@ test('keyboard model: open on the current language, move, choose, close', () =>
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true }); assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
assert.equal(model.activeOption.code, 'en'); assert.equal(model.activeOption.code, 'en');
model.keydown({ key: 'End' }); model.keydown({ key: 'End' });
assert.equal(model.activeOption.code, 'es'); assert.equal(model.activeOption.code, 'zh-CN');
model.keydown({ key: 'Home' }); model.keydown({ key: 'Home' });
assert.equal(model.activeOption.code, 'ru'); assert.equal(model.activeOption.code, 'ru');
model.keydown({ key: 'ArrowUp' }); model.keydown({ key: 'ArrowUp' });
+6 -5
View File
@@ -114,13 +114,14 @@ export function renderRadioState(state) {
...state.categories.map((category) => { ...state.categories.map((category) => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = category.id; option.value = category.id;
option.textContent = `● ${category.label} (${category.count})`; const label = t('{category} ({count})', {
category: category.label,
count: category.count,
});
option.textContent = `● ${label}`;
option.dataset.radioColor = category.color; option.dataset.radioColor = category.color;
option.style.color = category.color; option.style.color = category.color;
option.setAttribute( option.setAttribute('aria-label', label);
'aria-label',
`${category.label} (${category.count})`,
);
return option; return option;
}), }),
); );
+51
View File
@@ -0,0 +1,51 @@
/* Tight tracking keeps Chinese labels readable; preserve Latin tracking for
intel HUD readouts and content marked translate="no", including its containers. */
html:lang(zh)
body
:not(
#intel-hud,
#intel-hud *,
[translate='no'],
[translate='no'] *,
:has([translate='no'])
) {
letter-spacing: 0.02em !important;
}
/* Keep small Chinese labels legible at a minimum of 9px. */
html:lang(zh) #command-dock .gev-mic-label,
html:lang(zh) #command-dock #gev-voice-detail,
html:lang(zh) .gev-voice-tier-btn,
html:lang(zh) .panel-layer-toggle {
font-size: 9px;
}
/* A two-glyph button label must not break between its glyphs. */
html:lang(zh) .pp-mode-btn {
white-space: nowrap;
}
/* Keep unit symbols in their catalog case, including the cockpit readouts. */
html:lang(zh) #intel-hud *,
html:lang(zh) #cockpit-hud * {
text-transform: none;
}
/* Move short travel modes (步行 / 骑行) to the next line as whole words. */
html:lang(zh) [data-layer-id='directions'] .data-toggle-meta {
word-break: keep-all;
overflow-wrap: break-word;
}
/* Leave room for the complete sensor name and resolution on two lines. */
html:lang(zh) .recent-imagery-readout .ri-strip {
height: 131px;
}
html:lang(zh) .recent-imagery-readout .ri-card {
height: 123px;
grid-template-rows: 42px 13px 22px 11px 18px;
}
html:lang(zh) .recent-imagery-readout .ri-card-sensor {
white-space: normal;
text-overflow: clip;
}
+5
View File
@@ -1538,6 +1538,11 @@ export class VisualSettings {
preserveCyberRestore = false, preserveCyberRestore = false,
} = {}, } = {},
) { ) {
if (
typeof styleName !== 'string' ||
(styleName !== 'normal' && !Object.hasOwn(STYLES, styleName))
)
return;
const { setDetectionStyle } = this.services; const { setDetectionStyle } = this.services;
if (!restore) this.shareLinkManager?.claimRestoreLane?.('visual'); if (!restore) this.shareLinkManager?.claimRestoreLane?.('visual');
if (this.hud.getVariant() === 'cyber') { if (this.hud.getVariant() === 'cyber') {
+3 -1
View File
@@ -370,7 +370,9 @@ export function viewFromParams(params) {
pitch_deg: number('pitch'), pitch_deg: number('pitch'),
}, },
layers: state?.enabledLayerIds ?? [], layers: state?.enabledLayerIds ?? [],
style: STYLES_BY_URL_NAME[params.get('style')] ?? null, style: Object.hasOwn(STYLES_BY_URL_NAME, params.get('style'))
? STYLES_BY_URL_NAME[params.get('style')]
: null,
map: VIEW_MAPS.includes(map) ? map : null, map: VIEW_MAPS.includes(map) ? map : null,
follow, follow,
annotations: annotationsFromParams(params), annotations: annotationsFromParams(params),
+17
View File
@@ -126,3 +126,20 @@ test('annotations travel in the link, keeping only what the app draws', async ()
// Too long for a link: the view keeps them, the link leaves them out. // Too long for a link: the view keeps them, the link leaves them out.
assert.equal(viewToParams(many).has('an'), false); assert.equal(viewToParams(many).has('an'), false);
}); });
test('inherited and unknown style keys do not corrupt a restored view', () => {
for (const style of [
'constructor',
'__proto__',
'toString',
'hasOwnProperty',
'unknown',
]) {
const view = viewFromParams(
new URLSearchParams({ lat: '10', lon: '20', style, map: 'osm' }),
);
assert.equal(view.style, null);
assert.equal(view.map, 'osm');
assert.equal(view.camera.lat, 10);
}
});
+1
View File
@@ -32,4 +32,5 @@
@import './src/ui/styles/weather.css'; @import './src/ui/styles/weather.css';
@import './src/ui/styles/panel-surfaces.css'; @import './src/ui/styles/panel-surfaces.css';
@import './src/ui/styles/segmented-control.css'; @import './src/ui/styles/segmented-control.css';
@import './src/ui/styles/cjk.css';
@import './src/ui/styles/cyber.css'; @import './src/ui/styles/cyber.css';