mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge pull request #626 from bilawalsidhu/fix/vessel-partial-feed-status
fix: distinguish partial vessel snapshots from stale data
This commit is contained in:
13 files changed
+155
-3
No files matched your search
@@ -1,5 +1,8 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
- Distinguish PARTIAL vessel snapshots from STALE data in the layer panel, with
|
||||||
|
accepted-record counts and unchanged retention, freshness and outage safeguards.
|
||||||
|
|
||||||
- Keep Nepal provider media inside the Pinokio compatibility boundary: use
|
- Keep Nepal provider media inside the Pinokio compatibility boundary: use
|
||||||
source-linked fallback cards instead of automatic embeds or hidden preloads
|
source-linked fallback cards instead of automatic embeds or hidden preloads
|
||||||
that launch an external browser. Ordinary browsers retain embedded playback.
|
that launch an external browser. Ordinary browsers retain embedded playback.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# God's Eye View Current State
|
# God's Eye View Current State
|
||||||
|
|
||||||
|
Vessel snapshot completeness is separate from freshness. A current snapshot with
|
||||||
|
rejected or duplicate records shows PARTIAL with accepted/received counts; stale
|
||||||
|
or unknown freshness and transport failures retain their warnings. Partial
|
||||||
|
snapshots still retain missing contacts within the existing age and row limits.
|
||||||
|
A complete successful snapshot clears the partial indicator.
|
||||||
|
|
||||||
Director imports now open a non-mutating preview before Apply. EDIT DETAILS
|
Director imports now open a non-mutating preview before Apply. EDIT DETAILS
|
||||||
authors validated anchor, camera, pack and interaction drafts; SHARE SCENE exports
|
authors validated anchor, camera, pack and interaction drafts; SHARE SCENE exports
|
||||||
a selected scene or a bounded bundle of explicitly chosen pack files. Bundled
|
a selected scene or a bounded bundle of explicitly chosen pack files. Bundled
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
|
|||||||
/**
|
/**
|
||||||
* Normalize heterogeneous layer stats into one honest control-chip state.
|
* Normalize heterogeneous layer stats into one honest control-chip state.
|
||||||
* @param {object|null} stats Layer getStats() result.
|
* @param {object|null} stats Layer getStats() result.
|
||||||
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state.
|
* @returns {'nominal'|'loading'|'degraded'|'stale'|'partial'|'fallback'|'unavailable'} Feed state.
|
||||||
*/
|
*/
|
||||||
export function layerFeedState(stats = {}) {
|
export function layerFeedState(stats = {}) {
|
||||||
const state = stats || {};
|
const state = stats || {};
|
||||||
@@ -50,5 +50,6 @@ export function layerFeedState(stats = {}) {
|
|||||||
state.available === false
|
state.available === false
|
||||||
)
|
)
|
||||||
return 'degraded';
|
return 'degraded';
|
||||||
|
if (state.partial === true) return 'partial';
|
||||||
return 'nominal';
|
return 'nominal';
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,7 @@ export function createEvidence({
|
|||||||
state.feed.loaded = true;
|
state.feed.loaded = true;
|
||||||
state.feed.error = null;
|
state.feed.error = null;
|
||||||
state.feed.stale = false;
|
state.feed.stale = false;
|
||||||
|
state.feed.partial = false;
|
||||||
state.feed.lastUpdate = Date.now();
|
state.feed.lastUpdate = Date.now();
|
||||||
state.feed.transportStatus = 'synthetic';
|
state.feed.transportStatus = 'synthetic';
|
||||||
state.feed.lastMessageAt = null;
|
state.feed.lastMessageAt = null;
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export function createIngestion({
|
|||||||
feed.lastMessageAt = snapshot.lastMessageAt;
|
feed.lastMessageAt = snapshot.lastMessageAt;
|
||||||
feed.rawRowCount = snapshot.rawRowCount;
|
feed.rawRowCount = snapshot.rawRowCount;
|
||||||
feed.acceptedRowCount = snapshot.acceptedRowCount;
|
feed.acceptedRowCount = snapshot.acceptedRowCount;
|
||||||
|
feed.partial = payload?.complete === false;
|
||||||
|
|
||||||
if (snapshot.acceptedRowCount === 0) {
|
if (snapshot.acceptedRowCount === 0) {
|
||||||
feed.count = readCount();
|
feed.count = readCount();
|
||||||
@@ -133,7 +134,8 @@ export function createIngestion({
|
|||||||
feed.count = readCount();
|
feed.count = readCount();
|
||||||
feed.stale =
|
feed.stale =
|
||||||
Boolean(payload?.refreshing) ||
|
Boolean(payload?.refreshing) ||
|
||||||
payload?.complete === false ||
|
payload?.freshness === 'stale' ||
|
||||||
|
snapshot.transportStatus === 'stale' ||
|
||||||
payload?.freshness === 'unknown';
|
payload?.freshness === 'unknown';
|
||||||
feed.newestPositionAt = payload?.newestPositionAt || null;
|
feed.newestPositionAt = payload?.newestPositionAt || null;
|
||||||
// Not unconditionally null: a degraded feed keeps its reason even though the
|
// Not unconditionally null: a degraded feed keeps its reason even though the
|
||||||
@@ -190,6 +192,7 @@ export function createVesselFeed() {
|
|||||||
loading: false,
|
loading: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
stale: false,
|
stale: false,
|
||||||
|
partial: false,
|
||||||
error: null,
|
error: null,
|
||||||
loadingLabel: '',
|
loadingLabel: '',
|
||||||
lastUpdate: null,
|
lastUpdate: null,
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export function createLifecycle({
|
|||||||
state.feed.loading = false;
|
state.feed.loading = false;
|
||||||
state.feed.loaded = false;
|
state.feed.loaded = false;
|
||||||
state.feed.stale = false;
|
state.feed.stale = false;
|
||||||
|
state.feed.partial = false;
|
||||||
state.feed.error = null;
|
state.feed.error = null;
|
||||||
state.feed.loadingLabel = '';
|
state.feed.loadingLabel = '';
|
||||||
state.feed.lastUpdate = null;
|
state.feed.lastUpdate = null;
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ test('partial observations retain missing contacts only until their receipt dead
|
|||||||
current = snapshot([observation('111')], { complete: false, rawRowCount: 2 });
|
current = snapshot([observation('111')], { complete: false, rawRowCount: 2 });
|
||||||
await layer.update();
|
await layer.update();
|
||||||
assert.equal(layer.hasContact('222'), true);
|
assert.equal(layer.hasContact('222'), true);
|
||||||
assert.equal(layer.getStats().stale, true);
|
assert.equal(layer.getStats().stale, false);
|
||||||
|
assert.equal(layer.getStats().partial, true);
|
||||||
assert.equal(layer.getStats().rawRowCount, 2);
|
assert.equal(layer.getStats().rawRowCount, 2);
|
||||||
assert.equal(layer.getStats().acceptedRowCount, 1);
|
assert.equal(layer.getStats().acceptedRowCount, 1);
|
||||||
assert.equal(layer.getStats().lastUpdate, 500);
|
assert.equal(layer.getStats().lastUpdate, 500);
|
||||||
@@ -301,3 +302,62 @@ test('refresh keeps pick identity and renderer geometry separate, and eviction r
|
|||||||
assert.equal(primitives.has(primitive), false);
|
assert.equal(primitives.has(primitive), false);
|
||||||
assert.equal(layer.findByQuery('111'), null);
|
assert.equal(layer.findByQuery('111'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('partial vessel snapshots preserve freshness and recover after a complete update', async (t) => {
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
globalThis.document = { getElementById: () => null };
|
||||||
|
t.after(() => {
|
||||||
|
if (originalDocument === undefined) delete globalThis.document;
|
||||||
|
else globalThis.document = originalDocument;
|
||||||
|
});
|
||||||
|
const { vesselSnapshot } = await import('../../sources/live/vessels.js');
|
||||||
|
const { layerFeedState } = await import('../../data/feedState.js');
|
||||||
|
const valid = {
|
||||||
|
mmsi: '123456789',
|
||||||
|
lat: 51.93,
|
||||||
|
lon: 4.05,
|
||||||
|
last_position_epoch: 1,
|
||||||
|
};
|
||||||
|
let current = vesselSnapshot({
|
||||||
|
rows: [valid, { ...valid }, { mmsi: '987654321', lat: 200, lon: 4 }],
|
||||||
|
status: 'live',
|
||||||
|
});
|
||||||
|
const { layer } = setup({ getSnapshot: async () => current });
|
||||||
|
await layer.update();
|
||||||
|
assert.equal(layer.getStats().partial, true);
|
||||||
|
assert.equal(layer.getStats().stale, false);
|
||||||
|
assert.equal(layer.getStats().rawRowCount, 3);
|
||||||
|
assert.equal(layer.getStats().acceptedRowCount, 1);
|
||||||
|
assert.equal(layerFeedState(layer.getStats()), 'partial');
|
||||||
|
assert.equal(layer.hasContact('123456789'), true);
|
||||||
|
|
||||||
|
for (const extra of [
|
||||||
|
{ stale: true },
|
||||||
|
{ freshness: 'stale' },
|
||||||
|
{ freshness: 'unknown', observedAtMs: null },
|
||||||
|
{ transportStatus: 'stale' },
|
||||||
|
]) {
|
||||||
|
const original = current;
|
||||||
|
current = { ...original, ...extra };
|
||||||
|
await layer.update();
|
||||||
|
assert.equal(layer.getStats().partial, true);
|
||||||
|
assert.equal(layerFeedState(layer.getStats()), 'stale');
|
||||||
|
current = original;
|
||||||
|
}
|
||||||
|
current = { ...current, transportStatus: 'reconnecting' };
|
||||||
|
await layer.update();
|
||||||
|
assert.equal(layerFeedState(layer.getStats()), 'degraded');
|
||||||
|
assert.match(layer.getStats().error, /reconnecting/);
|
||||||
|
|
||||||
|
current = vesselSnapshot({ rows: [valid], status: 'live' });
|
||||||
|
await layer.update();
|
||||||
|
assert.equal(layer.getStats().partial, false);
|
||||||
|
assert.equal(layer.getStats().stale, false);
|
||||||
|
assert.equal(layer.getStats().error, null);
|
||||||
|
assert.equal(layerFeedState(layer.getStats()), 'nominal');
|
||||||
|
current = { ...current, complete: false };
|
||||||
|
await layer.update();
|
||||||
|
assert.equal(layer.getStats().partial, true);
|
||||||
|
layer.destroy();
|
||||||
|
assert.equal(layer.getStats().partial, false);
|
||||||
|
});
|
||||||
@@ -508,6 +508,7 @@ export function createQueries({
|
|||||||
: state.feed.loadingLabel,
|
: state.feed.loadingLabel,
|
||||||
error: state.feed.error,
|
error: state.feed.error,
|
||||||
stale: state.feed.stale,
|
stale: state.feed.stale,
|
||||||
|
partial: state.feed.partial,
|
||||||
status:
|
status:
|
||||||
state.feed.firstConnectPhase === 'unavailable'
|
state.feed.firstConnectPhase === 'unavailable'
|
||||||
? 'unavailable'
|
? 'unavailable'
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function createTesting({
|
|||||||
state.feed.loaded = options.loaded === true;
|
state.feed.loaded = options.loaded === true;
|
||||||
state.feed.loading = options.loading === true;
|
state.feed.loading = options.loading === true;
|
||||||
state.feed.stale = options.stale === true;
|
state.feed.stale = options.stale === true;
|
||||||
|
state.feed.partial = options.partial === true;
|
||||||
state.feed.error = options.error || null;
|
state.feed.error = options.error || null;
|
||||||
state.feed.lastUpdate = options.lastUpdate ?? null;
|
state.feed.lastUpdate = options.lastUpdate ?? null;
|
||||||
state.records.all = records;
|
state.records.all = records;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const FEED_STATE_LABELS = Object.freeze({
|
|||||||
loading: 'LOADING',
|
loading: 'LOADING',
|
||||||
degraded: 'DEGRADED',
|
degraded: 'DEGRADED',
|
||||||
stale: 'STALE',
|
stale: 'STALE',
|
||||||
|
partial: 'PARTIAL',
|
||||||
fallback: 'FALLBACK',
|
fallback: 'FALLBACK',
|
||||||
unavailable: 'UNAVAILABLE',
|
unavailable: 'UNAVAILABLE',
|
||||||
});
|
});
|
||||||
@@ -505,6 +506,17 @@ export class LayerPanel {
|
|||||||
: stats.coverage || ago;
|
: stats.coverage || ago;
|
||||||
return `${stateLabel} · ${source} · ${detail}`;
|
return `${stateLabel} · ${source} · ${detail}`;
|
||||||
}
|
}
|
||||||
|
if (feedState === 'partial') {
|
||||||
|
const { acceptedRowCount, rawRowCount } = stats;
|
||||||
|
const detail =
|
||||||
|
Number.isInteger(acceptedRowCount) &&
|
||||||
|
Number.isInteger(rawRowCount) &&
|
||||||
|
acceptedRowCount >= 0 &&
|
||||||
|
rawRowCount > acceptedRowCount
|
||||||
|
? `${acceptedRowCount} of ${rawRowCount} records accepted`
|
||||||
|
: 'incomplete snapshot';
|
||||||
|
return `${stateLabel} · ${source} · ${detail} · ${ago}`;
|
||||||
|
}
|
||||||
if (feedState === 'stale') {
|
if (feedState === 'stale') {
|
||||||
const retry =
|
const retry =
|
||||||
typeof stats.retryInSec === 'number' && stats.retryInSec > 0
|
typeof stats.retryInSec === 'number' && stats.retryInSec > 0
|
||||||
|
|||||||
@@ -29,3 +29,63 @@ test('panel presentation places Transit between Street Traffic and Bike Share in
|
|||||||
);
|
);
|
||||||
assert.equal(order.filter(({ id }) => id === 'transit').length, 1);
|
assert.equal(order.filter(({ id }) => id === 'transit').length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('partial feed controls distinguish incomplete records from stale data and outages', async () => {
|
||||||
|
const { LayerPanel, layerFeedState } = await import('./layerPanel.js');
|
||||||
|
const classes = new Map();
|
||||||
|
const attrs = new Map();
|
||||||
|
const button = {
|
||||||
|
classList: { toggle: (key, value) => classes.set(key, value) },
|
||||||
|
dataset: {},
|
||||||
|
setAttribute: (key, value) => attrs.set(key, value),
|
||||||
|
};
|
||||||
|
const layer = {
|
||||||
|
id: 'ais-live-vessels',
|
||||||
|
name: 'Live Vessels',
|
||||||
|
source: 'AISStream',
|
||||||
|
enabled: true,
|
||||||
|
stats: {
|
||||||
|
partial: true,
|
||||||
|
stale: false,
|
||||||
|
count: 2,
|
||||||
|
acceptedRowCount: 2,
|
||||||
|
rawRowCount: 3,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const panel = LayerPanel.prototype;
|
||||||
|
panel._syncToggleButton(button, layer);
|
||||||
|
assert.equal(button.textContent, 'PARTIAL');
|
||||||
|
assert.equal(button.dataset.feedState, 'partial');
|
||||||
|
assert.equal(classes.get('feed-partial'), true);
|
||||||
|
assert.equal(classes.get('feed-stale'), false);
|
||||||
|
assert.match(attrs.get('aria-label'), /PARTIAL/);
|
||||||
|
assert.match(
|
||||||
|
panel._buildMetaText(layer),
|
||||||
|
/^PARTIAL · AISStream · 2 of 3 records accepted · /,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
panel._buildMetaText({
|
||||||
|
...layer,
|
||||||
|
stats: { ...layer.stats, rawRowCount: 2 },
|
||||||
|
}),
|
||||||
|
/incomplete snapshot/,
|
||||||
|
);
|
||||||
|
assert.equal(layerFeedState({ ...layer.stats, stale: true }), 'stale');
|
||||||
|
assert.equal(
|
||||||
|
layerFeedState({ ...layer.stats, error: 'Connection lost' }),
|
||||||
|
'degraded',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
layerFeedState({ ...layer.stats, status: 'unavailable' }),
|
||||||
|
'unavailable',
|
||||||
|
);
|
||||||
|
assert.equal(layerFeedState({ ...layer.stats, loading: true }), 'loading');
|
||||||
|
layer.stats = { ...layer.stats, partial: false };
|
||||||
|
panel._syncToggleButton(button, layer);
|
||||||
|
assert.equal(button.textContent, 'ON');
|
||||||
|
assert.equal(classes.get('feed-partial'), false);
|
||||||
|
layer.enabled = false;
|
||||||
|
panel._syncToggleButton(button, layer);
|
||||||
|
assert.equal(button.textContent, 'OFF');
|
||||||
|
});
|
||||||
@@ -1298,6 +1298,7 @@
|
|||||||
.feed-loading,
|
.feed-loading,
|
||||||
.feed-degraded,
|
.feed-degraded,
|
||||||
.feed-stale,
|
.feed-stale,
|
||||||
|
.feed-partial,
|
||||||
.feed-fallback,
|
.feed-fallback,
|
||||||
.feed-unavailable
|
.feed-unavailable
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -666,6 +666,7 @@
|
|||||||
.data-toggle-btn.feed-loading,
|
.data-toggle-btn.feed-loading,
|
||||||
.data-toggle-btn.feed-degraded,
|
.data-toggle-btn.feed-degraded,
|
||||||
.data-toggle-btn.feed-stale,
|
.data-toggle-btn.feed-stale,
|
||||||
|
.data-toggle-btn.feed-partial,
|
||||||
.data-toggle-btn.feed-fallback,
|
.data-toggle-btn.feed-fallback,
|
||||||
.data-toggle-btn.feed-unavailable {
|
.data-toggle-btn.feed-unavailable {
|
||||||
min-width: 82px;
|
min-width: 82px;
|
||||||
@@ -685,6 +686,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.data-toggle-btn.active.feed-stale,
|
.data-toggle-btn.active.feed-stale,
|
||||||
|
.data-toggle-btn.active.feed-partial,
|
||||||
.data-toggle-btn.active.feed-fallback {
|
.data-toggle-btn.active.feed-fallback {
|
||||||
color: #ffd27a;
|
color: #ffd27a;
|
||||||
border-color: rgba(255, 190, 82, 0.75);
|
border-color: rgba(255, 190, 82, 0.75);
|
||||||
|
|||||||
Reference in new issue
Block a user