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.");
}