Refactor: Bootstrap 5.3.8, Bootstrap Icons, Client/Http/Configuration layout

- Update Bootstrap to 5.3.8, add Bootstrap Icons; replace custom SVGs
- Fix settings modal: load editing state only when modal opens (avoid refresh overwrite)
- Home: StatusSection and LogsSection components with own timers
- Move HTTP clients under Client/Http with Abstractions namespace pattern
- Rename Options to Configuration; remove empty Options dir
- Components: Modals/, Home/; consistent Abstractions (Services, Client.Services, Client.Http)
This commit is contained in:
2026-02-01 22:51:05 +10:00
parent e50cfc986c
commit 92dfa2b170
95 changed files with 1246 additions and 636 deletions
@@ -0,0 +1,9 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for GET api/config/set.</summary>
public interface IConfigSetClient
{
Task<DnsmasqConfigSet> GetConfigSetAsync(CancellationToken ct = default);
}
@@ -0,0 +1,10 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for GET/PUT api/dhcp/hosts.</summary>
public interface IDhcpHostsClient
{
Task<IReadOnlyList<DhcpHostEntry>> GetDhcpHostsAsync(CancellationToken ct = default);
Task<SaveWithReloadResult> SaveDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default);
}
@@ -0,0 +1,10 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for GET/PUT api/hosts.</summary>
public interface IHostsClient
{
Task<IReadOnlyList<HostEntry>> GetHostsAsync(CancellationToken ct = default);
Task<SaveWithReloadResult> SaveHostsAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for GET api/leases.</summary>
public interface ILeasesClient
{
Task<LeasesResult> GetLeasesAsync(CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
using DnsmasqWebUI.Services.Abstractions;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for POST api/reload.</summary>
public interface IReloadClient
{
Task<ReloadResult> ReloadAsync(CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for GET api/status.</summary>
public interface IStatusClient
{
Task<DnsmasqServiceStatus> GetStatusAsync(CancellationToken ct = default);
}
@@ -0,0 +1,16 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Client.Http;
public sealed class ConfigSetClient : IConfigSetClient
{
private readonly HttpClient _http;
public ConfigSetClient(HttpClient http) => _http = http;
public async Task<DnsmasqConfigSet> GetConfigSetAsync(CancellationToken ct = default) =>
await _http.GetFromJsonAsync<DnsmasqConfigSet>("api/config/set", ct)
?? throw new InvalidOperationException("Unexpected null from api/config/set.");
}
@@ -0,0 +1,26 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Client.Http;
public sealed class DhcpHostsClient : IDhcpHostsClient
{
private readonly HttpClient _http;
public DhcpHostsClient(HttpClient http) => _http = http;
public async Task<IReadOnlyList<DhcpHostEntry>> GetDhcpHostsAsync(CancellationToken ct = default)
{
var list = await _http.GetFromJsonAsync<List<DhcpHostEntry>>("api/dhcp/hosts", ct);
return list ?? new List<DhcpHostEntry>();
}
public async Task<SaveWithReloadResult> SaveDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default)
{
var response = await _http.PutAsJsonAsync("api/dhcp/hosts", entries, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SaveWithReloadResult>(ct)
?? throw new InvalidOperationException("Unexpected null from api/dhcp/hosts.");
}
}
@@ -0,0 +1,26 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Client.Http;
public sealed class HostsClient : IHostsClient
{
private readonly HttpClient _http;
public HostsClient(HttpClient http) => _http = http;
public async Task<IReadOnlyList<HostEntry>> GetHostsAsync(CancellationToken ct = default)
{
var list = await _http.GetFromJsonAsync<List<HostEntry>>("api/hosts", ct);
return list ?? new List<HostEntry>();
}
public async Task<SaveWithReloadResult> SaveHostsAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default)
{
var response = await _http.PutAsJsonAsync("api/hosts", entries, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SaveWithReloadResult>(ct)
?? throw new InvalidOperationException("Unexpected null from api/hosts.");
}
}
@@ -0,0 +1,16 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Client.Http;
public sealed class LeasesClient : ILeasesClient
{
private readonly HttpClient _http;
public LeasesClient(HttpClient http) => _http = http;
public async Task<LeasesResult> GetLeasesAsync(CancellationToken ct = default) =>
await _http.GetFromJsonAsync<LeasesResult>("api/leases", ct)
?? throw new InvalidOperationException("Unexpected null from api/leases.");
}
@@ -0,0 +1,20 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Services.Abstractions;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Client.Http;
public sealed class ReloadClient : IReloadClient
{
private readonly HttpClient _http;
public ReloadClient(HttpClient http) => _http = http;
public async Task<ReloadResult> ReloadAsync(CancellationToken ct = default)
{
var response = await _http.PostAsync("api/reload", null, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ReloadResult>(ct)
?? throw new InvalidOperationException("Unexpected null from api/reload.");
}
}
@@ -0,0 +1,27 @@
using System.Net;
using DnsmasqWebUI.Services.Abstractions;
namespace DnsmasqWebUI.Client.Http;
/// <summary>
/// DelegatingHandler that rewrites relative request URIs to the current request's scheme, host, and path base.
/// Used so Blazor/API callers can use relative paths (e.g. "/api/status") and hit the same host.
/// Resolved in the same scope as the code that requested the HttpClient (e.g. Blazor component),
/// so IHttpContextAccessor has the current request when available.
/// </summary>
public sealed class SameHostBaseAddressHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler, IApplicationScopedConcrete
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var context = httpContextAccessor.HttpContext;
if (context != null && request.RequestUri != null)
{
var baseUri = new Uri($"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value ?? ""}");
request.RequestUri = request.RequestUri.IsAbsoluteUri
? new Uri(baseUri, request.RequestUri.PathAndQuery)
: new Uri(baseUri, request.RequestUri);
}
return base.SendAsync(request, cancellationToken);
}
}
@@ -0,0 +1,16 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Client.Http;
public sealed class StatusClient : IStatusClient
{
private readonly HttpClient _http;
public StatusClient(HttpClient http) => _http = http;
public async Task<DnsmasqServiceStatus> GetStatusAsync(CancellationToken ct = default) =>
await _http.GetFromJsonAsync<DnsmasqServiceStatus>("api/status", ct)
?? throw new InvalidOperationException("Unexpected null from api/status.");
}
@@ -0,0 +1,13 @@
namespace DnsmasqWebUI.Client.Models;
/// <summary>
/// Client-side preferences persisted in browser local storage.
/// </summary>
public class ClientSettings
{
/// <summary>Polling interval for the service status block (seconds).</summary>
public int ServiceStatusPollingIntervalSeconds { get; set; } = 15;
/// <summary>Polling interval for the recent logs block (seconds).</summary>
public int RecentLogsPollingIntervalSeconds { get; set; } = 5;
}
@@ -0,0 +1,16 @@
namespace DnsmasqWebUI.Client.Models;
/// <summary>
/// Which subset of client settings to show in the settings modal.
/// </summary>
public enum SettingsModalContext
{
/// <summary>Show all settings with optional search.</summary>
All,
/// <summary>Show only service status polling interval.</summary>
ServicePolling,
/// <summary>Show only recent logs polling interval.</summary>
LogsPolling
}
@@ -0,0 +1,17 @@
using DnsmasqWebUI.Client.Models;
using DnsmasqWebUI.Services.Abstractions;
namespace DnsmasqWebUI.Client.Services.Abstractions;
/// <summary>
/// Loads and saves client-side preferences (e.g. polling intervals) to browser local storage.
/// Registered as scoped via assembly scanning (<see cref="IApplicationScopedService"/>).
/// </summary>
public interface IClientSettingsService : IApplicationScopedService
{
/// <summary>Loads settings from local storage; returns defaults if missing or invalid.</summary>
Task<ClientSettings> LoadSettingsAsync();
/// <summary>Persists settings to local storage.</summary>
Task SaveSettingsAsync(ClientSettings settings);
}
@@ -0,0 +1,72 @@
using System.Text.Json;
using DnsmasqWebUI.Client.Models;
using DnsmasqWebUI.Client.Services.Abstractions;
using Microsoft.JSInterop;
namespace DnsmasqWebUI.Client.Services;
/// <summary>
/// Loads and saves client-side settings to browser local storage via JS interop.
/// Scoped per Blazor circuit; disposes the JS module when the scope is disposed.
/// </summary>
public sealed class ClientSettingsService : IClientSettingsService, IAsyncDisposable
{
private readonly IJSRuntime _jsRuntime;
private IJSObjectReference? _module;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = false
};
public ClientSettingsService(IJSRuntime jsRuntime) => _jsRuntime = jsRuntime;
private async Task<IJSObjectReference> GetModuleAsync()
{
if (_module != null) return _module;
_module = await _jsRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/client-settings-storage.js");
return _module;
}
/// <inheritdoc />
public async Task<ClientSettings> LoadSettingsAsync()
{
var module = await GetModuleAsync();
var json = await module.InvokeAsync<string?>("getItem");
if (string.IsNullOrWhiteSpace(json)) return new ClientSettings();
try
{
return JsonSerializer.Deserialize<ClientSettings>(json, JsonOptions) ?? new ClientSettings();
}
catch
{
return new ClientSettings();
}
}
/// <inheritdoc />
public async Task SaveSettingsAsync(ClientSettings settings)
{
var json = JsonSerializer.Serialize(settings, JsonOptions);
var module = await GetModuleAsync();
await module.InvokeVoidAsync("setItem", json);
}
/// <summary>
/// Disposes the JS module reference. Called when the Blazor circuit scope is disposed.
/// </summary>
public async ValueTask DisposeAsync()
{
if (_module == null) return;
try
{
await _module.DisposeAsync();
}
finally
{
_module = null;
}
}
}