From e614dadb1985511c6e5beef95b5a984d449caca4 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sat, 14 Feb 2026 11:34:20 -0600 Subject: [PATCH 1/5] Fix GPS tracking for walk-test signal mapping - GPS was never captured due to race condition: OnAfterRenderAsync checked _client before it was loaded, so the condition always failed - Switched from one-shot getCurrentPosition to watchPosition for continuous GPS tracking during walk tests - GPS watcher starts on first poll (after client identity is known), skips wired devices - Added "Location Permission Denied" banner when browser blocks GPS - Uses DotNetObjectReference for JS-to-.NET callbacks - Properly cleans up watcher via IAsyncDisposable --- .../Components/Pages/ClientDashboard.razor | 116 +++++++++++++----- 1 file changed, 86 insertions(+), 30 deletions(-) diff --git a/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor b/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor index 77d91711..35f7cc7a 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor @@ -1,6 +1,6 @@ @page "/client-dashboard" @rendermode InteractiveServer -@implements IDisposable +@implements IAsyncDisposable @using NetworkOptimizer.Web.Services @using NetworkOptimizer.Web.Models @using NetworkOptimizer.UniFi.Models @@ -63,6 +63,19 @@ } + @if (_gpsDenied) + { +
+ +
+ } + @if (_loading) {
@@ -134,6 +147,10 @@ Live@(_isLogging ? " ยท Logging" : "") } + else + { + Connecting... + } @if (!_isOwnDevice && _client is { IsWired: false }) { @@ -785,6 +802,10 @@ private int? _gpsAccuracy; private bool _isInsecureContext; private bool _showGpsWarning; + private bool _gpsDenied; + private DotNetObjectReference? _dotNetRef; + private int? _gpsWatcherId; + private DateTime _lastGpsCallback; // Time filter options private static readonly (int hours, string label)[] _timeFilters = new[] @@ -846,6 +867,8 @@ // Identify client await IdentifyAndLoadAsync(); + // Note: initial GPS request happens in OnAfterRenderAsync (needs JS interop) + // Start polling every 5 seconds _pollTimer = new System.Threading.Timer(async _ => { @@ -876,14 +899,13 @@ // Check if browser is in a secure context (HTTPS or localhost) - GPS requires it // Must be in OnAfterRenderAsync because JS interop isn't available during prerender _isInsecureContext = !await JS.InvokeAsync("eval", "window.isSecureContext"); - _showGpsWarning = _isInsecureContext && _isOwnDevice && _client is { IsWired: false }; + _showGpsWarning = _isInsecureContext && _isOwnDevice; if (_showGpsWarning) StateHasChanged(); - // Request GPS location only for wireless clients viewing own device - if (_isOwnDevice && _client is { IsWired: false } && !_isInsecureContext) - _ = RequestGpsAsync(); + // GPS watcher is started from the first poll cycle (after _client is identified) + // so we can skip it for wired devices } private async Task IdentifyAndLoadAsync() @@ -949,11 +971,27 @@ if (string.IsNullOrEmpty(_clientIp)) return; + // Start GPS watcher on first poll for wireless own-device (needs _client to be loaded) + if (!_gpsWatcherId.HasValue && _isOwnDevice && !_isInsecureContext + && _client is { IsWired: false }) + await StartGpsWatcherAsync(); + + // If the GPS watcher was sending callbacks (success or error) but went completely + // silent, the browser is gone - stop persisting to avoid zombie writes. + // GPS signal loss still fires error callbacks, so silence = dead browser. + var watcherSilent = _gpsWatcherId.HasValue + && _lastGpsCallback != default + && _lastGpsCallback < DateTime.UtcNow.AddSeconds(-15); + var persist = _isLogging && !watcherSilent; + var lat = watcherSilent ? null : _gpsLat; + var lng = watcherSilent ? null : _gpsLng; + var acc = watcherSilent ? null : _gpsAccuracy; + try { _latestPoll = await DashboardService.PollSignalAsync( - _clientIp, _gpsLat, _gpsLng, _gpsAccuracy, - persist: _isLogging); + _clientIp, lat, lng, acc, + persist: persist); if (_latestPoll != null) { _client = _latestPoll.Client; @@ -1485,38 +1523,48 @@ _ => "Unknown" }; - private async Task RequestGpsAsync() + private async Task StartGpsWatcherAsync() { try { - // Wrap browser geolocation in a Promise for JS interop - var js = "new Promise((resolve) => { " + - "if (!navigator.geolocation) { resolve(null); return; } " + - "navigator.geolocation.getCurrentPosition(" + - "(pos) => resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude, acc: Math.round(pos.coords.accuracy) }), " + - "() => resolve(null), " + - "{ enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }" + - "); })"; - var result = await JS.InvokeAsync("eval", js); - - if (result != null) - { - _gpsLat = result.Lat; - _gpsLng = result.Lng; - _gpsAccuracy = result.Acc; - } + _dotNetRef = DotNetObjectReference.Create(this); + + // Register a named JS function, then call it with the DotNetObjectReference + // (eval doesn't pass extra args, so we need a two-step approach) + await JS.InvokeVoidAsync("eval", + "window.__startGpsWatcher = (ref) => { " + + "if (!navigator.geolocation) return null; " + + "return navigator.geolocation.watchPosition(" + + "(pos) => ref.invokeMethodAsync('OnGpsPosition', " + + "pos.coords.latitude, pos.coords.longitude, Math.round(pos.coords.accuracy)), " + + "(err) => ref.invokeMethodAsync('OnGpsError', err.code), " + + "{ enableHighAccuracy: true, maximumAge: 0 }" + + "); }"); + + _gpsWatcherId = await JS.InvokeAsync("__startGpsWatcher", _dotNetRef); } catch { - // GPS is optional - don't fail if browser denies permission + // GPS is optional } } - private class GpsResult + [JSInvokable] + public void OnGpsPosition(double lat, double lng, int acc) { - public double Lat { get; set; } - public double Lng { get; set; } - public int? Acc { get; set; } + _gpsDenied = false; + _gpsLat = lat; + _gpsLng = lng; + _gpsAccuracy = acc; + _lastGpsCallback = DateTime.UtcNow; + } + + [JSInvokable] + public void OnGpsError(int code) + { + // 1 = PERMISSION_DENIED, 2 = POSITION_UNAVAILABLE, 3 = TIMEOUT + _gpsDenied = code == 1; + _lastGpsCallback = DateTime.UtcNow; } // --- URL Construction --- @@ -1896,9 +1944,17 @@ }; } - public void Dispose() + public async ValueTask DisposeAsync() { _pollTimer?.Dispose(); + + // Stop GPS watcher + if (_gpsWatcherId.HasValue) + { + try { await JS.InvokeVoidAsync("eval", $"navigator.geolocation.clearWatch({_gpsWatcherId.Value})"); } + catch { } + } + _dotNetRef?.Dispose(); } } From 29156a544e7ace351b64f0b43b62e0f10bc5e4b3 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sat, 14 Feb 2026 12:09:40 -0600 Subject: [PATCH 2/5] Set identity-live min-width to 103px --- src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor b/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor index 35f7cc7a..4b9cd504 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor @@ -2048,7 +2048,7 @@ display: flex; align-items: center; gap: 5px; - min-width: 38px; + min-width: 103px; min-height: 18px; } .logging-toggle { From d942a12e960db695e0a1a12904ac803aff18b5d4 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sat, 14 Feb 2026 12:12:32 -0600 Subject: [PATCH 3/5] Fire first poll immediately; add margin-left to Connecting label --- .../Components/Pages/ClientDashboard.razor | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor b/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor index 4b9cd504..3dd27f30 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor @@ -149,7 +149,7 @@ } else { - Connecting... + Connecting... } @if (!_isOwnDevice && _client is { IsWired: false }) @@ -889,7 +889,7 @@ _consecutiveFailures++; try { await InvokeAsync(StateHasChanged); } catch { } } - }, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); + }, null, TimeSpan.Zero, TimeSpan.FromSeconds(5)); } protected override async Task OnAfterRenderAsync(bool firstRender) From 74b06fbade6b62bcc934d7f7e077c33e7c1f2a38 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sat, 14 Feb 2026 12:16:03 -0600 Subject: [PATCH 4/5] Reduce signal-bars margin-bottom to 0.5rem --- src/NetworkOptimizer.Web/wwwroot/css/app.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NetworkOptimizer.Web/wwwroot/css/app.css b/src/NetworkOptimizer.Web/wwwroot/css/app.css index 9db5b924..0f79ee3c 100644 --- a/src/NetworkOptimizer.Web/wwwroot/css/app.css +++ b/src/NetworkOptimizer.Web/wwwroot/css/app.css @@ -9272,7 +9272,7 @@ a.path-hop.hop-clickable:hover { height: 150px; gap: 4px; padding: 0 0.5rem; - margin-bottom: 1rem; + margin-bottom: 0.5rem; } .signal-bar-wrapper { From b0e2158c7dc4c4d3d7175669cd0f23dc2888bdfa Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sat, 14 Feb 2026 12:17:46 -0600 Subject: [PATCH 5/5] Scope signal-bars margin: 0 in app.css, 0.5rem in ClientDashboard --- src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor | 1 + src/NetworkOptimizer.Web/wwwroot/css/app.css | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor b/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor index 3dd27f30..7821f672 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor @@ -2278,6 +2278,7 @@ align-items: flex-end; gap: 3px; height: 24px; + margin-bottom: 0.5rem; } .signal-bars .bar { width: 5px; diff --git a/src/NetworkOptimizer.Web/wwwroot/css/app.css b/src/NetworkOptimizer.Web/wwwroot/css/app.css index 0f79ee3c..a8414abd 100644 --- a/src/NetworkOptimizer.Web/wwwroot/css/app.css +++ b/src/NetworkOptimizer.Web/wwwroot/css/app.css @@ -9272,7 +9272,7 @@ a.path-hop.hop-clickable:hover { height: 150px; gap: 4px; padding: 0 0.5rem; - margin-bottom: 0.5rem; + margin-bottom: 0; } .signal-bar-wrapper {