Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/build-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 22 additions & 6 deletions server/worldmonitor/military/v1/get-aircraft-details-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,27 @@ 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,
): Promise<GetAircraftDetailsBatchResponse> {
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<string, AircraftDetails> = {};
const toFetch: string[] = [];

Expand All @@ -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<CachedAircraftDetails>(
`${SINGLE_KEY}:${icao24}`,
SINGLE_TTL,
async () => {
Expand All @@ -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<string, unknown>;
const details = mapWingbitsDetails(icao24, data);
Expand Down
24 changes: 21 additions & 3 deletions server/worldmonitor/military/v1/get-aircraft-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ declare const process: { env: Record<string, string | undefined> };

import type {
ServerContext,
AircraftDetails,
GetAircraftDetailsRequest,
GetAircraftDetailsResponse,
} from '../../../../src/generated/server/worldmonitor/military/v1/service_server';
Expand All @@ -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,
Expand All @@ -24,12 +30,16 @@ export async function getAircraftDetails(
const cacheKey = `${REDIS_CACHE_KEY}:${icao24}`;

try {
const result = await cachedFetchJson<GetAircraftDetailsResponse>(cacheKey, REDIS_CACHE_TTL, async () => {
const result = await cachedFetchJson<CachedAircraftDetails>(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<string, unknown>;
Expand All @@ -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 };
}
Expand Down
28 changes: 16 additions & 12 deletions server/worldmonitor/military/v1/get-theater-posture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,18 +184,22 @@ function calculatePostures(flights: RawFlight[]): TheaterPosture[] {
// ========================================================================

async function fetchTheaterPostureFresh(): Promise<GetTheaterPostureResponse> {
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);
Expand Down
27 changes: 23 additions & 4 deletions src/components/StrategicPosturePanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<void> {
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();
Expand Down Expand Up @@ -118,6 +125,8 @@ export class StrategicPosturePanel extends Panel {
}

private async fetchAndRender(): Promise<void> {
if (!this.isPanelVisible()) return;

try {
// Fetch aircraft data from server
this.showLoadingStage('aircraft');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions src/services/military-flights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,8 @@ async function enrichFlightsWithWingbits(flights: MilitaryFlight[]): Promise<Mil
return flights;
}

// Get hex codes for all flights
const hexCodes = flights.map(f => 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);
Expand Down
43 changes: 38 additions & 5 deletions src/services/wingbits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -206,7 +226,7 @@ export async function getAircraftDetails(icao24: string): Promise<WingbitsAircra

if (!resp.details) {
// Cache negative result
setLocalCache(key, { icao24: key } as WingbitsAircraftDetails);
setLocalCache(key, createNegativeDetailsEntry(key));
return null;
}

Expand All @@ -223,10 +243,10 @@ export async function getAircraftDetailsBatch(icao24List: string[]): Promise<Map
if (!isFeatureAvailable('wingbitsEnrichment')) return new Map();
const results = new Map<string, WingbitsAircraftDetails>();
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
Expand All @@ -250,11 +270,24 @@ export async function getAircraftDetailsBatch(icao24List: string[]): Promise<Map
}

// Process results
const returnedKeys = new Set<string>();
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));
}
}

Expand Down