From 14cf893fc1a01fd5bde92e82a58233f8b4b6714b Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Thu, 26 Feb 2026 16:55:24 +0400 Subject: [PATCH 1/2] ci(linux): add AppImage smoke test to desktop build Launch the built AppImage under Xvfb after the Linux build to catch startup crashes and render failures automatically. Uploads a screenshot artifact for visual inspection. --- .github/workflows/build-desktop.yml | 39 +++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index cbb8140a7..998e989d9 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -293,6 +293,45 @@ jobs: fi echo "Verified signed app bundle and embedded Node runtime: $NODE_PATH" + - name: Smoke-test AppImage (Linux) + if: contains(matrix.platform, 'ubuntu') + shell: bash + run: | + sudo apt-get install -y xvfb imagemagick + APPIMAGE=$(find src-tauri/target/release/bundle/appimage -name '*.AppImage' | head -1) + if [ -z "$APPIMAGE" ]; then + echo "::error::No AppImage found after build" + exit 1 + fi + chmod +x "$APPIMAGE" + # Start Xvfb with known display number + Xvfb :99 -screen 0 1440x900x24 & + export DISPLAY=:99 + sleep 2 + # Launch AppImage under virtual framebuffer + "$APPIMAGE" --no-sandbox & + APP_PID=$! + # Wait for app to render + sleep 15 + # Screenshot the virtual display + import -window root screenshot.png || true + # Verify app is still running (didn't crash) + if kill -0 $APP_PID 2>/dev/null; then + echo "✅ AppImage launched successfully" + kill $APP_PID || true + else + echo "❌ AppImage crashed during startup" + exit 1 + fi + + - name: Upload smoke test screenshot + if: contains(matrix.platform, 'ubuntu') + uses: actions/upload-artifact@v4 + with: + name: linux-smoke-test-screenshot + path: screenshot.png + if-no-files-found: warn + - name: Cleanup Apple signing materials if: always() && contains(matrix.platform, 'macos') shell: bash From b7986549eae909057e8012ca9c58df71a6eb1cf2 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Thu, 26 Feb 2026 17:46:51 +0400 Subject: [PATCH 2/2] Optimize Wingbits API usage and panel polling --- .../military/v1/get-aircraft-details-batch.ts | 28 +++++++++--- .../military/v1/get-aircraft-details.ts | 24 +++++++++-- .../military/v1/get-theater-posture.ts | 28 ++++++------ src/components/StrategicPosturePanel.ts | 27 ++++++++++-- src/services/military-flights.ts | 4 +- src/services/wingbits.ts | 43 ++++++++++++++++--- 6 files changed, 122 insertions(+), 32 deletions(-) diff --git a/server/worldmonitor/military/v1/get-aircraft-details-batch.ts b/server/worldmonitor/military/v1/get-aircraft-details-batch.ts index 6c23c938a..0a2c54131 100644 --- a/server/worldmonitor/military/v1/get-aircraft-details-batch.ts +++ b/server/worldmonitor/military/v1/get-aircraft-details-batch.ts @@ -11,6 +11,11 @@ import { mapWingbitsDetails } from './_shared'; import { CHROME_UA } from '../../../_shared/constants'; import { getCachedJsonBatch, cachedFetchJson } from '../../../_shared/redis'; +interface CachedAircraftDetails { + details: AircraftDetails | null; + configured: boolean; +} + export async function getAircraftDetailsBatch( _ctx: ServerContext, req: GetAircraftDetailsBatchRequest, @@ -18,11 +23,15 @@ export async function getAircraftDetailsBatch( const apiKey = process.env.WINGBITS_API_KEY; if (!apiKey) return { results: {}, fetched: 0, requested: 0, configured: false }; - const limitedList = req.icao24s.slice(0, 20).map((id) => id.toLowerCase()); + const normalized = req.icao24s + .map((id) => id.trim().toLowerCase()) + .filter((id) => id.length > 0); + const uniqueSorted = Array.from(new Set(normalized)).sort(); + const limitedList = uniqueSorted.slice(0, 20); // Redis shared cache — batch GET all keys in a single pipeline round-trip const SINGLE_KEY = 'military:aircraft:v1'; - const SINGLE_TTL = 300; + const SINGLE_TTL = 24 * 60 * 60; const results: Record = {}; const toFetch: string[] = []; @@ -31,16 +40,20 @@ export async function getAircraftDetailsBatch( for (let i = 0; i < limitedList.length; i++) { const icao24 = limitedList[i]!; - const cached = cachedMap.get(cacheKeys[i]!) as { details?: AircraftDetails } | null; - if (cached?.details) { - results[icao24] = cached.details; + const cached = cachedMap.get(cacheKeys[i]!); + if (cached && typeof cached === 'object' && 'details' in cached) { + const details = (cached as { details?: AircraftDetails | null }).details; + if (details) { + results[icao24] = details; + } + // details === null means cached negative lookup; skip refetch. } else { toFetch.push(icao24); } } const fetches = toFetch.map(async (icao24) => { - const cacheResult = await cachedFetchJson<{ details: AircraftDetails; configured: boolean }>( + const cacheResult = await cachedFetchJson( `${SINGLE_KEY}:${icao24}`, SINGLE_TTL, async () => { @@ -49,6 +62,9 @@ export async function getAircraftDetailsBatch( headers: { 'x-api-key': apiKey, Accept: 'application/json', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(10_000), }); + if (resp.status === 404) { + return { details: null, configured: true }; + } if (resp.ok) { const data = (await resp.json()) as Record; const details = mapWingbitsDetails(icao24, data); diff --git a/server/worldmonitor/military/v1/get-aircraft-details.ts b/server/worldmonitor/military/v1/get-aircraft-details.ts index 39cb6c06a..bcd57604d 100644 --- a/server/worldmonitor/military/v1/get-aircraft-details.ts +++ b/server/worldmonitor/military/v1/get-aircraft-details.ts @@ -2,6 +2,7 @@ declare const process: { env: Record }; import type { ServerContext, + AircraftDetails, GetAircraftDetailsRequest, GetAircraftDetailsResponse, } from '../../../../src/generated/server/worldmonitor/military/v1/service_server'; @@ -11,7 +12,12 @@ import { CHROME_UA } from '../../../_shared/constants'; import { cachedFetchJson } from '../../../_shared/redis'; const REDIS_CACHE_KEY = 'military:aircraft:v1'; -const REDIS_CACHE_TTL = 300; // 5 min — aircraft details rarely change +const REDIS_CACHE_TTL = 24 * 60 * 60; // 24 hours — aircraft metadata is mostly static + +interface CachedAircraftDetails { + details: AircraftDetails | null; + configured: boolean; +} export async function getAircraftDetails( _ctx: ServerContext, @@ -24,12 +30,16 @@ export async function getAircraftDetails( const cacheKey = `${REDIS_CACHE_KEY}:${icao24}`; try { - const result = await cachedFetchJson(cacheKey, REDIS_CACHE_TTL, async () => { + const result = await cachedFetchJson(cacheKey, REDIS_CACHE_TTL, async () => { const resp = await fetch(`https://customer-api.wingbits.com/v1/flights/details/${icao24}`, { headers: { 'x-api-key': apiKey, Accept: 'application/json', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(10_000), }); + // Cache not-found responses to avoid repeated misses for the same aircraft. + if (resp.status === 404) { + return { details: null, configured: true }; + } if (!resp.ok) return null; const data = (await resp.json()) as Record; @@ -38,7 +48,15 @@ export async function getAircraftDetails( configured: true, }; }); - return result || { details: undefined, configured: true }; + + if (!result || !result.details) { + return { details: undefined, configured: true }; + } + + return { + details: result.details, + configured: true, + }; } catch { return { details: undefined, configured: true }; } diff --git a/server/worldmonitor/military/v1/get-theater-posture.ts b/server/worldmonitor/military/v1/get-theater-posture.ts index ce63ec09a..ea0256a9e 100644 --- a/server/worldmonitor/military/v1/get-theater-posture.ts +++ b/server/worldmonitor/military/v1/get-theater-posture.ts @@ -184,18 +184,22 @@ function calculatePostures(flights: RawFlight[]): TheaterPosture[] { // ======================================================================== async function fetchTheaterPostureFresh(): Promise { - let flights: RawFlight[]; - const [openskyResult, wingbitsResult] = await Promise.allSettled([ - fetchMilitaryFlightsFromOpenSky(), - fetchMilitaryFlightsFromWingbits(), - ]); - - if (openskyResult.status === 'fulfilled' && openskyResult.value.length > 0) { - flights = openskyResult.value; - } else if (wingbitsResult.status === 'fulfilled' && wingbitsResult.value && wingbitsResult.value.length > 0) { - flights = wingbitsResult.value; - } else { - throw new Error('Both OpenSky and Wingbits unavailable'); + let flights: RawFlight[] = []; + + try { + flights = await fetchMilitaryFlightsFromOpenSky(); + } catch { + flights = []; + } + + // Wingbits is a fallback only when OpenSky is unavailable/empty. + if (flights.length === 0) { + const wingbitsFlights = await fetchMilitaryFlightsFromWingbits(); + if (wingbitsFlights && wingbitsFlights.length > 0) { + flights = wingbitsFlights; + } else { + throw new Error('Both OpenSky and Wingbits unavailable'); + } } const theaters = calculatePostures(flights); diff --git a/src/components/StrategicPosturePanel.ts b/src/components/StrategicPosturePanel.ts index 2c726c7de..7a9069cbd 100644 --- a/src/components/StrategicPosturePanel.ts +++ b/src/components/StrategicPosturePanel.ts @@ -28,7 +28,7 @@ export class StrategicPosturePanel extends Panel { private init(): void { this.showLoading(); - this.fetchAndRender(); + void this.fetchAndRender(); this.startAutoRefresh(); // Re-augment with vessels after stream has had time to populate // AIS data accumulates gradually - check at 30s, 60s, 90s, 120s @@ -39,11 +39,18 @@ export class StrategicPosturePanel extends Panel { } private startAutoRefresh(): void { - this.refreshInterval = setInterval(() => this.fetchAndRender(), 5 * 60 * 1000); + this.refreshInterval = setInterval(() => { + if (!this.isPanelVisible()) return; + void this.fetchAndRender(); + }, 5 * 60 * 1000); + } + + private isPanelVisible(): boolean { + return !this.element.classList.contains('hidden'); } private async reaugmentVessels(): Promise { - if (this.postures.length === 0) return; + if (!this.isPanelVisible() || this.postures.length === 0) return; console.log('[StrategicPosturePanel] Re-augmenting with vessels...'); await this.augmentWithVessels(); this.render(); @@ -118,6 +125,8 @@ export class StrategicPosturePanel extends Panel { } private async fetchAndRender(): Promise { + if (!this.isPanelVisible()) return; + try { // Fetch aircraft data from server this.showLoadingStage('aircraft'); @@ -145,7 +154,9 @@ export class StrategicPosturePanel extends Panel { // If we rendered stale localStorage data, re-fetch fresh after a short delay if (this.isStale) { - setTimeout(() => this.fetchAndRender(), 3000); + setTimeout(() => { + void this.fetchAndRender(); + }, 3000); } } catch (error) { if (this.isAbortError(error)) return; @@ -509,6 +520,14 @@ export class StrategicPosturePanel extends Panel { return this.postures; } + public override show(): void { + const wasHidden = this.element.classList.contains('hidden'); + super.show(); + if (wasHidden) { + void this.fetchAndRender(); + } + } + public destroy(): void { if (this.refreshInterval) clearInterval(this.refreshInterval); this.stopLoadingTimer(); diff --git a/src/services/military-flights.ts b/src/services/military-flights.ts index 95f54f476..03ec04b3a 100644 --- a/src/services/military-flights.ts +++ b/src/services/military-flights.ts @@ -348,8 +348,8 @@ async function enrichFlightsWithWingbits(flights: MilitaryFlight[]): Promise f.hexCode.toLowerCase()); + // Use deterministic ordering to improve cache locality across refreshes. + const hexCodes = Array.from(new Set(flights.map((f) => f.hexCode.toLowerCase()))).sort(); // Batch fetch aircraft details const detailsMap = await getAircraftDetailsBatch(hexCodes); diff --git a/src/services/wingbits.ts b/src/services/wingbits.ts index add3de627..2a63c372b 100644 --- a/src/services/wingbits.ts +++ b/src/services/wingbits.ts @@ -162,6 +162,26 @@ function toWingbitsDetails(d: AircraftDetails): WingbitsAircraftDetails { }; } +function createNegativeDetailsEntry(icao24: string): WingbitsAircraftDetails { + return { + icao24, + registration: null, + manufacturerIcao: null, + manufacturerName: null, + model: null, + typecode: null, + serialNumber: null, + icaoAircraftType: null, + operator: null, + operatorCallsign: null, + operatorIcao: null, + owner: null, + built: null, + engines: null, + categoryDescription: null, + }; +} + /** * Check if Wingbits API is configured */ @@ -206,7 +226,7 @@ export async function getAircraftDetails(icao24: string): Promise(); const toFetch: string[] = []; + const requestedKeys = Array.from(new Set(icao24List.map((icao24) => icao24.toLowerCase()))).sort(); // Check local cache first - for (const icao24 of icao24List) { - const key = icao24.toLowerCase(); + for (const key of requestedKeys) { const cached = getFromLocalCache(key); if (cached) { if (cached.registration) { // Only include valid results @@ -250,11 +270,24 @@ export async function getAircraftDetailsBatch(icao24List: string[]): Promise(); for (const [icao24, protoDetails] of Object.entries(resp.results)) { + const key = icao24.toLowerCase(); + returnedKeys.add(key); const details = toWingbitsDetails(protoDetails); - setLocalCache(icao24, details); + setLocalCache(key, details); if (details.registration) { - results.set(icao24, details); + results.set(key, details); + } + } + + // Cache missing lookups as negative entries to avoid repeated retries. + const requestedCount = Number.isFinite(resp.requested) + ? Math.max(0, Math.min(toFetch.length, resp.requested)) + : toFetch.length; + for (const key of toFetch.slice(0, requestedCount)) { + if (!returnedKeys.has(key)) { + setLocalCache(key, createNegativeDetailsEntry(key)); } }