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)
+ {
+
+
+
!
+
+ Location Permission Denied
+ Enable location access in your browser or device settings to collect GPS data for walk-test signal mapping.
+
+
+
+ }
+
@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 {