mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
Leases cache: manual refresh, staleness fallback, no duplicate reads
- ILeasesCache.Invalidate() so manual Refresh forces re-read; no watcher recreate - GET api/leases?refresh=true; LeasesClient.GetLeasesAsync(forceRefresh); Dhcp Refresh button uses forceRefresh: true - Staleness fallback: cache older than 2 min treated as dirty (fallback if FileSystemWatcher missed event) - LeasesCache: _lastReadUtc, StaleCacheSeconds = 120
This commit is contained in:
@@ -5,5 +5,6 @@ namespace DnsmasqWebUI.Client.Http.Abstractions;
|
||||
/// <summary>Typed client for GET api/leases.</summary>
|
||||
public interface ILeasesClient
|
||||
{
|
||||
Task<LeasesResult> GetLeasesAsync(CancellationToken ct = default);
|
||||
/// <param name="forceRefresh">When true, invalidates the server cache so the next read is from disk (e.g. after manual Refresh).</param>
|
||||
Task<LeasesResult> GetLeasesAsync(bool forceRefresh = false, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ public sealed class LeasesClient : ILeasesClient
|
||||
|
||||
public LeasesClient(HttpClient http) => _http = http;
|
||||
|
||||
public async Task<LeasesResult> GetLeasesAsync(CancellationToken ct = default) =>
|
||||
await _http.GetFromJsonAsync<LeasesResult>("api/leases", ct)
|
||||
public async Task<LeasesResult> GetLeasesAsync(bool forceRefresh = false, CancellationToken ct = default)
|
||||
{
|
||||
var url = forceRefresh ? "api/leases?refresh=true" : "api/leases";
|
||||
return await _http.GetFromJsonAsync<LeasesResult>(url, ct)
|
||||
?? throw new InvalidOperationException("Unexpected null from api/leases.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ else
|
||||
{
|
||||
<p class="text-muted">No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.</p>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
|
||||
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true)" disabled="@_leasesLoading">Refresh</button>
|
||||
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading">Refresh</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -317,7 +317,7 @@ else
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
|
||||
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true)" disabled="@_leasesLoading">Refresh</button>
|
||||
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading">Refresh</button>
|
||||
</div>
|
||||
}
|
||||
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
|
||||
@@ -461,13 +461,13 @@ else
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task LoadLeases(bool showLoading = true)
|
||||
private async Task LoadLeases(bool showLoading = true, bool forceRefresh = false)
|
||||
{
|
||||
if (_status == null || !_status.LeasesPathConfigured) return;
|
||||
if (showLoading) _leasesLoading = true;
|
||||
try
|
||||
{
|
||||
var resp = await LeasesClient.GetLeasesAsync();
|
||||
var resp = await LeasesClient.GetLeasesAsync(forceRefresh: forceRefresh);
|
||||
_leases = resp.Entries;
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -9,17 +9,21 @@ namespace DnsmasqWebUI.Controllers;
|
||||
public class LeasesController : ControllerBase
|
||||
{
|
||||
private readonly ILeasesFileService _leasesService;
|
||||
private readonly ILeasesCache _cache;
|
||||
|
||||
public LeasesController(ILeasesFileService leasesService)
|
||||
public LeasesController(ILeasesFileService leasesService, ILeasesCache cache)
|
||||
{
|
||||
_leasesService = leasesService;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<LeasesResult>> Get(CancellationToken ct)
|
||||
public async Task<ActionResult<LeasesResult>> Get([FromQuery] bool refresh, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (refresh)
|
||||
_cache.Invalidate();
|
||||
var (available, entries) = await _leasesService.TryReadAsync(ct);
|
||||
if (!available)
|
||||
return Ok(new LeasesResult(false, null, "Leases not configured."));
|
||||
|
||||
@@ -7,5 +7,8 @@ namespace DnsmasqWebUI.Services.Abstractions;
|
||||
/// </summary>
|
||||
public interface ILeasesCache : IApplicationSingleton
|
||||
{
|
||||
/// <summary>Forces the next <see cref="GetOrRefreshAsync"/> to re-read the file. Use for manual Refresh; no need to recreate the file watcher.</summary>
|
||||
void Invalidate();
|
||||
|
||||
Task<(bool Available, IReadOnlyList<LeaseEntry>? Entries)> GetOrRefreshAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,19 @@ namespace DnsmasqWebUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton cache for the leases file with a <see cref="FileSystemWatcher"/> so we re-read only when the file changes.
|
||||
/// Falls back to treating cache as dirty after <see cref="StaleCacheSeconds"/> so missed watcher events still get fresh data.
|
||||
/// </summary>
|
||||
public sealed class LeasesCache : ILeasesCache, IDisposable
|
||||
{
|
||||
/// <summary>After this many seconds, cached data is treated as stale and re-read on next request (fallback if FileSystemWatcher missed an event).</summary>
|
||||
private const int StaleCacheSeconds = 120;
|
||||
|
||||
private readonly string? _path;
|
||||
private readonly ILogger<LeasesCache> _logger;
|
||||
private FileSystemWatcher? _watcher;
|
||||
private readonly object _lock = new();
|
||||
private (bool Available, IReadOnlyList<LeaseEntry>? Entries)? _cache;
|
||||
private DateTime? _lastReadUtc;
|
||||
private bool _dirty = true;
|
||||
|
||||
public LeasesCache(IDnsmasqConfigSetService configSetService, ILogger<LeasesCache> logger)
|
||||
@@ -64,6 +69,14 @@ public sealed class LeasesCache : ILeasesCache, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public void Invalidate()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
public (bool Available, IReadOnlyList<LeaseEntry>? Entries) GetOrRefresh(CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_path))
|
||||
@@ -71,13 +84,19 @@ public sealed class LeasesCache : ILeasesCache, IDisposable
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_dirty && _cache.HasValue)
|
||||
return _cache.Value;
|
||||
if (!_dirty && _cache.HasValue && _lastReadUtc.HasValue)
|
||||
{
|
||||
var ageSeconds = (DateTime.UtcNow - _lastReadUtc.Value).TotalSeconds;
|
||||
if (ageSeconds < StaleCacheSeconds)
|
||||
return _cache.Value;
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
_logger.LogDebug("Leases file not found: {Path}", _path);
|
||||
_cache = (true, Array.Empty<LeaseEntry>());
|
||||
_lastReadUtc = DateTime.UtcNow;
|
||||
_dirty = false;
|
||||
return _cache.Value;
|
||||
}
|
||||
@@ -92,6 +111,7 @@ public sealed class LeasesCache : ILeasesCache, IDisposable
|
||||
entries.Add(entry);
|
||||
}
|
||||
_cache = (true, entries);
|
||||
_lastReadUtc = DateTime.UtcNow;
|
||||
_dirty = false;
|
||||
return _cache.Value;
|
||||
}
|
||||
@@ -99,6 +119,7 @@ public sealed class LeasesCache : ILeasesCache, IDisposable
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read leases file: {Path}", _path);
|
||||
_cache = (true, null);
|
||||
_lastReadUtc = DateTime.UtcNow;
|
||||
_dirty = false;
|
||||
return _cache.Value;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user