(url, ct)
?? throw new InvalidOperationException("Unexpected null from api/leases.");
+ }
}
diff --git a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor
index 5bcb683..ffa20ca 100644
--- a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor
+++ b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor
@@ -252,7 +252,7 @@ else
{
No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.
-
+
}
else
@@ -317,7 +317,7 @@ else
-
+
}
@@ -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
diff --git a/src/DnsmasqWebUI/Controllers/LeasesController.cs b/src/DnsmasqWebUI/Controllers/LeasesController.cs
index d86b03c..27e2f07 100644
--- a/src/DnsmasqWebUI/Controllers/LeasesController.cs
+++ b/src/DnsmasqWebUI/Controllers/LeasesController.cs
@@ -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> Get(CancellationToken ct)
+ public async Task> 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."));
diff --git a/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs b/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs
index d39dabd..2c54f3e 100644
--- a/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs
+++ b/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs
@@ -7,5 +7,8 @@ namespace DnsmasqWebUI.Services.Abstractions;
///
public interface ILeasesCache : IApplicationSingleton
{
+ /// Forces the next to re-read the file. Use for manual Refresh; no need to recreate the file watcher.
+ void Invalidate();
+
Task<(bool Available, IReadOnlyList? Entries)> GetOrRefreshAsync(CancellationToken ct = default);
}
diff --git a/src/DnsmasqWebUI/Services/LeasesCache.cs b/src/DnsmasqWebUI/Services/LeasesCache.cs
index 85fa603..2545149 100644
--- a/src/DnsmasqWebUI/Services/LeasesCache.cs
+++ b/src/DnsmasqWebUI/Services/LeasesCache.cs
@@ -7,14 +7,19 @@ namespace DnsmasqWebUI.Services;
///
/// Singleton cache for the leases file with a so we re-read only when the file changes.
+/// Falls back to treating cache as dirty after so missed watcher events still get fresh data.
///
public sealed class LeasesCache : ILeasesCache, IDisposable
{
+ /// After this many seconds, cached data is treated as stale and re-read on next request (fallback if FileSystemWatcher missed an event).
+ private const int StaleCacheSeconds = 120;
+
private readonly string? _path;
private readonly ILogger _logger;
private FileSystemWatcher? _watcher;
private readonly object _lock = new();
private (bool Available, IReadOnlyList? Entries)? _cache;
+ private DateTime? _lastReadUtc;
private bool _dirty = true;
public LeasesCache(IDnsmasqConfigSetService configSetService, ILogger logger)
@@ -64,6 +69,14 @@ public sealed class LeasesCache : ILeasesCache, IDisposable
}
}
+ public void Invalidate()
+ {
+ lock (_lock)
+ {
+ _dirty = true;
+ }
+ }
+
public (bool Available, IReadOnlyList? 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());
+ _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;
}