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
121 changes: 89 additions & 32 deletions src/NetworkOptimizer.Web/Components/Pages/ClientDashboard.razor
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -63,6 +63,19 @@
</div>
}

@if (_gpsDenied)
{
<div class="connection-banner">
<div class="banner-content">
<span class="banner-icon">!</span>
<div class="banner-text">
<strong>Location Permission Denied</strong>
<span>Enable location access in your browser or device settings to collect GPS data for walk-test signal mapping.</span>
</div>
</div>
</div>
}

@if (_loading)
{
<div class="skeleton-hero card">
Expand Down Expand Up @@ -134,6 +147,10 @@
<span class="poll-dot"></span>
<span class="poll-label">Live@(_isLogging ? " · Logging" : "")</span>
}
else
{
<span class="poll-label" style="opacity: 0.5; margin-left: 13px">Connecting...</span>
}
</span>
@if (!_isOwnDevice && _client is { IsWired: false })
{
Expand Down Expand Up @@ -785,6 +802,10 @@
private int? _gpsAccuracy;
private bool _isInsecureContext;
private bool _showGpsWarning;
private bool _gpsDenied;
private DotNetObjectReference<ClientDashboard>? _dotNetRef;
private int? _gpsWatcherId;
private DateTime _lastGpsCallback;

// Time filter options
private static readonly (int hours, string label)[] _timeFilters = new[]
Expand Down Expand Up @@ -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 _ =>
{
Expand All @@ -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)
Expand All @@ -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<bool>("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()
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<GpsResult?>("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<int?>("__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 ---
Expand Down Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -1992,7 +2048,7 @@
display: flex;
align-items: center;
gap: 5px;
min-width: 38px;
min-width: 103px;
min-height: 18px;
}
.logging-toggle {
Expand Down Expand Up @@ -2222,6 +2278,7 @@
align-items: flex-end;
gap: 3px;
height: 24px;
margin-bottom: 0.5rem;
}
.signal-bars .bar {
width: 5px;
Expand Down
2 changes: 1 addition & 1 deletion src/NetworkOptimizer.Web/wwwroot/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down