fix: use GPS utility for location, compact dashboard layout

Replace broken app.gps_state lookup with utils.gps.get_current_position()
and return GPS metadata (fix quality, satellites, accuracy). Shrink location
card to single-column with 200px globe, move System Info into row 2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-26 23:51:26 +00:00
parent 0a90010c1f
commit f72b43c6bf
5 changed files with 118 additions and 36 deletions

View File

@@ -436,17 +436,21 @@ def _ensure_collector() -> None:
def _get_observer_location() -> dict[str, Any]:
"""Get observer location from GPS state or config defaults."""
lat, lon, source = None, None, 'none'
gps_meta: dict[str, Any] = {}
# Try GPS state from app module
# Try GPS via utils.gps
with contextlib.suppress(Exception):
import app as app_module
from utils.gps import get_current_position
gps_state = getattr(app_module, 'gps_state', None)
if gps_state and isinstance(gps_state, dict):
g_lat = gps_state.get('lat') or gps_state.get('latitude')
g_lon = gps_state.get('lon') or gps_state.get('longitude')
if g_lat is not None and g_lon is not None:
lat, lon, source = float(g_lat), float(g_lon), 'gps'
pos = get_current_position()
if pos and pos.fix_quality >= 2:
lat, lon, source = pos.latitude, pos.longitude, 'gps'
gps_meta['fix_quality'] = pos.fix_quality
gps_meta['satellites'] = pos.satellites
if pos.epx is not None and pos.epy is not None:
gps_meta['accuracy'] = round(max(pos.epx, pos.epy), 1)
if pos.altitude is not None:
gps_meta['altitude'] = round(pos.altitude, 1)
# Fall back to config defaults
if lat is None:
@@ -456,7 +460,10 @@ def _get_observer_location() -> dict[str, Any]:
if DEFAULT_LATITUDE != 0.0 or DEFAULT_LONGITUDE != 0.0:
lat, lon, source = DEFAULT_LATITUDE, DEFAULT_LONGITUDE, 'config'
return {'lat': lat, 'lon': lon, 'source': source}
result: dict[str, Any] = {'lat': lat, 'lon': lon, 'source': source}
if gps_meta:
result['gps'] = gps_meta
return result
# ---------------------------------------------------------------------------

View File

@@ -282,16 +282,17 @@
color: var(--accent-cyan, #00d4ff);
}
/* Globe container */
/* Globe container — compact vertical layout */
.sys-location-inner {
display: flex;
gap: 16px;
align-items: stretch;
flex-direction: column;
gap: 10px;
align-items: center;
}
.sys-globe-wrap {
width: 300px;
height: 300px;
width: 200px;
height: 200px;
flex-shrink: 0;
background: #000;
border-radius: 8px;
@@ -300,10 +301,42 @@
}
.sys-location-details {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
}
/* GPS status indicator */
.sys-gps-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: var(--text-dim, #8888aa);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sys-gps-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
}
.sys-gps-dot.fix-3d {
background: var(--accent-green, #00ff88);
box-shadow: 0 0 4px rgba(0, 255, 136, 0.4);
}
.sys-gps-dot.fix-2d {
background: var(--accent-yellow, #ffcc00);
box-shadow: 0 0 4px rgba(255, 204, 0, 0.4);
}
.sys-gps-dot.no-fix {
background: var(--text-dim, #555);
}
.sys-location-coords {
@@ -515,13 +548,9 @@
grid-column: 1;
}
.sys-location-inner {
flex-direction: column;
}
.sys-globe-wrap {
width: 100%;
height: 250px;
height: 180px;
}
.sys-process-grid {

View File

@@ -390,14 +390,27 @@ const SystemHealth = (function () {
// Globe container
html += '<div class="sys-globe-wrap" id="sysGlobeContainer"></div>';
// Details column
// Details below globe
html += '<div class="sys-location-details">';
if (locationData && locationData.lat != null) {
html += '<div class="sys-location-coords">' +
locationData.lat.toFixed(4) + '&deg;' + (locationData.lat >= 0 ? 'N' : 'S') + '<br>' +
locationData.lat.toFixed(4) + '&deg;' + (locationData.lat >= 0 ? 'N' : 'S') + ', ' +
locationData.lon.toFixed(4) + '&deg;' + (locationData.lon >= 0 ? 'E' : 'W') + '</div>';
html += '<div class="sys-location-source">Source: ' + escHtml(locationData.source || 'unknown') + '</div>';
// GPS status indicator
if (locationData.source === 'gps' && locationData.gps) {
var gps = locationData.gps;
var fixLabel = gps.fix_quality === 3 ? '3D Fix' : '2D Fix';
var dotCls = gps.fix_quality === 3 ? 'fix-3d' : 'fix-2d';
html += '<div class="sys-gps-status">' +
'<span class="sys-gps-dot ' + dotCls + '"></span> ' + fixLabel;
if (gps.satellites != null) html += ' &middot; ' + gps.satellites + ' sats';
if (gps.accuracy != null) html += ' &middot; &plusmn;' + gps.accuracy + 'm';
html += '</div>';
} else {
html += '<div class="sys-location-source">Source: ' + escHtml(locationData.source || 'unknown') + '</div>';
}
} else {
html += '<div class="sys-location-coords" style="color:var(--text-dim)">No location</div>';
}

View File

@@ -3151,10 +3151,14 @@
<div class="sys-card-header">Network</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<div class="sys-card sys-card-wide" id="sysCardLocation">
<div class="sys-card" id="sysCardLocation">
<div class="sys-card-header">Location &amp; Weather</div>
<div class="sys-card-body"><span class="sys-metric-na">Loading&hellip;</span></div>
</div>
<div class="sys-card" id="sysCardInfo">
<div class="sys-card-header">System Info</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<!-- Row 3: EQUIPMENT & OPERATIONS -->
<div class="sys-group-header">Equipment &amp; Operations</div>
@@ -3170,12 +3174,6 @@
<div class="sys-card-header">Active Processes</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<!-- Full-width system info -->
<div class="sys-card sys-card-full" id="sysCardInfo">
<div class="sys-card-header">System Info</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
</div>
</div>

View File

@@ -123,8 +123,7 @@ def test_stream_returns_sse_content_type(client):
def test_location_returns_shape(client):
"""GET /system/location returns lat/lon/source shape."""
_login(client)
with patch('routes.system.contextlib.suppress'):
resp = client.get('/system/location')
resp = client.get('/system/location')
assert resp.status_code == 200
data = resp.get_json()
assert 'lat' in data
@@ -132,12 +131,48 @@ def test_location_returns_shape(client):
assert 'source' in data
def test_location_from_gps(client):
"""Location endpoint returns GPS data when fix available."""
_login(client)
mock_pos = MagicMock()
mock_pos.fix_quality = 3
mock_pos.latitude = 51.5074
mock_pos.longitude = -0.1278
mock_pos.satellites = 12
mock_pos.epx = 2.5
mock_pos.epy = 3.1
mock_pos.altitude = 45.0
with patch('routes.system.get_current_position', return_value=mock_pos, create=True):
# Patch the import inside the function
import routes.system as mod
original = mod._get_observer_location
def _patched():
with patch('utils.gps.get_current_position', return_value=mock_pos):
return original()
mod._get_observer_location = _patched
try:
resp = client.get('/system/location')
finally:
mod._get_observer_location = original
assert resp.status_code == 200
data = resp.get_json()
assert data['source'] == 'gps'
assert data['lat'] == 51.5074
assert data['lon'] == -0.1278
assert data['gps']['fix_quality'] == 3
assert data['gps']['satellites'] == 12
assert data['gps']['accuracy'] == 3.1
assert data['gps']['altitude'] == 45.0
def test_location_falls_back_to_config(client):
"""Location endpoint returns config defaults when GPS unavailable."""
_login(client)
with patch('routes.system.DEFAULT_LATITUDE', 40.7128, create=True), \
patch('routes.system.DEFAULT_LONGITUDE', -74.006, create=True):
# Mock the import inside _get_observer_location
with patch('utils.gps.get_current_position', return_value=None):
resp = client.get('/system/location')
assert resp.status_code == 200
data = resp.get_json()