Roll back a Space Missions entry whose mission index fails to load

The launches layer swallowed every fetch error in its update, so the
manager treated a 429 or timeout on /api/launches as a successful start.
Context then committed the isolating mode with no missions: the other
layers stayed off, unrelated enables were refused, the camera stayed at
the full-globe view and the roster kept reading "loading".

Until the first index loads, the update now reports the failure. The
manager fails the enable closed and both entry routes roll back to the
pre-entry layers. The camera pose captured at entry is restored when the
mode is left before any index loaded, and the roster shows a failed
state instead of loading. A valid empty index still enters, and a
refresh failure after a loaded index keeps its records.
This commit is contained in:
German Khokhlov committed 2026-11-05 12:43:49 +04:00
1 parent 7d8b80db0a
commit 5c04b12164
18 files changed
+424 -6

No files matched your search

+1
View File
@@ -301,6 +301,7 @@
"AVAILABLE MISSIONS": "المهام المتاحة",
"SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها",
"LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا",
"30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح",
"RADIO": "الراديو",
"ENABLE": "تفعيل",
"Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة",
+1
View File
@@ -760,6 +760,7 @@
"AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN",
"SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN",
"LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN",
"30-DAY MISSION INDEX UNAVAILABLE": "30-TAGE-MISSIONSINDEX NICHT VERFÜGBAR",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL",
"Internet radio and local SDR": "Internetradio und lokales SDR",
"STATION TAG": "KATEGORIE",
+1
View File
@@ -192,6 +192,7 @@
"AVAILABLE MISSIONS": "MISIONES DISPONIBLES",
"SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR",
"LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS",
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISIONES DE 30 DÍAS NO DISPONIBLE",
"RADIO": "RADIO",
"ENABLE": "ACTIVAR",
"Previous filtered radio station": "Emisora de radio filtrada anterior",
+1
View File
@@ -2799,6 +2799,7 @@
"AVAILABLE MISSIONS": "MISSIONS DISPONIBLES",
"SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER",
"LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS",
"30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE",
"Internet radio and local SDR": "Radio Internet et SDR local",
"STATION TAG": "TAG DE STATION",
+1
View File
@@ -2799,6 +2799,7 @@
"AVAILABLE MISSIONS": "MISSIONI DISPONIBILI",
"SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE",
"LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI",
"30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA",
"Internet radio and local SDR": "Radio internet e SDR locale",
"STATION TAG": "TAG STAZIONE",
+1
View File
@@ -2657,6 +2657,7 @@
"AVAILABLE MISSIONS": "利用可能なミッション",
"SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示",
"LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中",
"30-DAY MISSION INDEX UNAVAILABLE": "30日間のミッション一覧を取得できません",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択",
"Internet radio and local SDR": "インターネットラジオとローカルSDR",
"STATION TAG": "局タグ",
+1
View File
@@ -2801,6 +2801,7 @@
"AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS",
"SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR",
"LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS",
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISSÕES DE 30 DIAS INDISPONÍVEL",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA",
"Internet radio and local SDR": "Rádio pela internet e SDR local",
"STATION TAG": "TAG DA ESTAÇÃO",
+1
View File
@@ -722,6 +722,7 @@
"Contact Context actions": "Действия контекста контакта",
"Available Space Missions": "Доступные космические миссии",
"LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ",
"30-DAY MISSION INDEX UNAVAILABLE": "ИНДЕКС МИССИЙ ЗА 30 ДНЕЙ НЕДОСТУПЕН",
"Radio playback": "Воспроизведение радио",
"Radio volume": "Громкость радио",
"STATION TAG": "ТЕГ СТАНЦИИ",
+1
View File
@@ -2869,6 +2869,7 @@
"AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ",
"SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ",
"LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ",
"30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР",
"Internet radio and local SDR": "Інтернет-радіо та локальний SDR",
"STATION TAG": "ТЕГ СТАНЦІЇ",
+1
View File
@@ -2556,6 +2556,7 @@
"AVAILABLE MISSIONS": "可用任务",
"SELECT A MISSION TO INSPECT": "选择一项任务以查看",
"LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引",
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任务索引不可用",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择",
"Internet radio and local SDR": "网络电台与本地 SDR",
"STATION TAG": "电台标签",
+1
View File
@@ -2544,6 +2544,7 @@
"AVAILABLE MISSIONS": "可用任務",
"SELECT A MISSION TO INSPECT": "選擇一項任務以查看",
"LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引",
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇",
"Internet radio and local SDR": "網絡電臺與本地 SDR",
"STATION TAG": "電臺標籤",
+348
View File
@@ -0,0 +1,348 @@
// A Space Missions entry whose mission index never loads must not commit the
// mode. The real manager, Context coordinator and launches ingestion run
// against a local /api/launches that answers 429, so the test follows the
// same failure the browser sees: isolation clears the other layers, the first
// update fails, and the entry rolls back to the layers and view it replaced.
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import * as Cesium from 'cesium';
import { DataLayerManager } from '../../data/manager.js';
import { ContextControls } from '../../ui/contextControls.js';
import { createLaunchSource } from './source.js';
import { createState } from './state.js';
import { createModel } from './model.js';
import { createPolicyHelpers } from './policyHelpers.js';
import { createIngestion } from './ingestion.js';
import { createPanel } from './panel.js';
import { createControls } from './controls.js';
import { createLifecycle } from './lifecycle.js';
const noop = () => {};
const launchRecord = {
id: 'fixture-launch',
name: 'Fixture mission',
net: new Date(Date.now() - 3600000).toISOString(),
status: { name: 'Launch Successful' },
pad: { name: 'Fixture pad', latitude: 28.6, longitude: -80.6 },
rocket: { payloads: [] },
};
function plainLayer(id, name, params = {}) {
let current = { ...params };
return {
id,
name,
icon: '',
source: 'test',
updateInterval: -1,
init: noop,
enable: noop,
disable: noop,
update: noop,
getParams: () => ({ ...current }),
setParams(next) {
current = { ...current, ...next };
return true;
},
};
}
function fakeCamera() {
const camera = {
position: new Cesium.Cartesian3(1, 2, 3),
heading: 0.1,
pitch: -0.5,
roll: 0,
flights: 0,
cancelled: 0,
get positionWC() {
return camera.position;
},
flyTo({ destination }) {
camera.flights += 1;
camera.position = Cesium.Cartesian3.clone(destination);
camera.heading = 0;
camera.pitch = -Math.PI / 2;
},
cancelFlight() {
camera.cancelled += 1;
},
setView({ destination, orientation }) {
camera.position = Cesium.Cartesian3.clone(destination);
camera.heading = orientation.heading;
camera.pitch = orientation.pitch;
camera.roll = orientation.roll;
},
};
return camera;
}
async function fixture(t) {
const upstream = { status: 429, results: [launchRecord] };
const server = http.createServer((req, res) => {
if (req.url === '/api/celestrak/active') {
res.end('');
return;
}
if (upstream.status !== 200) {
res.writeHead(upstream.status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Launch Library 2 unavailable' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ results: upstream.results }));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const source = createLaunchSource({
fetchImpl: (url, options) => fetch(new URL(url, origin), options),
});
const services = {
overlays: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
};
const state = createState({ services });
const camera = fakeCamera();
const canvas = Object.assign(new EventTarget(), {
disableRootEvents: true,
onwheel: null,
style: {},
clientWidth: 1200,
clientHeight: 800,
});
const viewer = {
camera,
dataSources: new Cesium.DataSourceCollection(),
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
};
// Scene presentation is inert; source, ingestion, lifecycle, panel views
// and the satellites dependency run unchanged.
const parts = {
overlays: {
shortMissionLabel: (name) => name,
createReplayVehicleOverlay: noop,
clearMissionOverlaySources: noop,
hideReplayVehicleOverlay: noop,
destroyReplayVehicleOverlay: noop,
},
replay: { stopMissionReplay: noop, presentReplay: () => null },
selection: {
focusFullGlobe: (target) =>
target.camera.flyTo({ destination: new Cesium.Cartesian3(9, 9, 9) }),
setSelectedMission: noop,
focusMission: noop,
stopMissionZoomAnchor: noop,
entityLaunchId: () => null,
},
orbitRendering: {
removeMissionOrbitPrimitives: noop,
syncMissionOrbitPrimitiveVisibility: noop,
},
launchPad: {
initLaunchPadZonePrimitive: noop,
hideLaunchPadZone: noop,
destroyLaunchPadZonePrimitive: noop,
},
rendering: {
updateMissionFrame: noop,
addLaunchEntity(record) {
state._dataSource.entities.add({
id: record.id,
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
});
},
},
};
const context = { state, services, parts, source };
parts.policyHelpers = createPolicyHelpers(context);
parts.model = createModel(context);
parts.panel = createPanel(context);
parts.ingestion = createIngestion(context);
parts.controls = createControls(context);
parts.lifecycle = createLifecycle(context);
const missions = Object.assign(
{},
parts.controls.methods,
parts.lifecycle.methods,
parts.ingestion.methods,
);
const rosters = [];
parts.panel.subscribePanel((view) => rosters.push(view.roster));
const manager = new DataLayerManager(viewer);
manager.register(plainLayer('earthquakes', 'Earthquakes (24h)'));
manager.register(plainLayer('satellites', 'Satellites'));
manager.register(plainLayer('wind', 'Wind'));
manager.register(missions);
missions.attachDataManager(manager);
const toasts = [];
const controls = new ContextControls({
elements: {},
installations: {},
actions: {
getCockpit: () => null,
claimVisualAuthority: noop,
showToast: (message) => toasts.push(message),
setClearBusy: noop,
syncDetection: noop,
scheduleLayout: noop,
refreshRadio: noop,
setPanelCollapsed: noop,
},
});
controls.connect(manager);
t.after(async () => {
controls.stop();
controls.disconnect();
for (const id of ['rocket-launches', 'satellites', 'earthquakes', 'wind'])
await manager.setEnabled(id, false);
await missions.destroy(viewer);
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
});
const enabledIds = () => [...manager.getEnabledLayerIds()].sort();
const view = () => ({
x: camera.position.x,
y: camera.position.y,
z: camera.position.z,
heading: camera.heading,
pitch: camera.pitch,
});
const settle = async () => {
await controls._waitForContextLayerSettlement();
for (const id of ['rocket-launches', 'satellites', 'earthquakes'])
await manager.waitForLayerSettled(id);
await controls._waitForContextLayerSettlement();
};
return {
upstream,
manager,
controls,
missions,
state,
camera,
rosters,
toasts,
enabledIds,
view,
settle,
};
}
test('a direct Space Missions entry that gets HTTP 429 rolls back instead of isolating', async (t) => {
const f = await fixture(t);
assert.equal(
await f.manager.setEnabled('earthquakes', true, { origin: 'user' }),
true,
);
const before = f.view();
const entered = await f.manager.setEnabled('rocket-launches', true, {
origin: 'user',
});
await f.settle();
assert.equal(entered, false, 'a failed mission index fails the enable');
assert.deepEqual(f.enabledIds(), ['earthquakes']);
assert.equal(f.controls.getContextModeState().mode, null);
assert.equal(f.controls.getContextModeState().active, false);
assert.equal(f.missions.getStats().error, 'HTTP 429');
assert.deepEqual(f.view(), before, 'the pre-entry view comes back');
assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled');
// Nothing is left isolating the workspace.
assert.equal(
await f.manager.setEnabled('wind', true, { origin: 'user' }),
true,
'an unrelated layer is not blocked after the failed entry',
);
});
test('a Context-panel Space Missions entry that gets HTTP 429 restores the previous layers', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
const before = f.view();
const result = await f.controls.setContextMode('space-missions', {
claimVisualAuthority: false,
});
await f.settle();
assert.equal(result.ok, false);
assert.deepEqual(f.enabledIds(), ['earthquakes']);
assert.equal(f.controls.getContextModeState().mode, null);
assert.deepEqual(f.view(), before);
});
test('the roster reports a failed mission index instead of loading forever', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
const roster = f.rosters.at(-1);
assert.ok(roster, 'a failed load publishes a roster view');
assert.deepEqual(roster.entries, []);
assert.equal(roster.empty, '30-DAY MISSION INDEX UNAVAILABLE');
// A new attempt starts from the loading state, then shows the index.
f.upstream.status = 200;
const loadingFrom = f.rosters.length;
assert.equal(
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
true,
);
await f.settle();
assert.equal(f.rosters.slice(loadingFrom).includes(null), true);
assert.equal(f.rosters.at(-1).entries.length, 1);
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
});
test('a valid empty mission index is a successful entry', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
f.upstream.results = [];
assert.equal(
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
true,
);
await f.settle();
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
assert.equal(
f.rosters.at(-1).empty,
'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW',
);
assert.ok(f.camera.flights > 0, 'a successful entry frames the globe');
});
test('a refresh failure after a loaded index keeps the mode and its records', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
const framed = f.view();
f.upstream.status = 429;
await f.missions.update();
await f.settle();
assert.equal(f.manager.isEnabled('rocket-launches'), true);
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
assert.equal(f.missions.getStats().count, 1);
assert.equal(f.missions.getStats().error, 'HTTP 429');
assert.equal(f.rosters.at(-1).entries.length, 1);
// Leaving a mode that loaded keeps today's camera behaviour.
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
await f.settle();
assert.deepEqual(f.view(), framed);
});
+15 -4
View File
@@ -50,8 +50,9 @@ export function createIngestion({
layerState._postTleRetryCount++;
layerState._retryTimer = setTimeout(() => {
layerState._retryTimer = null;
// A failed retry is already recorded in the layer stats.
if (layerState._enabled && token === layerState._lifecycleToken)
requestMissionUpdate();
requestMissionUpdate().catch(() => {});
}, POST_TLE_RETRY_DELAY_MS);
}
@@ -201,6 +202,10 @@ export function createIngestion({
if (layerState._renderedTleText === layerState._activeTleText)
clearPostTleRetry();
layerState._count = launches.length;
// The roster reads these to tell a loaded empty index from loading.
layerState._lastUpdate = Date.now();
layerState._lastError = null;
layerState._stale = stale === true;
parts.panel.renderMissionRoster();
if (
!layerState._selectedLaunchId ||
@@ -219,13 +224,19 @@ export function createIngestion({
}
layerState._focusAfterActiveLookup = false;
parts.panel.renderMissionPanel();
layerState._lastUpdate = Date.now();
layerState._lastError = null;
layerState._stale = stale === true;
layerState._entryCameraView = null;
} catch (error) {
if (layerState._enabled && token === layerState._lifecycleToken) {
layerState._lastError = error.message;
console.warn('[Data:RocketLaunches] Fetch error:', error);
// Without one usable index there is no mode to enter: report the
// failure so the manager fails the enable closed and Context rolls
// back. A refresh after a loaded index keeps its records instead.
if (layerState._lastUpdate === null) {
parts.panel.renderMissionRoster();
throw error;
}
layerState._entryCameraView = null;
}
}
}
+34
View File
@@ -111,6 +111,28 @@ export function createLaunchHover({
};
}
/** The camera pose to give back, in world coordinates. */
function captureCameraView(viewer) {
const camera = viewer?.camera;
if (!camera?.positionWC) return null;
return {
destination: Cesium.Cartesian3.clone(camera.positionWC),
orientation: {
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
},
};
}
/** Stop the full-globe flight and return to a captured pose. */
function restoreCameraView(viewer, view) {
const camera = viewer?.camera;
if (!camera) return;
camera.cancelFlight?.();
camera.setView(view);
}
export function createLifecycle({
state: layerState,
services,
@@ -200,6 +222,14 @@ export function createLifecycle({
layerState._updateDirty = false;
if (layerState._dataSource) layerState._dataSource.show = true;
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
if (layerState._lastUpdate === null) {
// A new attempt at an index that never loaded starts from loading.
layerState._lastError = null;
parts.panel.renderMissionRoster();
}
// Kept until the mission index loads, so an entry that never does
// gives the view back.
layerState._entryCameraView = captureCameraView(layerState._viewer);
parts.selection.focusFullGlobe(layerState._viewer);
if (layerState._selectedLaunchId)
parts.selection.setSelectedMission(
@@ -214,6 +244,9 @@ export function createLifecycle({
hover?.reset();
layerState._sourceController.abort();
layerState._enabled = false;
const entryView = layerState._entryCameraView;
layerState._entryCameraView = null;
if (entryView) restoreCameraView(layerState._viewer, entryView);
releaseContinuousRender('rocket-launches');
layerState._lifecycleToken++;
layerState._updateDirty = false;
@@ -239,6 +272,7 @@ export function createLifecycle({
layerState._sourceController.abort();
releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix)
layerState._enabled = false;
layerState._entryCameraView = null;
layerState._lifecycleToken++;
layerState._updateDirty = false;
await parts.ingestion.restoreSatelliteDependency();
+12 -1
View File
@@ -263,8 +263,19 @@ export function createPanel({ state: layerState, services, parts, source }) {
publishPanel();
}
/** The roster entries as the list shows them. */
/**
* The roster entries as the list shows them; null while the first index
* is still loading, and an empty failed roster once that load failed.
*/
function presentRoster() {
if (layerState._lastUpdate === null && !layerState._launches.length) {
if (!layerState._lastError) return null;
return {
count: '—',
entries: [],
empty: t('30-DAY MISSION INDEX UNAVAILABLE'),
};
}
const entries = parts.policyHelpers
.missionRosterEntries(layerState._launches)
.map(({ launch, index }) => {
+2
View File
@@ -19,6 +19,8 @@ export function createState({ services }) {
state._lastError = null;
state._entryCameraView = null;
state._stale = false;
state._orbitMatches = 0;