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
120 changes: 65 additions & 55 deletions WebATM/server/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import json
import os
import re
import sqlite3
import time
from pathlib import Path

Expand Down Expand Up @@ -129,6 +131,32 @@ def by_name(entry):
return sorted(folders, key=by_name) + sorted(files, key=by_name)


def _split_incomplete_utf8(data):
"""Split off an incomplete trailing UTF-8 sequence from ``data``.

A log poll can catch the writer mid-character; the held-back bytes are
re-read whole on the next poll instead of being decoded as replacement
characters twice.

Args:
data (bytes): Chunk read up to end-of-file.

Returns:
tuple[bytes, bytes]: ``(complete, held_back)``; ``held_back`` is empty
unless ``data`` ends inside a multi-byte character.
"""
for i in range(1, min(3, len(data)) + 1):
byte = data[-i]
if byte >= 0xC0: # lead byte of a 2-4 byte sequence
seq_len = 2 if byte < 0xE0 else 3 if byte < 0xF0 else 4
if seq_len > i:
return data[:-i], data[-i:]
break
if byte < 0x80: # ASCII, nothing pending
break
return data, b""


def get_webpack_assets():
"""Read the webpack manifest and build script tags in load order.

Expand All @@ -142,13 +170,11 @@ def get_webpack_assets():
list[str]: HTML ``<script>`` tags for the webpack bundles.
"""
try:
# Go up one level from server/ to WebATM/ to find static/
manifest_path = (
Path(__file__).parent.parent / "static" / "dist" / "manifest.json"
)

if not manifest_path.exists():
# Fallback to single bundle.js if manifest doesn't exist
return ['<script src="/static/dist/bundle.js"></script>']

with open(manifest_path) as f:
Expand All @@ -171,7 +197,6 @@ def get_webpack_assets():

except Exception as e:
logger.info(f"Error reading webpack manifest: {e}")
# Fallback to single bundle.js
return ['<script src="/static/dist/bundle.js"></script>']


Expand Down Expand Up @@ -271,14 +296,12 @@ def update_server_config():
set_bluesky_proxy,
)

# Every (re)connect gets a completely fresh proxy: recreating the
# ZMQ client is the reliable way to shed any half-dead connection
# state. Only the Socket.IO wiring carries over. The old proxy is
# replaced in place (never deleted) so concurrent requests always
# find a usable current_app.bluesky_proxy. The swap-and-connect
# Every (re)connect gets a fresh proxy — recreating the ZMQ client
# is the reliable way to shed half-dead connection state; only the
# Socket.IO wiring carries over. The old proxy is replaced in place
# so concurrent requests always find a usable proxy, and the swap
# runs under connect_lock so the integrated auto-start (or another
# concurrent connect request) can never revive the proxy this
# request is tearing down.
# connect request) can't revive the proxy being torn down.
with connect_lock:
old_proxy = getattr(current_app, "bluesky_proxy", None)
if old_proxy is not None:
Expand Down Expand Up @@ -516,15 +539,11 @@ def search_navdata():
# (this also strips any FTS syntax the user might type) and turn
# each into a prefix term so "heath" matches "Heathrow" and "kse"
# matches "KSEA". Multiple tokens are implicitly AND-ed.
import re

tokens = re.findall(r"[A-Za-z0-9]+", query)
if not tokens:
return jsonify({"success": True, "results": []})
match_expr = " ".join(f"{t}*" for t in tokens)

import sqlite3

# Open read-only so a concurrent rebuild can't be corrupted.
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
try:
Expand Down Expand Up @@ -609,38 +628,20 @@ def status_check():
sections, or 503 with the error on failure.
"""
try:
hostname = getattr(current_app.bluesky_proxy, "server_ip", None)
listening, _ = probe_bluesky_ports(hostname)
port_11000_listening = 11000 in listening
port_11001_listening = 11001 in listening
bluesky_running = bool(listening)

# Additional check: if we have a proxy connection, see if it's receiving data
proxy_running = False
proxy_connected = False
has_active_nodes = False
if hasattr(current_app, "bluesky_proxy"):
proxy_running = getattr(current_app.bluesky_proxy, "running", False)
proxy_connected = getattr(
current_app.bluesky_proxy, "is_connected", False
)
tracked_nodes = getattr(current_app.bluesky_proxy, "tracked_nodes", [])
has_active_nodes = len(tracked_nodes) > 0

# Get session information from session manager
session_info = session_manager.get_session_info()
proxy = getattr(current_app, "bluesky_proxy", None)
listening, _ = probe_bluesky_ports(getattr(proxy, "server_ip", None))

response_data = {
"status": "healthy",
"bluesky_server": {
"ports_accessible": bluesky_running,
"port_11000": port_11000_listening,
"port_11001": port_11001_listening,
"proxy_running": proxy_running,
"proxy_connected": proxy_connected,
"has_active_nodes": has_active_nodes,
"ports_accessible": bool(listening),
"port_11000": 11000 in listening,
"port_11001": 11001 in listening,
"proxy_running": getattr(proxy, "running", False),
"proxy_connected": getattr(proxy, "is_connected", False),
"has_active_nodes": len(getattr(proxy, "tracked_nodes", [])) > 0,
},
"session_info": session_info,
"session_info": session_manager.get_session_info(),
"timestamp": time.time(),
}

Expand Down Expand Up @@ -1000,23 +1001,24 @@ def download_output_file(filepath):
def get_output_file_content(filepath):
"""Read output-file content (GET /api/bluesky/output/content/<filepath>).

Supports log streaming: with ``offset`` > 0 the file is read
incrementally from that byte offset to the end; with offset 0 the
Supports log streaming with plain byte offsets: with ``offset`` > 0
the file is read from that byte offset to the end; with offset 0 the
last ``lines`` lines are tailed for the initial load. If the file
shrank below the offset (truncated/rewritten between polls), the
stream restarts with a tail load instead of silently skipping the
new content. Query parameters:

- ``offset``: byte offset to read from (0 = tail mode).
- ``lines``: maximum lines for the initial tail load (default 200).
- ``lines``: maximum lines for the initial tail load (default 200);
0 or negative skips history and streams from the current end.

Args:
filepath (str): Path of the file relative to the output
directory; validated against traversal.

Returns:
JSON with ``content``, the new ``offset``, ``total_size`` and
``filename``, or a 400/403/404/500 error payload.
JSON with ``content``, the new byte ``offset``, ``total_size``
and ``filename``, or a 400/403/404/500 error payload.
"""
try:
resolved_path, error = _validate_output_path(filepath)
Expand All @@ -1028,23 +1030,31 @@ def get_output_file_content(filepath):
file_size = resolved_path.stat().st_size

# A file smaller than the poller's offset was truncated or
# rewritten (e.g. a re-run scenario logging to the same name).
# The offset points into the old contents, so restart with a
# tail load instead of pinning the stream at end-of-file, which
# would silently skip everything the new file already holds.
# rewritten (e.g. a re-run scenario logging to the same name);
# restart with a tail load instead of pinning at end-of-file.
if offset > file_size:
offset = 0

with open(resolved_path, errors="replace") as f:
# Read in binary so offsets are real byte positions. Text-mode
# tell() returns opaque cookies whose newline translation also
# delivered a trailing \r as \n twice when a CRLF log was caught
# mid-line (a spurious blank line in the stream viewer).
with open(resolved_path, "rb") as f:
if offset > 0:
# Incremental read from offset to end.
f.seek(offset)
content = f.read()
else:
data = f.read()
elif max_lines > 0:
# Initial (or post-truncation) load: tail the last N lines.
content = "".join(f.readlines()[-max_lines:])
data = b"".join(f.readlines()[-max_lines:])
else:
f.seek(0, os.SEEK_END)
data = b""
new_offset = f.tell()

data, held_back = _split_incomplete_utf8(data)
new_offset -= len(held_back)
content = data.decode("utf-8", errors="replace").replace("\r\n", "\n")

return jsonify(
{
"success": True,
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/core/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,10 +422,12 @@ export class App {
// Check if we just sent an explicit POS command for this aircraft
const isExplicitPosResponse = this.aircraftInteractionManager?.wasLastExplicitPosFor(data.acid) ?? false;

// If receiving ROUTEDATA for an aircraft that is not currently selected,
// treat it as an implicit selection ONLY if it's not a response to our explicit POS
// Unsolicited ROUTEDATA (a route broadcast enabled outside this
// client) becomes an implicit selection, but only while nothing
// is selected: it must never steal an active selection (e.g. a
// still-streaming broadcast of a previously selected aircraft).
const currentSelection = this.stateManager.getState().selectedAircraft;
if (data.acid && data.acid !== currentSelection && !isExplicitPosResponse) {
if (data.acid && !currentSelection && !isExplicitPosResponse) {
logger.info('App', '🛰️ Unsolicited ROUTEDATA received for', data.acid, '- treating as implicit selection');
this.stateManager.setSelectedAircraft(data.acid);
}
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/data/DataProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ describe('DataProcessor compact map labels', () => {
expect(DataProcessor.formatAltitudeLabel(304.8, 'ft')).toBe('1000ft');
expect(DataProcessor.formatAltitudeLabel(3000.4, 'm')).toBe('3000m');
});

it('altitudeUnitLabel returns the display suffix for each unit', () => {
expect(DataProcessor.altitudeUnitLabel('ft')).toBe('ft');
expect(DataProcessor.altitudeUnitLabel('m')).toBe('m');
expect(DataProcessor.altitudeUnitLabel('km')).toBe('km');
expect(DataProcessor.altitudeUnitLabel('fl')).toBe('FL');
});
});

describe('DataProcessor.convertVerticalSpeed', () => {
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/data/DataProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,23 @@ export class DataProcessor {
}
}

/**
* Altitude unit suffix for labels (e.g. "ft", "FL").
*/
static altitudeUnitLabel(unit: AltitudeUnit): string {
switch (unit) {
case 'm':
return 'm';
case 'km':
return 'km';
case 'fl':
return 'FL';
case 'ft':
default:
return 'ft';
}
}

/**
* Unit suffix used in compact map labels (e.g. "250kt").
*/
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/data/aircraftModels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
fetchAircraftModels,
isKnownModelSelection,
populateModelSelect,
resetAircraftModelsCache,
} from './aircraftModels';
Expand Down Expand Up @@ -92,3 +93,19 @@ describe('populateModelSelect', () => {
expect(select.value).toBe(AUTO_MODEL_SENTINEL);
});
});

describe('isKnownModelSelection', () => {
it('accepts the Auto sentinel regardless of catalog', () => {
expect(isKnownModelSelection([], AUTO_MODEL_SENTINEL)).toBe(true);
expect(isKnownModelSelection(MODELS, AUTO_MODEL_SENTINEL)).toBe(true);
});

it('accepts a model present in the catalog', () => {
expect(isKnownModelSelection(MODELS, 'B747.glb')).toBe(true);
});

it('rejects a model missing from the catalog', () => {
expect(isKnownModelSelection(MODELS, 'GONE.glb')).toBe(false);
expect(isKnownModelSelection([], 'A320.glb')).toBe(false);
});
});
16 changes: 13 additions & 3 deletions frontend/src/data/aircraftModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ export function resetAircraftModelsCache(): void {
catalogPromise = null;
}

/**
* Whether `selected` is a valid model choice against the given catalog:
* either the Auto sentinel or a model file present in the catalog.
*/
export function isKnownModelSelection(
models: AircraftModelOption[],
selected: string
): boolean {
return selected === AUTO_MODEL_SENTINEL
|| models.some(m => m.filename === selected);
}

/**
* Rebuild a model <select> as the "Auto" sentinel plus every known
* model, then select `selected` — falling back to Auto when it is
Expand All @@ -88,7 +100,5 @@ export function populateModelSelect(
select.appendChild(option);
}

const hasSelected = selected === AUTO_MODEL_SENTINEL
|| models.some(m => m.filename === selected);
select.value = hasSelected ? selected : AUTO_MODEL_SENTINEL;
select.value = isKnownModelSelection(models, selected) ? selected : AUTO_MODEL_SENTINEL;
}
21 changes: 19 additions & 2 deletions frontend/src/ui/map/BaseDrawingManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function createFakeMap() {
return {
handlers,
canvas,
doubleClickZoom: { enable: vi.fn(), disable: vi.fn() },
on: vi.fn((event: string, handler: (e: unknown) => void) => {
(handlers[event] ??= []).push(handler);
}),
Expand Down Expand Up @@ -69,8 +70,8 @@ describe('BaseDrawingManager', () => {
let snapper: { snap: ReturnType<typeof vi.fn>; highlight: ReturnType<typeof vi.fn>; clearHighlight: ReturnType<typeof vi.fn> };
let manager: TestDrawingManager;

const clickEvent = (lat: number, lng: number) =>
({ lngLat: { lat, lng }, preventDefault: vi.fn() }) as unknown as MapMouseEvent;
const clickEvent = (lat: number, lng: number, detail = 1) =>
({ lngLat: { lat, lng }, originalEvent: { detail }, preventDefault: vi.fn() }) as unknown as MapMouseEvent;

function createManager(
finishOnEnter = false,
Expand Down Expand Up @@ -118,6 +119,22 @@ describe('BaseDrawingManager', () => {
expect(manager.points).toEqual([{ lat: 50, lng: 5 }]);
});

it('ignores the second click of a double-click', () => {
manager.start();
map.fire('click', clickEvent(52, 4));
map.fire('click', clickEvent(52, 4, 2));
expect(manager.points).toEqual([{ lat: 52, lng: 4 }]);
});

it('suspends double-click zoom while drawing and restores it after', () => {
manager.start();
expect(map.doubleClickZoom.disable).toHaveBeenCalledTimes(1);
expect(map.doubleClickZoom.enable).not.toHaveBeenCalled();

manager.stop();
expect(map.doubleClickZoom.enable).toHaveBeenCalledTimes(1);
});

it('mousemove highlights navaids and reports the cursor position', () => {
manager.start();
map.fire('mousemove', clickEvent(51, 3));
Expand Down
Loading