diff --git a/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor b/src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor index 77d91711..7821f672 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 _ => { @@ -866,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) @@ -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(); } } @@ -1992,7 +2048,7 @@ display: flex; align-items: center; gap: 5px; - min-width: 38px; + min-width: 103px; min-height: 18px; } .logging-toggle { @@ -2222,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 9db5b924..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: 1rem; + margin-bottom: 0; } .signal-bar-wrapper {