From 415f23739f7c81dc9f798fb5107f0e9e1044b2bf Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 16:26:55 +0400 Subject: [PATCH 1/4] Name the reporting provider in Contacts and Cockpit counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cockpit Contact counts named fixed providers (OpenSky, adsb.lol, AISStream) while the Layers panel and Contacts named the provider the last snapshot reported, and the vessels layer kept that name out of getStats(), so Contacts always said AISStream. Each cohort now carries the provider and whether it answered as a fallback or from a stale snapshot, and both views show it the way the Layers row does ("adsb.lol · FALLBACK"). Fixes HAL-721. --- docs/CURRENT-STATE.md | 1 + scripts/package-boundaries.json | 7 ++++ src/layers/awareness/model.js | 1 + src/layers/awareness/panel.js | 3 +- src/layers/awareness/panel.test.mjs | 38 +++++++++++++++++++++ src/layers/awareness/queries.test.mjs | 26 +++++++++++++++ src/layers/awareness/subject.js | 12 +++++++ src/layers/vessels/ownership.test.mjs | 14 ++++++++ src/layers/vessels/queries.js | 2 ++ src/ui/cockpitContext.js | 3 ++ src/ui/cockpitContext.test.mjs | 46 ++++++++++++++++++++++++++ src/ui/cockpitContextState.js | 5 ++- src/ui/layerNames.js | 14 ++++++++ src/ui/layerNames.test.mjs | 17 ++++++++++ src/ui/views/panels/CockpitContext.vue | 8 ++--- 15 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 src/layers/awareness/panel.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 35d81dd05..9f5a40e63 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4965,6 +4965,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. +- Contacts rows and the Cockpit Contact counts name each cohort's provider as its layer row does: the source the last snapshot reported, marked `FALLBACK` or `STALE` in the same cases. A flight snapshot answered by the adsb.lol regional fallback reads `adsb.lol · FALLBACK` everywhere, never `OpenSky`. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6d55213e7..f5529e731 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -871,6 +871,7 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", + "src/ui/layerNames.js", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1713,6 +1714,10 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", + "src/data/feedState.js", + "src/loadingFeedback.js", + "src/data/installationFeedback.js", + "src/ui/layerNames.js", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -3208,6 +3213,8 @@ "application-layer-construction": { "exports": ["./application/layers"], "modules": [ + "src/data/feedState.js", + "src/loadingFeedback.js", "src/sources/adsbFields.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", diff --git a/src/layers/awareness/model.js b/src/layers/awareness/model.js index 1a399c5af..1ff2c93cf 100644 --- a/src/layers/awareness/model.js +++ b/src/layers/awareness/model.js @@ -196,6 +196,7 @@ export function createModel({ state: layerState, services, parts, source }) { id: cohort.id, label: cohort.label, source: cohort.source, + sourceStatus: cohort.sourceStatus || null, coverage: cohort.coverage || null, relationship: cohort.summary.relationship, count: cohort.summary.count, diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index c0c71ed01..5396a0390 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -8,6 +8,7 @@ import { t, translateKnown, } from '../../i18n/index.js'; +import { sourceStatusLabel } from '../../ui/layerNames.js'; import { formatQuantity } from '../../ui/unitLabels.js'; import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js'; import { distanceParts } from '../../data/distanceFormat.js'; @@ -150,7 +151,7 @@ export function createPanel({ state: layerState, parts }) { relationship: summary.relationship.toLowerCase(), label: cohort.label, count: `${presentAwarenessCount(summary)}${pageLabel}`, - detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`, + detail: `${sourceStatusLabel(cohort.source, cohort.sourceStatus)}${coverage} · ${presentAwarenessReason(summary)}`, nearest, }; } diff --git a/src/layers/awareness/panel.test.mjs b/src/layers/awareness/panel.test.mjs new file mode 100644 index 000000000..eaaac70e9 --- /dev/null +++ b/src/layers/awareness/panel.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPanel } from './panel.js'; + +test('a Contacts row names its provider as the Layers panel does, with its feed state', () => { + const cohort = (id, source, sourceStatus) => ({ + id, + label: id, + source, + sourceStatus, + summary: { relationship: 'OBSERVED', count: 1, nearest: [], reason: 'observed' }, + }); + const state = { + enabled: true, + results: { + subject: { label: 'AFR38JF' }, + cohorts: [ + cohort('flights', 'adsb.lol', 'fallback'), + cohort('military', 'adsb.lol', null), + cohort('ais-live-vessels', 'Datalastic', 'stale'), + ], + }, + cohortPages: new Map(), + panelListeners: new Set(), + panelSignature: '', + navigationIndex: 0, + }; + const parts = { + navigation: { canNavigateNext: () => false }, + rendering: { hideDirectionOverlay() {} }, + }; + createPanel({ state, parts }).renderResults(); + assert.deepEqual( + state.panelView.rows.map((row) => row.detail), + ['adsb.lol · FALLBACK · observed', 'adsb.lol · observed', 'Datalastic · STALE · observed'], + ); + state.offPanelLocale?.(); +}); diff --git a/src/layers/awareness/queries.test.mjs b/src/layers/awareness/queries.test.mjs index 04e8a8982..07686dcfc 100644 --- a/src/layers/awareness/queries.test.mjs +++ b/src/layers/awareness/queries.test.mjs @@ -81,3 +81,29 @@ test('Contacts excludes square corners and measures from the current moving subj assert.equal(moved.reason, 'mapped matches within 100 km of the subject'); assert.deepEqual(installationState.contextAnchor, { latitude: 0, longitude: 0 }); }); + +test('Contacts cohorts carry the provider and whether it answered as a fallback or stale', () => { + const services = { installations: { getNearby: () => [], getStats: () => ({}) }, + flights: { getNearby: () => [] }, military: { getNearby: () => [] }, vessels: { getNearby: () => [] } }; + const parts = { navigation: { summarizeAwarenessCohortForNavigation: (items, source) => + summarizeAwarenessCohort(items, source) } }; + parts.queries = createQueries({ state: {}, services, parts }); + const subjectOwner = createSubject({ state: {}, services, parts }); + const states = { + // The OpenSky proxy answers from adsb.lol when OpenSky fails. + flights: { available: true, stats: { source: 'adsb.lol', coverage: '250nm regional fallback', count: 4, lastUpdate: 1 } }, + military: { available: true, stats: { source: 'adsb.lol', fallback: false, count: 2, lastUpdate: 1 } }, + 'ais-live-vessels': { available: true, stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1 } }, + 'military-installations': { available: true, stats: { coverage: { kind: 'subject', radiusM: 100000 } } }, + }; + const { cohorts } = subjectOwner.evaluateSubject({ + id: 'subject', layerId: 'flights', position: Cesium.Cartesian3.fromDegrees(0, 0, 10000), + }, states); + const byId = Object.fromEntries(cohorts.map((cohort) => [cohort.id, cohort])); + assert.equal(byId.flights.source, 'adsb.lol'); + assert.equal(byId.flights.sourceStatus, 'fallback'); + assert.equal(byId.military.sourceStatus, null); + assert.equal(byId['ais-live-vessels'].source, 'Datalastic'); + assert.equal(byId['ais-live-vessels'].sourceStatus, 'stale'); + assert.equal(byId['military-installations'].sourceStatus, null); +}); diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 1baf54ec4..a555d332e 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -3,6 +3,7 @@ import { AWARENESS_RELATIONSHIP, } from '../../data/militaryAwarenessEngine.js'; import * as Cesium from 'cesium'; +import { layerFeedState } from '../../data/feedState.js'; import { t } from '../../i18n/index.js'; import { AWARENESS_QUERY_LIMIT, @@ -52,6 +53,13 @@ export function createSubject({ state: layerState, services, parts, source }) { const vesselsState = sourceStates['ais-live-vessels']; const installationsState = sourceStates['military-installations']; const evaluatedAt = Date.now(); + // Whether the provider answered as a fallback or from a stale snapshot. + const sourceStatus = (sourceState) => { + const feedState = layerFeedState(sourceState.stats); + return feedState === 'fallback' || feedState === 'stale' + ? feedState + : null; + }; const retainedProvenance = (id, label, sourceState, fallbackSource) => { const retainedSource = sourceState.stats.source || fallbackSource; return { @@ -94,6 +102,7 @@ export function createSubject({ state: layerState, services, parts, source }) { id: 'flights', label: t('Flights'), source: flightsState.stats.source || SOURCE_LABEL.flights, + sourceStatus: sourceStatus(flightsState), provenance: retainedProvenance( 'flights', 'Flights', @@ -113,6 +122,7 @@ export function createSubject({ state: layerState, services, parts, source }) { id: 'military', label: t('Military flights'), source: militaryState.stats.source || SOURCE_LABEL.military, + sourceStatus: sourceStatus(militaryState), provenance: retainedProvenance( 'military', 'Military flights', @@ -132,6 +142,7 @@ export function createSubject({ state: layerState, services, parts, source }) { id: 'ais-live-vessels', label: t('AIS vessels'), source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'], + sourceStatus: sourceStatus(vesselsState), provenance: retainedProvenance( 'ais-live-vessels', 'AIS vessels', @@ -149,6 +160,7 @@ export function createSubject({ state: layerState, services, parts, source }) { source: installationsState.stats.source || SOURCE_LABEL['military-installations'], + sourceStatus: sourceStatus(installationsState), provenance: retainedProvenance( 'military-installations', 'Mapped installations', diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 4ef9b7525..a838a8099 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -409,3 +409,17 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy assert.ok(layer.getStats().error, JSON.stringify(patch)); } }); + +test('vessel stats name the provider the last snapshot reported', async () => { + // HAL-721: Contacts named a fixed AIS provider while this layer showed the + // one its server reported. + const { layer } = setup({ + label: 'Vessels', + async getSnapshot() { + return snapshot([observation('111')], { source: 'Datalastic' }); + }, + }); + assert.equal(layer.getStats().source, 'Vessels'); + await layer.update(); + assert.equal(layer.getStats().source, 'Datalastic'); +}); diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index c848acc2d..48ded012b 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -548,6 +548,8 @@ export function createQueries({ // backing off, say how long until the next attempt instead of leaving // the user to guess whether anything is still happening. retryInSec: aisRetryInSec(), + // The provider the last snapshot named, as flights and military report it. + source: layer.source, }; }, }; diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index 1158c632b..ad73869db 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -1,6 +1,7 @@ /** Contact readouts and nearby-signal presentation for the Cockpit controller. */ import * as Cesium from 'cesium'; import { formatNumber, t, tn } from '../i18n/index.js'; +import { sourceStatusLabel } from './layerNames.js'; import { unitLabel } from './unitLabels.js'; import { bearingBetweenCoordinates, @@ -110,6 +111,8 @@ export function updateContext(info, heading) { if (shown) { shown.count = cohort.count === null ? '?' : String(cohort.count); shown.unknown = cohort.relationship === 'UNKNOWN'; + // The provider that answered, named as the Layers panel names it. + shown.source = sourceStatusLabel(cohort.source, cohort.sourceStatus); } if (cohort.count === null) unknownCount += 1; for (const item of cohort.nearest) nearest.push({ ...item, cohort }); diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs index 47d3719c7..a8d6616d5 100644 --- a/src/ui/cockpitContext.test.mjs +++ b/src/ui/cockpitContext.test.mjs @@ -96,6 +96,13 @@ test('before the controller presents, the card shows the state it starts in', (t ), ['—', '—', '—', '—'], ); + // No provider is named before a snapshot reports one. + assert.deepEqual( + [...document.querySelectorAll('[data-context-cohort] small')].map( + (source) => source.textContent, + ), + ['—', '—', '—', '—'], + ); assert.equal(text('#cockpit-context-nearest-label'), 'NO AVAILABLE EXAMPLE'); assert.equal( $('#cockpit-context-nearest-label').hasAttribute('aria-label'), @@ -159,6 +166,45 @@ test('a snapshot fills the card and keeps classes other code sets', async (t) => assert.equal(layouts.length, 1); }); +test('each count names the provider its snapshot reports, with its feed state', async (t) => { + // HAL-721: the counters named fixed providers while the layer and the + // Contacts list named the one that answered. + const reported = snapshot(); + reported.cohorts[0].source = 'adsb.lol'; + reported.cohorts[0].sourceStatus = 'fallback'; + reported.cohorts[1].source = 'Plane Finder'; + reported.cohorts.push( + { + id: 'ais-live-vessels', + label: 'AIS vessels', + source: 'Datalastic', + sourceStatus: 'stale', + count: 3, + relationship: 'OBSERVED', + nearest: [], + }, + { + id: 'military-installations', + label: 'Mapped installations', + source: 'OpenStreetMap', + sourceStatus: null, + count: 0, + relationship: 'MAPPED', + nearest: [], + }, + ); + const { $, owner } = fixture(t, reported); + updateContext.call(owner, INFO, 0); + await nextTick(); + const source = (id) => $(`[data-context-cohort="${id}"] small`); + assert.equal(source('flights').textContent, 'adsb.lol · FALLBACK'); + assert.equal(source('military').textContent, 'Plane Finder'); + assert.equal(source('ais-live-vessels').textContent, 'Datalastic · STALE'); + assert.equal(source('military-installations').textContent, 'OpenStreetMap'); + // The tile ellipsizes; the full name stays readable on hover. + assert.equal(source('flights').title, 'adsb.lol · FALLBACK'); +}); + test('a lost contact holds its readout behind the CONTACT LOST cue', async (t) => { const { $, text, owner, layouts } = fixture(t, snapshot()); updateContext.call(owner, INFO, 0); diff --git a/src/ui/cockpitContextState.js b/src/ui/cockpitContextState.js index dafffc11f..3c2db3c5a 100644 --- a/src/ui/cockpitContextState.js +++ b/src/ui/cockpitContextState.js @@ -25,7 +25,10 @@ export function createContextState() { canPrevious: true, canNext: true, cohorts: Object.fromEntries( - COCKPIT_CONTEXT_COHORTS.map((id) => [id, { count: '—', unknown: false }]), + COCKPIT_CONTEXT_COHORTS.map((id) => [ + id, + { count: '—', unknown: false, source: '—' }, + ]), ), nearest: { label: t('NO AVAILABLE EXAMPLE'), ariaLabel: null }, distance: { text: '—', ariaLabel: null }, diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index 2c918ad95..519b3e207 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -137,3 +137,17 @@ export function sourceLabel(source) { ? source.split(' · ').map(translateKnown).join(' · ') : translateKnown(source); } + +/** + * A provider name with the feed state it answered in, so a fallback or stale + * snapshot does not read as the live primary feed. + * @param {string} source English source name. + * @param {'fallback'|'stale'|null} status Feed state from `layerFeedState`. + * @returns {string} + */ +export function sourceStatusLabel(source, status) { + const name = sourceLabel(source); + if (status === 'fallback') return `${name} · ${t('FALLBACK')}`; + if (status === 'stale') return `${name} · ${t('STALE')}`; + return name; +} diff --git a/src/ui/layerNames.test.mjs b/src/ui/layerNames.test.mjs index df15c79bf..e1815e477 100644 --- a/src/ui/layerNames.test.mjs +++ b/src/ui/layerNames.test.mjs @@ -7,6 +7,7 @@ import { LAYER_NAME_KEYS, PANEL_LABEL_KEYS, sourceLabel, + sourceStatusLabel, } from './layerNames.js'; // Layers whose module name is a proper name and stays as it is in every @@ -51,3 +52,19 @@ for (const locale of ['es', 'ru']) { } }); } + +test('a provider name carries the fallback or stale state it answered in', () => { + assert.equal(sourceStatusLabel('Plane Finder', null), 'Plane Finder'); + assert.equal(sourceStatusLabel('adsb.lol', 'fallback'), 'adsb.lol · FALLBACK'); + assert.equal(sourceStatusLabel('OpenSky Network', 'stale'), 'OpenSky Network · STALE'); + setLocale('ru'); + try { + assert.equal( + sourceStatusLabel('adsb.lol', 'fallback'), + `adsb.lol · ${t('FALLBACK')}`, + ); + assert.notEqual(t('FALLBACK'), 'FALLBACK'); + } finally { + setLocale('en'); + } +}); diff --git a/src/ui/views/panels/CockpitContext.vue b/src/ui/views/panels/CockpitContext.vue index ed6df8362..2374deb3e 100644 --- a/src/ui/views/panels/CockpitContext.vue +++ b/src/ui/views/panels/CockpitContext.vue @@ -49,10 +49,10 @@ const weatherLabel = computed(() => {{ t('CONTEXT ONLY') }}
-
{{ t('FLT') }}{{ c.cohorts.flights.count }}OpenSky
-
{{ t('MIL') }}{{ c.cohorts.military.count }}adsb.lol
-
AIS{{ c.cohorts['ais-live-vessels'].count }}AISStream
-
{{ t('SITE') }}{{ c.cohorts['military-installations'].count }}OpenStreetMap
+
{{ t('FLT') }}{{ c.cohorts.flights.count }}{{ c.cohorts.flights.source }}
+
{{ t('MIL') }}{{ c.cohorts.military.count }}{{ c.cohorts.military.source }}
+
AIS{{ c.cohorts['ais-live-vessels'].count }}{{ c.cohorts['ais-live-vessels'].source }}
+
{{ t('SITE') }}{{ c.cohorts['military-installations'].count }}{{ c.cohorts['military-installations'].source }}
From 490c8d1769cc96fd9c93c4daff7986028e9a02e7 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 16:27:25 +0400 Subject: [PATCH 2/4] Add the changelog entry for #1521 --- changelog.d/1521-provider-labels.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1521-provider-labels.md diff --git a/changelog.d/1521-provider-labels.md b/changelog.d/1521-provider-labels.md new file mode 100644 index 000000000..d921d9d7b --- /dev/null +++ b/changelog.d/1521-provider-labels.md @@ -0,0 +1,4 @@ +- Name the provider that answered in the Cockpit Contact counts and the + Contacts list, as the Layers panel does, marked `FALLBACK` or `STALE` when + it is. The counts no longer read OpenSky while flights come from the adsb.lol + fallback, and vessels no longer always read AISStream (German, @gekh, #1521). From 99831ea19b2ff5b9fff9d40e26a11422e277bb99 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 16:34:39 +0400 Subject: [PATCH 3/4] Keep STALE on Contacts while a poll is in flight A poll that returned the same stale snapshot dropped STALE from the Contacts cohort until the next answer, because the in-flight loading flag outranked stale and the source revision ignored the provider name. A source that has answered now reads its status without the loading flag; one that has never answered (AIS first connect) still reads as loading, so it shows no STALE before any snapshot exists. --- src/layers/awareness/queries.js | 1 + .../awareness/staleAfterLoading.test.mjs | 159 ++++++++++++++++++ src/layers/awareness/subject.js | 8 +- src/layers/vessels/ownership.test.mjs | 2 - src/ui/cockpitContext.test.mjs | 2 - 5 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 src/layers/awareness/staleAfterLoading.test.mjs diff --git a/src/layers/awareness/queries.js b/src/layers/awareness/queries.js index 593c85ebf..0c5bd78f3 100644 --- a/src/layers/awareness/queries.js +++ b/src/layers/awareness/queries.js @@ -91,6 +91,7 @@ export function createQueries({ state: layerState, services, parts, source }) { stats.count ?? null, stats.status || null, stats.error || null, + stats.source || null, ]; }) .map((parts) => parts.join(':')) diff --git a/src/layers/awareness/staleAfterLoading.test.mjs b/src/layers/awareness/staleAfterLoading.test.mjs new file mode 100644 index 000000000..7128f4958 --- /dev/null +++ b/src/layers/awareness/staleAfterLoading.test.mjs @@ -0,0 +1,159 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createQueries } from './queries.js'; +import { createSubject } from './subject.js'; +import { createModel } from './model.js'; +import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js'; +import { layerFeedState } from '../../data/feedState.js'; +import aisLiveVesselsLayer, { + _setVesselStateForTest, + _beginAisSessionForTest, + _applyAisFeedSnapshotForTest, + _setAisRuntimeForTest, +} from '../../data/aisLiveVessels.js'; + +test('a poll that returns the same stale AIS snapshot restores STALE on Contacts cohorts', () => { + globalThis.document ??= { body: null }; + const services = { + installations: { getNearby: () => [], getStats: () => ({}) }, + flights: { getNearby: () => [] }, + military: { getNearby: () => [] }, + vessels: { getNearby: () => [] }, + }; + const layerState = { + enabled: true, + passive: true, + sourceRevision: '', + cohortPages: new Map(), + subject: { + id: 'site-1', + layerId: 'military-installations', + label: 'Site', + position: Cesium.Cartesian3.fromDegrees(0, 0, 0), + }, + }; + let states; + const parts = { + navigation: { + summarizeAwarenessCohortForNavigation: (items, source) => + summarizeAwarenessCohort(items, source), + isFlightLayer: (id) => id === 'flights' || id === 'military', + }, + rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} }, + panel: { renderResults() {} }, + }; + const realQueries = createQueries({ state: layerState, services, parts }); + parts.queries = { ...realQueries, collectSourceStates: () => states }; + parts.model = createModel({ state: layerState, services, parts }); + const subject = createSubject({ state: layerState, services, parts }); + const flights = (lastUpdate) => ({ + available: true, enabled: true, stale: false, + stats: { source: 'OpenSky Network', count: 4, lastUpdate }, + }); + const vessels = (loading) => ({ + available: true, enabled: true, stale: true, + stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1000, loading }, + }); + const rest = { + military: { available: true, enabled: true, stale: false, stats: { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 1 } }, + 'military-installations': { available: true, enabled: true, stale: false, stats: { coverage: { kind: 'subject', radiusM: 100000 } } }, + }; + const ais = () => layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels'); + + // 1. Stale AIS cache, nothing loading. + states = { flights: flights(1), 'ais-live-vessels': vessels(false), ...rest }; + subject.refreshSelectedSubject(true); + assert.equal(ais().sourceStatus, 'stale'); + + // 2. AIS re-poll in flight while the flights poll lands (revision changes). + states = { flights: flights(2), 'ais-live-vessels': vessels(true), ...rest }; + subject.refreshSelectedSubject(); + assert.equal(ais().sourceStatus, 'stale', 'a poll in flight keeps the snapshot stale'); + + // 3. AIS poll returns the same stale snapshot; only loading flips back. + states = { flights: flights(2), 'ais-live-vessels': vessels(false), ...rest }; + subject.refreshSelectedSubject(); + subject.refreshSelectedSubject(); + assert.equal(ais().sourceStatus, 'stale', 'Layers reads STALE again; Contacts/Cockpit must too'); +}); + +function fakeRuntime() { + return { + now: () => 1000, + setTimeout: () => ({}), + clearTimeout() {}, + }; +} + +test('first AIS connect (no snapshot yet) carries no STALE on the Contacts cohort', () => { + globalThis.document ??= { body: null }; + _setAisRuntimeForTest(fakeRuntime()); + _setVesselStateForTest({ viewer: {}, records: [] }); + try { + _beginAisSessionForTest(); + // What server/providers/vessels/ais-live.js answers while its socket connects. + _applyAisFeedSnapshotForTest({}, { + rows: [], source: 'AISStream', status: 'connecting', refreshing: true, + }); + const vesselStats = aisLiveVesselsLayer.getStats(); + assert.equal(vesselStats.loading, true); + assert.equal(vesselStats.stale, true); + assert.equal(vesselStats.lastUpdate, null); + assert.equal(layerFeedState(vesselStats), 'loading', 'Layers chip reads LOADING'); + + const flightsStats = { source: 'OpenSky Network', count: 2, lastUpdate: 5 }; + const militaryStats = { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 5 }; + const installStats = { coverage: { kind: 'subject', radiusM: 100000 } }; + const statsById = { + flights: flightsStats, + military: militaryStats, + 'ais-live-vessels': vesselStats, + 'military-installations': installStats, + }; + const services = { + installations: { getNearby: () => [], getStats: () => installStats }, + flights: { getNearby: () => [] }, + military: { getNearby: () => [] }, + vessels: aisLiveVesselsLayer, + }; + const layerState = { + enabled: true, + passive: true, + sourceRevision: '', + cohortPages: new Map(), + dataManager: { + isEnabled: () => true, + getLayerLifecycleState: () => ({ enabled: true, lifecycleState: 'enabled' }), + layers: new Map(Object.entries(statsById).map(([id, s]) => [id, { module: { getStats: () => s } }])), + }, + subject: { + id: 'site-1', + layerId: 'military-installations', + label: 'Site', + position: Cesium.Cartesian3.fromDegrees(0, 0, 0), + }, + }; + const parts = { + navigation: { + summarizeAwarenessCohortForNavigation: (items, source) => summarizeAwarenessCohort(items, source), + isFlightLayer: (id) => id === 'flights' || id === 'military', + }, + rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} }, + panel: { renderResults() {} }, + }; + parts.queries = createQueries({ state: layerState, services, parts }); + parts.model = createModel({ state: layerState, services, parts }); + const subject = createSubject({ state: layerState, services, parts }); + + const vesselsSource = parts.queries.collectSourceStates()['ais-live-vessels']; + assert.equal(vesselsSource.available, false, 'never-answered source is unavailable'); + + subject.refreshSelectedSubject(true); + const ais = layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels'); + assert.equal(ais.sourceStatus, null, 'no snapshot yet: no STALE label, Layers says LOADING'); + } finally { + _setVesselStateForTest({ enabled: false }); + _setAisRuntimeForTest(); + } +}); diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index a555d332e..32123d248 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -54,8 +54,14 @@ export function createSubject({ state: layerState, services, parts, source }) { const installationsState = sourceStates['military-installations']; const evaluatedAt = Date.now(); // Whether the provider answered as a fallback or from a stale snapshot. + // A poll in flight does not change that, and the source revision ignores it. + // Before the first answer there is no snapshot, so loading stays. const sourceStatus = (sourceState) => { - const feedState = layerFeedState(sourceState.stats); + const feedState = layerFeedState( + sourceState.available + ? { ...sourceState.stats, loading: false } + : sourceState.stats, + ); return feedState === 'fallback' || feedState === 'stale' ? feedState : null; diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index a838a8099..2404434a0 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -411,8 +411,6 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy }); test('vessel stats name the provider the last snapshot reported', async () => { - // HAL-721: Contacts named a fixed AIS provider while this layer showed the - // one its server reported. const { layer } = setup({ label: 'Vessels', async getSnapshot() { diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs index a8d6616d5..c2f20abcf 100644 --- a/src/ui/cockpitContext.test.mjs +++ b/src/ui/cockpitContext.test.mjs @@ -167,8 +167,6 @@ test('a snapshot fills the card and keeps classes other code sets', async (t) => }); test('each count names the provider its snapshot reports, with its feed state', async (t) => { - // HAL-721: the counters named fixed providers while the layer and the - // Contacts list named the one that answered. const reported = snapshot(); reported.cohorts[0].source = 'adsb.lol'; reported.cohorts[0].sourceStatus = 'fallback'; From 55e6c0017c79215220421c6a727742ec8005839d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 18:15:16 +0400 Subject: [PATCH 4/4] Tighten the provider-label comments and docs --- changelog.d/1521-provider-labels.md | 8 ++++---- docs/CURRENT-STATE.md | 2 +- src/layers/awareness/subject.js | 3 +-- src/layers/vessels/queries.js | 2 +- src/ui/cockpitContext.js | 1 - 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/changelog.d/1521-provider-labels.md b/changelog.d/1521-provider-labels.md index d921d9d7b..887d6a775 100644 --- a/changelog.d/1521-provider-labels.md +++ b/changelog.d/1521-provider-labels.md @@ -1,4 +1,4 @@ -- Name the provider that answered in the Cockpit Contact counts and the - Contacts list, as the Layers panel does, marked `FALLBACK` or `STALE` when - it is. The counts no longer read OpenSky while flights come from the adsb.lol - fallback, and vessels no longer always read AISStream (German, @gekh, #1521). +- Cockpit Contact counts and the Contacts list name the provider that + answered, marked `FALLBACK` or `STALE` as in the Layers panel. Flights from + the adsb.lol fallback no longer read OpenSky, and vessels no longer always + read AISStream (German, @gekh, #1521). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e845b1472..c6abbf74a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5112,7 +5112,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. -- Contacts rows and the Cockpit Contact counts name each cohort's provider as its layer row does: the source the last snapshot reported, marked `FALLBACK` or `STALE` in the same cases. A flight snapshot answered by the adsb.lol regional fallback reads `adsb.lol · FALLBACK` everywhere, never `OpenSky`. +- Contacts rows and the Cockpit Contact counts name each cohort's provider: the source of the last snapshot, marked `FALLBACK` or `STALE` as its layer row would be. A poll in flight keeps the mark, where the layer row shows `LOADING`. A flight snapshot from the adsb.lol regional fallback reads `adsb.lol · FALLBACK`, not `OpenSky`. - The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until its last finger or button lifts, even outside the canvas, unless the window loses focus or the page is hidden first; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 32123d248..60f622e29 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -53,8 +53,7 @@ export function createSubject({ state: layerState, services, parts, source }) { const vesselsState = sourceStates['ais-live-vessels']; const installationsState = sourceStates['military-installations']; const evaluatedAt = Date.now(); - // Whether the provider answered as a fallback or from a stale snapshot. - // A poll in flight does not change that, and the source revision ignores it. + // A poll in flight keeps FALLBACK or STALE: the revision key ignores loading. // Before the first answer there is no snapshot, so loading stays. const sourceStatus = (sourceState) => { const feedState = layerFeedState( diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index 48ded012b..d4d9ef796 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -548,7 +548,7 @@ export function createQueries({ // backing off, say how long until the next attempt instead of leaving // the user to guess whether anything is still happening. retryInSec: aisRetryInSec(), - // The provider the last snapshot named, as flights and military report it. + // Same field as flights and military, so Contacts names the provider. source: layer.source, }; }, diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index ad73869db..6dc209977 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -111,7 +111,6 @@ export function updateContext(info, heading) { if (shown) { shown.count = cohort.count === null ? '?' : String(cohort.count); shown.unknown = cohort.relationship === 'UNKNOWN'; - // The provider that answered, named as the Layers panel names it. shown.source = sourceStatusLabel(cohort.source, cohort.sourceStatus); } if (cohort.count === null) unknownCount += 1;