DHCP: read-only hosts from other configs, combobox inputs, polling, UI fixes

- Static hosts: read dhcp-host from all config files; show external as read-only
  with Source column; IsEditable + SourcePath on DhcpHostEntry; reject save if
  managed entry duplicates MAC from another file (400 + message)
- Lease field: single input + datalist (type or select), remove Other dropdown
- MAC field: single input + datalist from current leases, TryFillFromLeaseByMac
- Add host: default Name=*, suggest next free IP from DHCP range
- Leases: configurable refresh polling; Settings modal for interval (client-side)
- DhcpHostsClient: surface 400 error body message on save failure
- App title config (ApplicationOptions), nav/layout/error/hosts tweaks
This commit is contained in:
2026-02-02 00:40:53 +10:00
parent 92dfa2b170
commit a60d0e8db8
26 changed files with 803 additions and 106 deletions
+51 -3
View File
@@ -1,4 +1,5 @@
# Run the app with real dnsmasq and a DHCP client so hosts/leases are real. # Run the app with real dnsmasq and several DHCP clients so hosts/leases are real.
# Some clients also make periodic DNS requests so the DNS cache and logs show activity.
# Prepare mount: ./scripts/prepare-test-mount.sh [--prepare-only | --no-build | --recreate | --source DIR | --mount DIR] # Prepare mount: ./scripts/prepare-test-mount.sh [--prepare-only | --no-build | --recreate | --source DIR | --mount DIR]
# Or set TESTDATA_MOUNT to override the data volume (default: ./testdata-mount). # Or set TESTDATA_MOUNT to override the data volume (default: ./testdata-mount).
# #
@@ -31,11 +32,58 @@ services:
networks: networks:
testnet: testnet:
ipv4_address: 172.28.0.2 ipv4_address: 172.28.0.2
# no depends_on: app is the only service the client needs
# DHCP only; keeps running with lease.
dhcp-client: dhcp-client:
image: alpine:3.20 image: alpine:3.20
command: ["sh", "-c", "sleep 5 && udhcpc -i eth0 -f -n"] command: ["sh", "-c", "sleep 5 && exec udhcpc -i eth0 -f -n"]
networks:
testnet: {}
depends_on:
- app
# DHCP + periodic DNS lookups (every 20s) so DNS cache/logs show activity.
dhcp-client-dns-a:
image: alpine:3.20
command:
- sh
- -c
- |
udhcpc -i eth0 -f -n &
sleep 10
while true; do
nslookup google.com 2>/dev/null || true
nslookup cloudflare.com 2>/dev/null || true
sleep 20
done
networks:
testnet: {}
depends_on:
- app
# DHCP + periodic DNS lookups (every 45s); different interval for variety.
dhcp-client-dns-b:
image: alpine:3.20
command:
- sh
- -c
- |
udhcpc -i eth0 -f -n &
sleep 10
while true; do
nslookup example.com 2>/dev/null || true
nslookup github.com 2>/dev/null || true
sleep 45
done
networks:
testnet: {}
depends_on:
- app
# Third DHCP-only client so the leases table has more entries.
dhcp-client-2:
image: alpine:3.20
command: ["sh", "-c", "sleep 8 && exec udhcpc -i eth0 -f -n"]
networks: networks:
testnet: {} testnet: {}
depends_on: depends_on:
+3 -3
View File
@@ -1,6 +1,6 @@
#!/bin/sh #!/bin/sh
# Simulates "systemctl status dnsmasq" output when run in a container without systemd. # Simulates "systemctl status dnsmasq" output when run in a container without systemd.
# Uses ps/pgrep. Memory is RSS; the time shown is process uptime (elapsed), not CPU usage. # Uses ps/pgrep. Memory is RSS. Uptime line omitted; Active line has break after semicolon with continuation indented.
pid=$(pgrep -x dnsmasq) pid=$(pgrep -x dnsmasq)
if [ -z "$pid" ]; then if [ -z "$pid" ]; then
@@ -49,10 +49,11 @@ function fmt_since(s, n, a, mon_num, day_pad) {
rss_mb = (rss+0) / 1024 rss_mb = (rss+0) / 1024
if (rss_mb < 0.1) rss_mb = 0.0 if (rss_mb < 0.1) rss_mb = 0.0
# Active line: break after semicolon; continuation (e.g. "4 min ago") on next line, indented to align after "Active: "
if (lstart != "") { if (lstart != "") {
since = fmt_since(lstart) since = fmt_since(lstart)
ago = etime_ago(etime) ago = etime_ago(etime)
active_line = " Active: active (running) since " since "; " ago active_line = " Active: active (running) since " since ";\n " ago
} else { } else {
active_line = " Active: active (running) since container start" active_line = " Active: active (running) since container start"
} }
@@ -62,7 +63,6 @@ function fmt_since(s, n, a, mon_num, day_pad) {
printf "%s\n", active_line printf "%s\n", active_line
printf " Main PID: %s (dnsmasq)\n", pid printf " Main PID: %s (dnsmasq)\n", pid
printf " Memory: %.1fM\n", rss_mb printf " Memory: %.1fM\n", rss_mb
printf " Uptime: %s\n", etime
printf " CGroup: (container)\n" printf " CGroup: (container)\n"
printf " └─%s %s\n", pid, args printf " └─%s %s\n", pid, args
}' }'
@@ -1,6 +1,7 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using DnsmasqWebUI.Client.Http.Abstractions; using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models; using DnsmasqWebUI.Models;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Client.Http; namespace DnsmasqWebUI.Client.Http;
@@ -19,8 +20,25 @@ public sealed class DhcpHostsClient : IDhcpHostsClient
public async Task<SaveWithReloadResult> SaveDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default) public async Task<SaveWithReloadResult> SaveDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default)
{ {
var response = await _http.PutAsJsonAsync("api/dhcp/hosts", entries, ct); var response = await _http.PutAsJsonAsync("api/dhcp/hosts", entries, ct);
response.EnsureSuccessStatusCode(); if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(ct);
var msg = response.ReasonPhrase ?? response.StatusCode.ToString();
if (!string.IsNullOrEmpty(body) && body.TrimStart().StartsWith("{"))
{
try
{
var err = System.Text.Json.JsonSerializer.Deserialize<JsonError>(body);
if (!string.IsNullOrEmpty(err?.Error))
msg = err.Error;
}
catch { /* use msg as-is */ }
}
throw new HttpRequestException(msg);
}
return await response.Content.ReadFromJsonAsync<SaveWithReloadResult>(ct) return await response.Content.ReadFromJsonAsync<SaveWithReloadResult>(ct)
?? throw new InvalidOperationException("Unexpected null from api/dhcp/hosts."); ?? throw new InvalidOperationException("Unexpected null from api/dhcp/hosts.");
} }
private sealed class JsonError { [JsonPropertyName("error")] public string? Error { get; set; } }
} }
@@ -10,4 +10,7 @@ public class ClientSettings
/// <summary>Polling interval for the recent logs block (seconds).</summary> /// <summary>Polling interval for the recent logs block (seconds).</summary>
public int RecentLogsPollingIntervalSeconds { get; set; } = 5; public int RecentLogsPollingIntervalSeconds { get; set; } = 5;
/// <summary>Polling interval for the DHCP leases list (seconds).</summary>
public int LeasesPollingIntervalSeconds { get; set; } = 5;
} }
@@ -12,5 +12,8 @@ public enum SettingsModalContext
ServicePolling, ServicePolling,
/// <summary>Show only recent logs polling interval.</summary> /// <summary>Show only recent logs polling interval.</summary>
LogsPolling LogsPolling,
/// <summary>Show only DHCP leases refresh interval.</summary>
LeasesPolling
} }
+5 -1
View File
@@ -1,3 +1,4 @@
@inject IOptions<ApplicationOptions> AppOptions
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -11,7 +12,7 @@
<link rel="stylesheet" href="@Assets["DnsmasqWebUI.styles.css"]" /> <link rel="stylesheet" href="@Assets["DnsmasqWebUI.styles.css"]" />
<ImportMap /> <ImportMap />
<link rel="icon" type="image/png" href="favicon.png" /> <link rel="icon" type="image/png" href="favicon.png" />
<title>Local DNS</title> <title>@_appTitle</title>
<HeadOutlet /> <HeadOutlet />
</head> </head>
@@ -21,3 +22,6 @@
</body> </body>
</html> </html>
@code {
private string _appTitle => AppOptions!.Value!.EffectiveTitle;
}
@@ -1,10 +1,11 @@
@namespace DnsmasqWebUI.Components.Layout @namespace DnsmasqWebUI.Components.Layout
@inject IOptions<ApplicationOptions> AppOptions
<div class="top-row ps-3 navbar navbar-dark"> <div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid d-flex align-items-center"> <div class="container-fluid d-flex align-items-center">
<a class="navbar-brand d-flex align-items-center gap-2" href="" aria-label="Local DNS home"> <a class="navbar-brand d-flex align-items-center gap-2" href="" aria-label="@AppTitle home">
<i class="bi bi-globe brand-icon" aria-hidden="true"></i> <i class="bi bi-globe brand-icon" aria-hidden="true"></i>
<span>Local DNS</span> <span>@AppTitle</span>
</a> </a>
</div> </div>
</div> </div>
@@ -31,3 +32,6 @@
</nav> </nav>
</div> </div>
@code {
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
}
@@ -38,6 +38,15 @@
@bind="_editingSettings.RecentLogsPollingIntervalSeconds" /> @bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
</section> </section>
} }
@if (ShouldShowSection("Leases"))
{
<section class="mb-4">
<h6 class="text-muted">DHCP leases</h6>
<label for="leases-interval" class="form-label">Refresh interval (seconds)</label>
<input id="leases-interval" type="number" class="form-control" min="5" step="1"
@bind="_editingSettings.LeasesPollingIntervalSeconds" />
</section>
}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button> <button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
@@ -42,7 +42,8 @@ public partial class SettingsModal : IAsyncDisposable
_editingSettings = new ClientSettings _editingSettings = new ClientSettings
{ {
ServiceStatusPollingIntervalSeconds = _editingSettings.ServiceStatusPollingIntervalSeconds, ServiceStatusPollingIntervalSeconds = _editingSettings.ServiceStatusPollingIntervalSeconds,
RecentLogsPollingIntervalSeconds = _editingSettings.RecentLogsPollingIntervalSeconds RecentLogsPollingIntervalSeconds = _editingSettings.RecentLogsPollingIntervalSeconds,
LeasesPollingIntervalSeconds = _editingSettings.LeasesPollingIntervalSeconds
}; };
} }
} }
@@ -73,6 +74,8 @@ public partial class SettingsModal : IAsyncDisposable
return key == "ServiceStatus"; return key == "ServiceStatus";
if (SettingsContext == SettingsModalContext.LogsPolling) if (SettingsContext == SettingsModalContext.LogsPolling)
return key == "Logs"; return key == "Logs";
if (SettingsContext == SettingsModalContext.LeasesPolling)
return key == "Leases";
if (SettingsContext == SettingsModalContext.All) if (SettingsContext == SettingsModalContext.All)
{ {
if (string.IsNullOrWhiteSpace(_searchTerm)) return true; if (string.IsNullOrWhiteSpace(_searchTerm)) return true;
@@ -81,6 +84,7 @@ public partial class SettingsModal : IAsyncDisposable
{ {
"ServiceStatus" => "service status polling".Contains(term, StringComparison.OrdinalIgnoreCase), "ServiceStatus" => "service status polling".Contains(term, StringComparison.OrdinalIgnoreCase),
"Logs" => "recent logs polling".Contains(term, StringComparison.OrdinalIgnoreCase), "Logs" => "recent logs polling".Contains(term, StringComparison.OrdinalIgnoreCase),
"Leases" => "dhcp leases refresh polling".Contains(term, StringComparison.OrdinalIgnoreCase),
_ => false _ => false
}; };
} }
@@ -93,6 +97,8 @@ public partial class SettingsModal : IAsyncDisposable
_editingSettings.ServiceStatusPollingIntervalSeconds, 5, 300); _editingSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
_editingSettings.RecentLogsPollingIntervalSeconds = Math.Clamp( _editingSettings.RecentLogsPollingIntervalSeconds = Math.Clamp(
_editingSettings.RecentLogsPollingIntervalSeconds, 5, 300); _editingSettings.RecentLogsPollingIntervalSeconds, 5, 300);
_editingSettings.LeasesPollingIntervalSeconds = Math.Clamp(
_editingSettings.LeasesPollingIntervalSeconds, 5, 300);
await ClientSettingsService.SaveSettingsAsync(_editingSettings); await ClientSettingsService.SaveSettingsAsync(_editingSettings);
if (_jsModule != null) if (_jsModule != null)
+452 -57
View File
@@ -6,8 +6,11 @@
@inject IDhcpHostsClient DhcpHostsClient @inject IDhcpHostsClient DhcpHostsClient
@inject ILeasesClient LeasesClient @inject ILeasesClient LeasesClient
@inject IReloadClient ReloadClient @inject IReloadClient ReloadClient
@inject IClientSettingsService ClientSettingsService
@inject IOptions<ApplicationOptions> AppOptions
@implements IDisposable
<PageTitle>DHCP | Local DNS</PageTitle> <PageTitle>DHCP | @AppTitle</PageTitle>
<h1 class="page-title">DHCP</h1> <h1 class="page-title">DHCP</h1>
@@ -29,6 +32,15 @@ else
} }
else else
{ {
<p class="text-muted small mb-2">Managed file: @_status.ManagedFilePath</p>
<div class="row g-2 align-items-center mb-2">
<div class="col-auto">
<label for="filter-keyword" class="form-label mb-0 small text-muted">Search static hosts</label>
</div>
<div class="col-md-4 col-lg-3">
<input type="text" id="filter-keyword" class="form-control form-control-sm" placeholder="MAC, name, address, comment…" @bind="_filterKeyword" @bind:event="oninput" />
</div>
</div>
@if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown") @if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown")
{ {
<div class="alert alert-warning">Dnsmasq service appears to be <strong>@_status.DnsmasqStatus</strong>. Save will update the file but reload may fail until the service is fixed.</div> <div class="alert alert-warning">Dnsmasq service appears to be <strong>@_status.DnsmasqStatus</strong>. Save will update the file but reload may fail until the service is fixed.</div>
@@ -42,43 +54,188 @@ else
<div class="alert alert-success">@_message</div> <div class="alert alert-success">@_message</div>
} }
<div class="table-responsive-cards"> <div class="table-responsive-cards">
<table class="table table-bordered"> <div class="d-none d-md-block">
<thead> <table class="table table-bordered">
<tr> <thead>
<th>#</th><th>Valid</th><th>MAC</th><th>Name</th><th>Address</th><th>Lease</th><th>Ignore</th><th>Comment</th><th></th> <tr>
</tr> <th>#</th><th>Valid</th><th>MAC</th><th>Name</th><th>Address</th><th>Lease</th><th>Ignore</th><th>Comment</th><th>Source</th><th></th>
</thead>
<tbody>
@foreach (var e in _entries.Where(x => !x.IsDeleted))
{
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="e.Id">
<td>@e.LineNumber</td>
<td><input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(e)" /></td>
<td><input class="form-control form-control-sm" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => SetMac(e, ev.Value?.ToString())" style="min-width:140px" /></td>
<td><input class="form-control form-control-sm" @bind="e.Name" /></td>
<td><input class="form-control form-control-sm" @bind="e.Address" style="min-width:100px" /></td>
<td><input class="form-control form-control-sm" @bind="e.Lease" style="min-width:70px" /></td>
<td><input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(e)" /></td>
<td><input class="form-control form-control-sm" @bind="e.Comment" /></td>
<td><button class="btn btn-sm btn-danger" @onclick="() => Delete(e)">Delete</button></td>
</tr> </tr>
} </thead>
</tbody> <tbody>
</table> @foreach (var e in _visibleStaticEntries)
{
var editable = IsEditable(e);
<tr class="@(e.IsComment ? "table-secondary" : "") @(editable ? "" : "table-light")" @key="e.Id">
<td>@e.LineNumber</td>
@if (editable)
{
<td><input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(e)" title="Uncheck to comment out this line in config (dnsmasq will ignore it)" /></td>
<td>
<input class="form-control form-control-sm" list="static-host-lease-datalist" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => OnMacInput(e, ev.Value?.ToString())" style="min-width:140px" placeholder="MAC or pick from leases" />
@if (_leases != null && _leases.Count > 0)
{
<datalist id="static-host-lease-datalist">
@foreach (var l in _visibleLeases)
{
<option value="@l.Mac">@l.Mac @(string.IsNullOrEmpty(l.Name) ? "" : $" – {l.Name}") @(string.IsNullOrEmpty(l.Address) ? "" : $" – {l.Address}")</option>
}
</datalist>
}
</td>
<td><input class="form-control form-control-sm" @bind="e.Name" placeholder="Name" /></td>
<td><input class="form-control form-control-sm" @bind="e.Address" style="min-width:100px" placeholder="Address" /></td>
<td>
<input class="form-control form-control-sm" list="lease-datalist" @bind="e.Lease" @bind:event="oninput" style="min-width:7rem" placeholder="e.g. infinite or seconds" />
<datalist id="lease-datalist">
@foreach (var p in LeaseDatalistOptions)
{
<option value="@p.Value">@p.Label</option>
}
</datalist>
</td>
<td><input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(e)" title="Add ignore flag so dnsmasq will not assign this client a lease" /></td>
<td><input class="form-control form-control-sm" @bind="e.Comment" /></td>
<td class="text-muted small">—</td>
<td><button class="btn btn-sm btn-danger" @onclick="() => Delete(e)">Delete</button></td>
}
else
{
<td>@(e.IsComment ? "No" : "Yes")</td>
<td>@string.Join(", ", e.MacAddresses)</td>
<td>@e.Name</td>
<td>@e.Address</td>
<td>@e.Lease</td>
<td>@(e.Ignore ? "Yes" : "No")</td>
<td>@e.Comment</td>
<td><span class="text-muted small" title="@e.SourcePath">@(e.IsEditable ? "—" : (string.IsNullOrEmpty(e.SourcePath) ? "" : System.IO.Path.GetFileName(e.SourcePath)))</span></td>
<td></td>
}
</tr>
}
</tbody>
</table>
</div>
<div class="d-md-none table-cards-mobile">
@foreach (var e in _visibleStaticEntries)
{
var editable = IsEditable(e);
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "") @(editable ? "" : "border-start border-3 border-info")" @key="e.Id">
<div class="card-body py-2">
<div class="table-card-row"><span class="table-card-label">#</span> @e.LineNumber</div>
@if (!string.IsNullOrEmpty(e.SourcePath))
{
<div class="table-card-row">
<span class="table-card-label">Source</span>
<span class="text-muted small" title="@e.SourcePath">@System.IO.Path.GetFileName(e.SourcePath)</span>
</div>
}
<div class="table-card-row">
<span class="table-card-label">Valid</span>
<div class="table-card-control">
@if (editable)
{
<input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(e)" title="Uncheck to comment out this line in config (dnsmasq will ignore it)" />
}
else
{
<span>@(e.IsComment ? "No" : "Yes")</span>
}
</div>
</div>
<div class="table-card-row">
<span class="table-card-label">MAC</span>
@if (editable)
{
<div class="flex-grow-1" style="min-width:0">
<input class="form-control form-control-sm" list="static-host-lease-datalist-mobile" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => OnMacInput(e, ev.Value?.ToString())" placeholder="MAC or pick from leases" style="min-width:0; width:100%" />
@if (_leases != null && _leases.Count > 0)
{
<datalist id="static-host-lease-datalist-mobile">
@foreach (var l in _visibleLeases)
{
<option value="@l.Mac">@l.Mac @(string.IsNullOrEmpty(l.Name) ? "" : $" – {l.Name}") @(string.IsNullOrEmpty(l.Address) ? "" : $" – {l.Address}")</option>
}
</datalist>
}
</div>
}
else
{
<span>@string.Join(", ", e.MacAddresses)</span>
}
</div>
<div class="table-card-row">
<span class="table-card-label">Name</span>
@if (editable) { <input class="form-control form-control-sm" @bind="e.Name" /> } else { <span>@e.Name</span> }
</div>
<div class="table-card-row">
<span class="table-card-label">Address</span>
@if (editable) { <input class="form-control form-control-sm" @bind="e.Address" /> } else { <span>@e.Address</span> }
</div>
<div class="table-card-row">
<span class="table-card-label">Lease</span>
@if (editable)
{
<input class="form-control form-control-sm" list="lease-datalist-mobile" @bind="e.Lease" @bind:event="oninput" style="min-width:0; width:100%" placeholder="e.g. infinite or seconds" />
<datalist id="lease-datalist-mobile">
@foreach (var p in LeaseDatalistOptions)
{
<option value="@p.Value">@p.Label</option>
}
</datalist>
}
else
{
<span>@e.Lease</span>
}
</div>
<div class="table-card-row">
<span class="table-card-label">Ignore</span>
<div class="table-card-control">
@if (editable) { <input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(e)" /> } else { <span>@(e.Ignore ? "Yes" : "No")</span> }
</div>
</div>
<div class="table-card-row">
<span class="table-card-label">Comment</span>
@if (editable) { <input class="form-control form-control-sm" @bind="e.Comment" /> } else { <span>@e.Comment</span> }
</div>
@if (editable)
{
<div class="table-card-row mt-2">
<button class="btn btn-sm btn-danger" @onclick="() => Delete(e)">Delete</button>
</div>
}
</div>
</div>
}
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<button class="btn btn-primary" @onclick="Add">Add host</button>
<button class="btn btn-primary" @onclick="Save" disabled="@_saving">Save</button>
@if (_status.ReloadCommandConfigured)
{
<button class="btn btn-primary" @onclick="Reload" disabled="@_saving">Reload config</button>
}
</div> </div>
<button class="btn btn-primary mt-2" @onclick="Add">Add host</button>
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
@if (_status.ReloadCommandConfigured)
{
<button class="btn btn-primary mt-2" @onclick="Reload" disabled="@_saving">Reload config</button>
}
<p class="text-muted small mt-2">Managed file: @_status.ManagedFilePath</p>
} }
</section> </section>
@* ---- Leases (dhcp-leasefile, read-only) ---- *@ @* ---- Leases (dhcp-leasefile, read-only) ---- *@
<section class="page-section"> <section class="page-section">
<h2 class="page-section-title">Leases</h2> <div class="d-flex align-items-center justify-content-between mb-2">
<h2 class="page-section-title mb-0">Leases</h2>
@if (_status.LeasesPathConfigured)
{
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Leases refresh interval settings" @onclick="OpenLeasesSettings" title="Configure leases refresh interval">
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
</button>
}
</div>
@if (_status.LeasesPathConfigured && !string.IsNullOrEmpty(_status.LeasesPath))
{
<p class="text-muted small mb-2">Path: @_status.LeasesPath (read-only).</p>
}
@if (!_status.LeasesPathConfigured) @if (!_status.LeasesPathConfigured)
{ {
<p class="text-muted">Leases not configured (no dhcp-leasefile in config).</p> <p class="text-muted">Leases not configured (no dhcp-leasefile in config).</p>
@@ -94,45 +251,157 @@ else
else if (_leases.Count == 0) else if (_leases.Count == 0)
{ {
<p class="text-muted">No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.</p> <p class="text-muted">No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.</p>
<p class="text-muted small">Path: @_status.LeasesPath</p> <div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<button class="btn btn-primary btn-sm mt-1" @onclick="LoadLeases" disabled="@_leasesLoading">Refresh</button> <button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true)" disabled="@_leasesLoading">Refresh</button>
</div>
} }
else else
{ {
<div class="row g-2 align-items-center mb-2">
<div class="col-auto">
<label for="lease-filter-keyword" class="form-label mb-0 small text-muted">Search leases</label>
</div>
<div class="col-md-4 col-lg-3">
<input type="text" id="lease-filter-keyword" class="form-control form-control-sm" placeholder="MAC, address, name, client ID…" @bind="_leaseFilterKeyword" @bind:event="oninput" />
</div>
</div>
<div class="table-responsive-cards"> <div class="table-responsive-cards">
<table class="table table-bordered"> <div class="d-none d-md-block">
<thead> <table class="table table-bordered">
<tr><th>Expiry</th><th>MAC</th><th>Address</th><th>Name</th><th>Client ID</th></tr> <thead>
</thead> <tr><th>#</th><th>Expiry</th><th>MAC</th><th>Address</th><th>Name</th><th>Client ID</th><th>Control</th></tr>
<tbody> </thead>
@foreach (var e in _leases) <tbody>
{ @{ var leaseIndex = 0; }
<tr> @foreach (var e in _visibleLeases)
<td>@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</td> {
<td>@e.Mac</td> var idx = ++leaseIndex;
<td>@e.Address</td> <tr>
<td>@e.Name</td> <td>@idx</td>
<td>@e.ClientId</td> <td>@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</td>
</tr> <td>@e.Mac</td>
} <td>@e.Address</td>
</tbody> <td>@e.Name</td>
</table> <td>@e.ClientId</td>
<td>
<div class="d-flex flex-wrap gap-1">
<button type="button" class="btn btn-primary btn-sm" title="Add a new static host entry using this lease's MAC, name, and address" @onclick="() => AddStaticFromLease(e)">Add static</button>
<button type="button" class="btn btn-outline-danger btn-sm" title="Add a static host with ignore flag (dnsmasq will not assign this client a lease)" @onclick="() => AddStaticFromLeaseWithIgnore(e)">Ignore</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="d-md-none table-cards-mobile">
@{ leaseIndex = 0; }
@foreach (var e in _visibleLeases)
{
var idx = ++leaseIndex;
<div class="card mb-2">
<div class="card-body py-2">
<div class="table-card-row"><span class="table-card-label">#</span> @idx</div>
<div class="table-card-row"><span class="table-card-label">Expiry</span> @e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</div>
<div class="table-card-row"><span class="table-card-label">MAC</span> @e.Mac</div>
<div class="table-card-row"><span class="table-card-label">Address</span> @e.Address</div>
<div class="table-card-row"><span class="table-card-label">Name</span> @e.Name</div>
<div class="table-card-row"><span class="table-card-label">Client ID</span> @e.ClientId</div>
<div class="table-card-row mt-2 d-flex flex-wrap gap-1">
<button type="button" class="btn btn-primary btn-sm" title="Add a new static host entry using this lease's MAC, name, and address" @onclick="() => AddStaticFromLease(e)">Add static</button>
<button type="button" class="btn btn-outline-danger btn-sm" title="Add a static host with ignore flag (dnsmasq will not assign this client a lease)" @onclick="() => AddStaticFromLeaseWithIgnore(e)">Ignore</button>
</div>
</div>
</div>
}
</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>
</div> </div>
<p class="text-muted small">Path: @_status.LeasesPath (read-only). Cached and refreshed when the file changes.</p>
<button class="btn btn-primary btn-sm mt-1" @onclick="LoadLeases" disabled="@_leasesLoading">Refresh</button>
} }
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
</section> </section>
} }
@code { @code {
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private DnsmasqServiceStatus? _status; private DnsmasqServiceStatus? _status;
private List<DhcpHostEntry> _entries = new(); private List<DhcpHostEntry> _entries = new();
private IReadOnlyList<LeaseEntry>? _leases; private IReadOnlyList<LeaseEntry>? _leases;
private string _filterKeyword = "";
private string _leaseFilterKeyword = "";
private bool _leasesLoading; private bool _leasesLoading;
private string? _error; private string? _error;
private Timer? _leasesRefreshTimer;
private int _leasesRefreshIntervalSeconds = 5;
private string? _message; private string? _message;
private bool _settingsModalOpen;
private string _settingsModalTitle = "Client settings";
private SettingsModalContext _settingsModalContext = SettingsModalContext.All;
private bool _saving; private bool _saving;
private IEnumerable<DhcpHostEntry> _visibleStaticEntries =>
_entries.Where(x => !x.IsDeleted && StaticEntryMatchesKeyword(x, _filterKeyword));
private static bool IsEditable(DhcpHostEntry e) => e.IsEditable;
private static bool StaticEntryMatchesKeyword(DhcpHostEntry e, string keyword)
{
if (string.IsNullOrWhiteSpace(keyword)) return true;
var k = keyword.Trim();
var mac = string.Join(" ", e.MacAddresses);
return Contains(mac, k) || Contains(e.Name, k) || Contains(e.Address, k) || Contains(e.Lease, k) || Contains(e.Comment, k);
}
private static bool Contains(string? value, string keyword) =>
!string.IsNullOrEmpty(value) && value.Contains(keyword, StringComparison.OrdinalIgnoreCase);
private IEnumerable<LeaseEntry> _visibleLeases =>
_leases?.Where(e => LeaseEntryMatchesKeyword(e, _leaseFilterKeyword)) ?? Array.Empty<LeaseEntry>();
private static bool LeaseEntryMatchesKeyword(LeaseEntry e, string keyword)
{
if (string.IsNullOrWhiteSpace(keyword)) return true;
var k = keyword.Trim();
return Contains(e.Mac, k) || Contains(e.Address, k) || Contains(e.Name, k) || Contains(e.ClientId, k);
}
private static string LeaseKey(LeaseEntry e) => $"{e.Mac}|{e.Address}";
private void OnMacInput(DhcpHostEntry e, string? value)
{
SetMac(e, value);
TryFillFromLeaseByMac(e, value);
}
private void TryFillFromLeaseByMac(DhcpHostEntry e, string? value)
{
if (string.IsNullOrWhiteSpace(value) || _leases == null) return;
var mac = value.Trim();
if (mac.Contains(',')) return;
var lease = _visibleLeases.FirstOrDefault(l => string.Equals(l.Mac, mac, StringComparison.OrdinalIgnoreCase));
if (lease != null)
{
e.Name = lease.Name ?? "";
e.Address = lease.Address ?? "";
e.Lease = "infinite";
}
StateHasChanged();
}
private static readonly (string Value, string Label)[] LeaseDatalistOptions =
{
("", "(none)"),
("infinite", "Infinite"),
("3600", "1 hour"),
("21600", "6 hours"),
("43200", "12 hours"),
("86400", "24 hours"),
("604800", "7 days"),
("2592000", "30 days"),
};
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await Load(); await Load();
@@ -144,10 +413,14 @@ else
_message = null; _message = null;
try try
{ {
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300);
_status = await StatusClient.GetStatusAsync(); _status = await StatusClient.GetStatusAsync();
var entries = await DhcpHostsClient.GetDhcpHostsAsync(); var entries = await DhcpHostsClient.GetDhcpHostsAsync();
_entries = entries?.ToList() ?? new List<DhcpHostEntry>(); _entries = entries?.ToList() ?? new List<DhcpHostEntry>();
await LoadLeases(); await LoadLeases(showLoading: true);
if (_status != null && _status.LeasesPathConfigured && _leasesRefreshTimer == null)
StartLeasesRefreshTimer();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -155,10 +428,43 @@ else
} }
} }
private async Task LoadLeases() private void OpenLeasesSettings()
{
_settingsModalContext = SettingsModalContext.LeasesPolling;
_settingsModalTitle = "Leases refresh interval";
_settingsModalOpen = true;
}
private async Task CloseSettingsModal()
{
_settingsModalOpen = false;
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300);
if (_status != null && _status.LeasesPathConfigured)
StartLeasesRefreshTimer();
}
private void StartLeasesRefreshTimer()
{
_leasesRefreshTimer?.Dispose();
_leasesRefreshTimer = new Timer(
_ => _ = InvokeAsync(OnLeasesRefreshTick),
null,
TimeSpan.FromSeconds(_leasesRefreshIntervalSeconds),
TimeSpan.FromSeconds(_leasesRefreshIntervalSeconds));
}
private async Task OnLeasesRefreshTick()
{ {
if (_status == null || !_status.LeasesPathConfigured) return; if (_status == null || !_status.LeasesPathConfigured) return;
_leasesLoading = true; await LoadLeases(showLoading: false);
await InvokeAsync(StateHasChanged);
}
private async Task LoadLeases(bool showLoading = true)
{
if (_status == null || !_status.LeasesPathConfigured) return;
if (showLoading) _leasesLoading = true;
try try
{ {
var resp = await LeasesClient.GetLeasesAsync(); var resp = await LeasesClient.GetLeasesAsync();
@@ -166,16 +472,70 @@ else
} }
finally finally
{ {
_leasesLoading = false; if (showLoading) _leasesLoading = false;
} }
} }
private void Add() private void Add()
{ {
_entries.Add(new DhcpHostEntry { Id = "new:" + Guid.NewGuid().ToString("N"), LineNumber = 0, MacAddresses = new List<string>(), Extra = new List<string>() }); var nextAddress = GetNextAvailableAddress();
_entries.Add(new DhcpHostEntry
{
Id = "new:" + Guid.NewGuid().ToString("N"),
LineNumber = 0,
MacAddresses = new List<string>(),
Extra = new List<string>(),
Name = "*",
Address = nextAddress,
IsEditable = true
});
StateHasChanged(); StateHasChanged();
} }
private string? GetNextAvailableAddress()
{
if (_status == null || string.IsNullOrEmpty(_status.DhcpRangeStart) || string.IsNullOrEmpty(_status.DhcpRangeEnd))
return null;
if (!System.Net.IPAddress.TryParse(_status.DhcpRangeStart, out var startIp) || startIp.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
return null;
if (!System.Net.IPAddress.TryParse(_status.DhcpRangeEnd, out var endIp) || endIp.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
return null;
var used = new HashSet<uint>();
foreach (var e in _entries.Where(x => !x.IsDeleted && !string.IsNullOrEmpty(x.Address)))
{
if (System.Net.IPAddress.TryParse(e.Address, out var ip) && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
used.Add(ToUint(ip));
}
if (_leases != null)
{
foreach (var l in _leases)
{
if (!string.IsNullOrEmpty(l.Address) && System.Net.IPAddress.TryParse(l.Address, out var ip) && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
used.Add(ToUint(ip));
}
}
var start = ToUint(startIp);
var end = ToUint(endIp);
if (start > end) return null;
for (var u = start; u <= end; u++)
{
if (!used.Contains(u))
return FromUint(u);
}
return null;
}
private static uint ToUint(System.Net.IPAddress ip)
{
var b = ip.GetAddressBytes();
return ((uint)b[0] << 24) | ((uint)b[1] << 16) | ((uint)b[2] << 8) | b[3];
}
private static string FromUint(uint u)
{
return $"{(u >> 24) & 0xff}.{(u >> 16) & 0xff}.{(u >> 8) & 0xff}.{u & 0xff}";
}
private void Delete(DhcpHostEntry e) private void Delete(DhcpHostEntry e)
{ {
e.IsDeleted = true; e.IsDeleted = true;
@@ -200,6 +560,35 @@ else
StateHasChanged(); StateHasChanged();
} }
private void AddStaticFromLease(LeaseEntry lease)
{
AddStaticFromLeaseCore(lease, ignore: false);
}
private void AddStaticFromLeaseWithIgnore(LeaseEntry lease)
{
AddStaticFromLeaseCore(lease, ignore: true);
}
private void AddStaticFromLeaseCore(LeaseEntry lease, bool ignore)
{
_entries.Add(new DhcpHostEntry
{
Id = "new:" + Guid.NewGuid().ToString("N"),
LineNumber = 0,
IsComment = false,
IsDeleted = false,
Ignore = ignore,
MacAddresses = string.IsNullOrEmpty(lease.Mac) ? new List<string>() : new List<string> { lease.Mac },
Name = lease.Name ?? "",
Address = lease.Address ?? "",
Lease = "infinite",
Extra = new List<string>(),
Comment = null
});
StateHasChanged();
}
private async Task Save() private async Task Save()
{ {
_saving = true; _saving = true;
@@ -248,4 +637,10 @@ else
_saving = false; _saving = false;
} }
} }
public void Dispose()
{
_leasesRefreshTimer?.Dispose();
_leasesRefreshTimer = null;
}
} }
@@ -1,8 +1,9 @@
@page "/Error" @page "/Error"
@namespace DnsmasqWebUI.Components.Pages @namespace DnsmasqWebUI.Components.Pages
@using System.Diagnostics @using System.Diagnostics
@inject IOptions<ApplicationOptions> AppOptions
<PageTitle>Error | Local DNS</PageTitle> <PageTitle>Error | @AppTitle</PageTitle>
<h1 class="page-title text-danger">Error</h1> <h1 class="page-title text-danger">Error</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2> <h2 class="text-danger">An error occurred while processing your request.</h2>
@@ -29,6 +30,7 @@
[CascadingParameter] [CascadingParameter]
private HttpContext? HttpContext { get; set; } private HttpContext? HttpContext { get; set; }
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private string? RequestId { get; set; } private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
+3 -1
View File
@@ -5,9 +5,10 @@
@inject IConfigSetClient ConfigSetClient @inject IConfigSetClient ConfigSetClient
@inject IReloadClient ReloadClient @inject IReloadClient ReloadClient
@inject IClientSettingsService ClientSettingsService @inject IClientSettingsService ClientSettingsService
@inject IOptions<ApplicationOptions> AppOptions
@implements IDisposable @implements IDisposable
<PageTitle>Overview | Local DNS</PageTitle> <PageTitle>Overview | @AppTitle</PageTitle>
@if (_loading) @if (_loading)
{ {
@@ -156,6 +157,7 @@ else if (_status != null)
} }
@code { @code {
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private int _serviceStatusIntervalSeconds = 15; private int _serviceStatusIntervalSeconds = 15;
private int _recentLogsIntervalSeconds = 15; private int _recentLogsIntervalSeconds = 15;
private bool _settingsModalOpen; private bool _settingsModalOpen;
+32 -17
View File
@@ -3,8 +3,8 @@
@namespace DnsmasqWebUI.Components.Pages @namespace DnsmasqWebUI.Components.Pages
@inject IStatusClient StatusClient @inject IStatusClient StatusClient
@inject IHostsClient HostsClient @inject IHostsClient HostsClient
@inject IOptions<ApplicationOptions> AppOptions
<PageTitle>Hosts | Local DNS</PageTitle> <PageTitle>Hosts | @AppTitle</PageTitle>
<h1 class="page-title">Hosts file</h1> <h1 class="page-title">Hosts file</h1>
@@ -50,27 +50,42 @@ else
<div class="alert alert-success">@_message</div> <div class="alert alert-success">@_message</div>
} }
<div class="table-responsive-cards"> <div class="table-responsive-cards">
<table class="table table-bordered"> <div class="d-none d-md-block">
<thead> <table class="table table-bordered">
<tr><th>#</th><th>Address</th><th>Names</th></tr> <thead>
</thead> <tr><th>#</th><th>Address</th><th>Names</th></tr>
<tbody> </thead>
@foreach (var e in _entries.Where(x => !x.IsPassthrough)) <tbody>
{ @foreach (var e in _entries.Where(x => !x.IsPassthrough))
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="e.Id"> {
<td>@e.LineNumber</td> <tr class="@(e.IsComment ? "table-secondary" : "")" @key="e.Id">
<td>@e.Address</td> <td>@e.LineNumber</td>
<td>@string.Join(", ", e.Names)</td> <td>@e.Address</td>
</tr> <td>@string.Join(", ", e.Names)</td>
} </tr>
</tbody> }
</table> </tbody>
</table>
</div>
<div class="d-md-none table-cards-mobile">
@foreach (var e in _entries.Where(x => !x.IsPassthrough))
{
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "")" @key="e.Id">
<div class="card-body py-2">
<div class="table-card-row"><span class="table-card-label">#</span> @e.LineNumber</div>
<div class="table-card-row"><span class="table-card-label">Address</span> @e.Address</div>
<div class="table-card-row"><span class="table-card-label">Names</span> @string.Join(", ", e.Names)</div>
</div>
</div>
}
</div>
</div> </div>
<p class="text-muted small">Path: @_status.SystemHostsPath</p> <p class="text-muted small">Path: @_status.SystemHostsPath</p>
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button> <button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
} }
@code { @code {
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private DnsmasqServiceStatus? _status; private DnsmasqServiceStatus? _status;
private List<HostEntry> _entries = new(); private List<HostEntry> _entries = new();
private string? _error; private string? _error;
@@ -13,5 +13,7 @@
@using DnsmasqWebUI.Components.Modals @using DnsmasqWebUI.Components.Modals
@using DnsmasqWebUI.Components.Home @using DnsmasqWebUI.Components.Home
@using DnsmasqWebUI.Models @using DnsmasqWebUI.Models
@using DnsmasqWebUI.Configuration
@using DnsmasqWebUI.Client.Http.Abstractions @using DnsmasqWebUI.Client.Http.Abstractions
@using DnsmasqWebUI.Services.Abstractions @using DnsmasqWebUI.Services.Abstractions
@using Microsoft.Extensions.Options
@@ -0,0 +1,20 @@
namespace DnsmasqWebUI.Configuration;
/// <summary>
/// Application-level settings (title, etc.). Bound from "Application" config section
/// (appsettings.json or Application__* environment variables).
/// </summary>
public class ApplicationOptions
{
/// <summary>Configuration section name (e.g. "Application" for appsettings and Application__* env vars).</summary>
public const string SectionName = "Application";
/// <summary>Default application title when not configured.</summary>
public const string DefaultTitle = "Local DNS";
/// <summary>Application title shown in the navbar brand, page titles, and browser tab.</summary>
public string ApplicationTitle { get; set; } = DefaultTitle;
/// <summary>Title to use in UI (never null/empty).</summary>
public string EffectiveTitle => string.IsNullOrWhiteSpace(ApplicationTitle) ? DefaultTitle : ApplicationTitle;
}
@@ -42,6 +42,10 @@ public class DhcpController : ControllerBase
var reload = await _reloadService.ReloadAsync(ct); var reload = await _reloadService.ReloadAsync(ct);
return Ok(new SaveWithReloadResult(true, reload)); return Ok(new SaveWithReloadResult(true, reload));
} }
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
catch (Exception ex) catch (Exception ex)
{ {
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
@@ -32,6 +32,7 @@ public class StatusController : ControllerBase
var set = await _configSetService.GetConfigSetAsync(ct); var set = await _configSetService.GetConfigSetAsync(ct);
var effectiveLeasesPath = _configSetService.GetLeasesPath(); var effectiveLeasesPath = _configSetService.GetLeasesPath();
var effectiveConfig = _configSetService.GetEffectiveConfig(); var effectiveConfig = _configSetService.GetEffectiveConfig();
var (dhcpRangeStart, dhcpRangeEnd) = _configSetService.GetDhcpRange();
var systemHostsPath = _options.SystemHostsPath?.Trim(); var systemHostsPath = _options.SystemHostsPath?.Trim();
var statusResult = await _processRunner.RunAsync(_options.StatusCommand, TimeSpan.FromSeconds(5), ct); var statusResult = await _processRunner.RunAsync(_options.StatusCommand, TimeSpan.FromSeconds(5), ct);
@@ -52,7 +53,7 @@ public class StatusController : ControllerBase
var showResult = await showTask; var showResult = await showTask;
var logsResult = await logsTask; var logsResult = await logsTask;
var statusShowOutput = !string.IsNullOrWhiteSpace(_options.StatusShowCommand) var statusShowOutput = !string.IsNullOrWhiteSpace(_options.StatusShowCommand)
? showResult.Stdout + (showResult.TimedOut ? "\n(Command timed out.)" : "") ? FormatStatusShowOutput(showResult.Stdout + (showResult.TimedOut ? "\n(Command timed out.)" : ""))
: null; : null;
var logsOutput = !string.IsNullOrWhiteSpace(_options.LogsCommand) var logsOutput = !string.IsNullOrWhiteSpace(_options.LogsCommand)
? logsResult.Stdout + (logsResult.TimedOut ? "\n(Command timed out.)" : "") ? logsResult.Stdout + (logsResult.TimedOut ? "\n(Command timed out.)" : "")
@@ -83,7 +84,9 @@ public class StatusController : ControllerBase
StatusCommandStdout: dnsmasqStatus != "active" ? statusCommandStdout : null, StatusCommandStdout: dnsmasqStatus != "active" ? statusCommandStdout : null,
StatusCommandStderr: dnsmasqStatus != "active" ? statusCommandStderr : null, StatusCommandStderr: dnsmasqStatus != "active" ? statusCommandStderr : null,
StatusShowOutput: statusShowOutput, StatusShowOutput: statusShowOutput,
LogsOutput: logsOutput LogsOutput: logsOutput,
DhcpRangeStart: dhcpRangeStart,
DhcpRangeEnd: dhcpRangeEnd
); );
return Ok(status); return Ok(status);
} }
@@ -93,6 +96,33 @@ public class StatusController : ControllerBase
} }
} }
/// <summary>
/// Reformats StatusShow output: Active line gets a line break after the semicolon;
/// continuation (e.g. "4 min ago") is indented to align with the value after "Active: ".
/// </summary>
private static string FormatStatusShowOutput(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return raw;
var lines = raw.Split('\n');
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i];
var activeIdx = line.IndexOf("Active:", StringComparison.Ordinal);
if (activeIdx < 0) continue;
var semiIdx = line.IndexOf(';', activeIdx);
if (semiIdx < 0) continue;
var afterSemi = semiIdx + 1;
while (afterSemi < line.Length && line[afterSemi] == ' ') afterSemi++;
var continuation = afterSemi < line.Length ? line[afterSemi..].TrimEnd() : "";
var indentLength = activeIdx + "Active: ".Length;
var indent = new string(' ', indentLength);
lines[i] = continuation.Length > 0
? line[..(semiIdx + 1)] + "\n" + indent + continuation
: line[..(semiIdx + 1)];
}
return string.Join("\n", lines);
}
/// <summary>Returns the full log file from the path in effective config (log-facility). Untruncated.</summary> /// <summary>Returns the full log file from the path in effective config (log-facility). Untruncated.</summary>
[HttpGet("logs/download")] [HttpGet("logs/download")]
public async Task<IActionResult> GetLogsDownload(CancellationToken ct) public async Task<IActionResult> GetLogsDownload(CancellationToken ct)
+6
View File
@@ -17,4 +17,10 @@ public class DhcpHostEntry
public string? Lease { get; set; } public string? Lease { get; set; }
public List<string> Extra { get; set; } = new(); public List<string> Extra { get; set; } = new();
public string? Comment { get; set; } public string? Comment { get; set; }
/// <summary>True when this entry is from the managed file (editable). False when from main config or another file (read-only). Set by server on GET; new entries created in the UI should set this to true.</summary>
public bool IsEditable { get; set; }
/// <summary>Path to the config file this entry comes from. Set by server on GET; null for new entries not yet saved.</summary>
public string? SourcePath { get; set; }
} }
@@ -29,6 +29,8 @@ namespace DnsmasqWebUI.Models;
/// <param name="StatusCommandStderr">Stderr of StatusCommand when dnsmasq is not active. Null when active or not configured.</param> /// <param name="StatusCommandStderr">Stderr of StatusCommand when dnsmasq is not active. Null when active or not configured.</param>
/// <param name="StatusShowOutput">Output of StatusShowCommand (full service status). Null when not configured or command produced no output.</param> /// <param name="StatusShowOutput">Output of StatusShowCommand (full service status). Null when not configured or command produced no output.</param>
/// <param name="LogsOutput">Output of LogsCommand (recent logs preview). Null when not configured or command produced no output.</param> /// <param name="LogsOutput">Output of LogsCommand (recent logs preview). Null when not configured or command produced no output.</param>
/// <param name="DhcpRangeStart">Start IP of the first dhcp-range= (e.g. 172.28.0.10). Null when not set or unparseable.</param>
/// <param name="DhcpRangeEnd">End IP of the first dhcp-range= (e.g. 172.28.0.50). Null when not set or unparseable.</param>
public record DnsmasqServiceStatus( public record DnsmasqServiceStatus(
string? SystemHostsPath, string? SystemHostsPath,
bool SystemHostsPathExists, bool SystemHostsPathExists,
@@ -54,5 +56,7 @@ public record DnsmasqServiceStatus(
string? StatusCommandStdout, string? StatusCommandStdout,
string? StatusCommandStderr, string? StatusCommandStderr,
string? StatusShowOutput, string? StatusShowOutput,
string? LogsOutput string? LogsOutput,
string? DhcpRangeStart,
string? DhcpRangeEnd
); );
+4
View File
@@ -5,6 +5,10 @@ using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// ---- Application options (title, etc.) ----
builder.Services.AddOptions<ApplicationOptions>()
.Bind(builder.Configuration.GetSection(ApplicationOptions.SectionName));
// ---- Dnsmasq options (required paths validated at startup) ---- // ---- Dnsmasq options (required paths validated at startup) ----
builder.Services.AddOptions<DnsmasqOptions>() builder.Services.AddOptions<DnsmasqOptions>()
.Bind(builder.Configuration.GetSection(DnsmasqOptions.SectionName)) .Bind(builder.Configuration.GetSection(DnsmasqOptions.SectionName))
@@ -15,4 +15,7 @@ public interface IDnsmasqConfigSetService : IApplicationSingleton
/// <summary>Additional hosts paths discovered from the config set (addn-hosts=; cumulative, all in order). Empty if none.</summary> /// <summary>Additional hosts paths discovered from the config set (addn-hosts=; cumulative, all in order). Empty if none.</summary>
IReadOnlyList<string> GetAddnHostsPaths(); IReadOnlyList<string> GetAddnHostsPaths();
/// <summary>Start and end IP of the last dhcp-range= (e.g. 172.28.0.10,172.28.0.50). Parsed from the raw value; (null, null) when not set or unparseable.</summary>
(string? Start, string? End) GetDhcpRange();
} }
@@ -29,22 +29,29 @@ public class DnsmasqConfigService : IDnsmasqConfigService
public async Task<IReadOnlyList<DhcpHostEntry>> ReadDhcpHostsAsync(CancellationToken ct = default) public async Task<IReadOnlyList<DhcpHostEntry>> ReadDhcpHostsAsync(CancellationToken ct = default)
{ {
var path = await GetManagedFilePathAsync(ct); var set = await _configSetService.GetConfigSetAsync(ct);
if (string.IsNullOrEmpty(path)) if (string.IsNullOrEmpty(set.ManagedFilePath))
{ {
_logger.LogDebug("No managed file path (no conf-dir in main config); returning empty dhcp hosts"); _logger.LogDebug("No managed file path (no conf-dir in main config); returning empty dhcp hosts");
return Array.Empty<DhcpHostEntry>(); return Array.Empty<DhcpHostEntry>();
} }
if (!File.Exists(path)) var allEntries = new List<DhcpHostEntry>();
foreach (var file in set.Files)
{ {
_logger.LogWarning("Managed config file not found: {Path}", path); if (!File.Exists(file.Path))
return Array.Empty<DhcpHostEntry>(); continue;
var lines = await File.ReadAllLinesAsync(file.Path, Encoding.UTF8, ct);
var configLines = DnsmasqConfFileLineParser.ParseFile(lines);
var entries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList();
foreach (var e in entries)
{
e.SourcePath = file.Path;
e.IsEditable = file.IsManaged;
}
allEntries.AddRange(entries);
} }
var lines = await File.ReadAllLinesAsync(path, Encoding.UTF8, ct); AssignStableIds(allEntries);
var configLines = DnsmasqConfFileLineParser.ParseFile(lines); return allEntries;
var entries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList();
AssignStableIds(entries);
return entries;
} }
/// <summary>Assign stable Ids so we can match by Id on write (reorder-safe). Content-based; ":LineNumber" appended for uniqueness when needed.</summary> /// <summary>Assign stable Ids so we can match by Id on write (reorder-safe). Content-based; ":LineNumber" appended for uniqueness when needed.</summary>
@@ -84,10 +91,23 @@ public class DnsmasqConfigService : IDnsmasqConfigService
public async Task WriteDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default) public async Task WriteDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default)
{ {
var path = await GetManagedFilePathAsync(ct); var set = await _configSetService.GetConfigSetAsync(ct);
var path = set.ManagedFilePath;
if (string.IsNullOrEmpty(path)) if (string.IsNullOrEmpty(path))
throw new InvalidOperationException("No managed file path (main config has no conf-dir). Cannot write dhcp hosts."); throw new InvalidOperationException("No managed file path (main config has no conf-dir). Cannot write dhcp hosts.");
var managedEntries = entries.Where(e => e.IsEditable).ToList();
var externalMacs = await GetMacsFromNonManagedFilesAsync(set, ct);
foreach (var e in managedEntries.Where(e => !e.IsDeleted))
{
foreach (var mac in e.MacAddresses.Where(m => !string.IsNullOrWhiteSpace(m)))
{
var normalized = mac.Trim();
if (externalMacs.TryGetValue(normalized, out var sourcePath))
throw new ArgumentException($"MAC {mac} is already defined in {sourcePath}. Remove it from that file or use a different MAC.");
}
}
var dir = Path.GetDirectoryName(path); var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
@@ -103,7 +123,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
var fileEntries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList(); var fileEntries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList();
AssignStableIds(fileEntries); AssignStableIds(fileEntries);
var byId = entries.Where(e => !string.IsNullOrEmpty(e.Id)).GroupBy(e => e.Id, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); var byId = managedEntries.Where(e => !string.IsNullOrEmpty(e.Id)).GroupBy(e => e.Id, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
var matchedIds = new HashSet<string>(StringComparer.Ordinal); var matchedIds = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < configLines.Count; i++) for (var i = 0; i < configLines.Count; i++)
@@ -114,7 +134,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
configLines[i] = new DhcpHostLine { LineNumber = dhcpLine.LineNumber, DhcpHost = replacement }; configLines[i] = new DhcpHostLine { LineNumber = dhcpLine.LineNumber, DhcpHost = replacement };
} }
var appended = entries.Where(e => (string.IsNullOrEmpty(e.Id) || !matchedIds.Contains(e.Id)) && !e.IsDeleted).ToList(); var appended = managedEntries.Where(e => (string.IsNullOrEmpty(e.Id) || !matchedIds.Contains(e.Id)) && !e.IsDeleted).ToList();
var output = configLines.Select(DnsmasqConfFileLineParser.ToLine).ToList(); var output = configLines.Select(DnsmasqConfFileLineParser.ToLine).ToList();
foreach (var entry in appended) foreach (var entry in appended)
output.Add(DnsmasqConfDhcpHostLineParser.ToLine(entry)); output.Add(DnsmasqConfDhcpHostLineParser.ToLine(entry));
@@ -125,6 +145,29 @@ public class DnsmasqConfigService : IDnsmasqConfigService
_logger.LogInformation("Wrote managed config file: {Path}", path); _logger.LogInformation("Wrote managed config file: {Path}", path);
} }
/// <summary>Returns MAC -> source file path for all dhcp-host MACs in non-managed config files (for duplicate validation).</summary>
private static async Task<Dictionary<string, string>> GetMacsFromNonManagedFilesAsync(DnsmasqConfigSet set, CancellationToken ct)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var managedPath = set.ManagedFilePath ?? "";
foreach (var file in set.Files.Where(f => !f.IsManaged && !string.Equals(f.Path, managedPath, StringComparison.Ordinal)))
{
if (!File.Exists(file.Path)) continue;
var lines = await File.ReadAllLinesAsync(file.Path, Encoding.UTF8, ct);
var configLines = DnsmasqConfFileLineParser.ParseFile(lines);
foreach (var dhcp in configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost))
{
foreach (var mac in dhcp.MacAddresses.Where(m => !string.IsNullOrWhiteSpace(m)))
{
var normalized = mac.Trim();
if (normalized.Length > 0 && !result.ContainsKey(normalized))
result[normalized] = file.Path;
}
}
}
return result;
}
public async Task<ManagedConfigContent> ReadManagedConfigAsync(CancellationToken ct = default) public async Task<ManagedConfigContent> ReadManagedConfigAsync(CancellationToken ct = default)
{ {
var path = await GetManagedFilePathAsync(ct); var path = await GetManagedFilePathAsync(ct);
@@ -120,6 +120,36 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
return GetEffectiveConfig().AddnHostsPaths; return GetEffectiveConfig().AddnHostsPaths;
} }
/// <inheritdoc />
public (string? Start, string? End) GetDhcpRange()
{
var set = GetConfigSet();
if (set.Files.Count == 0)
return (null, null);
var paths = set.Files.Select(f => f.Path).ToList();
var (raw, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "dhcp-range");
return ParseDhcpRangeStartEnd(raw);
}
/// <summary>Parses dhcp-range value to (startIp, endIp). Format is typically start,end,mask,lease or tag:...,start,end,...; finds first two IPv4-looking tokens.</summary>
internal static (string? Start, string? End) ParseDhcpRangeStartEnd(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return (null, null);
var parts = raw.Split(',');
string? start = null;
string? end = null;
foreach (var p in parts)
{
var t = p.Trim();
if (string.IsNullOrEmpty(t)) continue;
if (!System.Net.IPAddress.TryParse(t, out var ip) || ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
continue;
if (start == null) { start = t; continue; }
if (end == null) { end = t; break; }
}
return (start, end);
}
private DnsmasqConfigSet GetConfigSet() private DnsmasqConfigSet GetConfigSet()
{ {
var mainPath = _options.MainConfigPath; var mainPath = _options.MainConfigPath;
+3
View File
@@ -1,4 +1,7 @@
{ {
"Application": {
"ApplicationTitle": "Local DNS"
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
+38
View File
@@ -162,11 +162,49 @@ h1:focus {
overflow-wrap: break-word; overflow-wrap: break-word;
} }
/* Table: desktop shows table, mobile shows card list (via d-none d-md-block / d-md-none in markup) */
.table-responsive-cards { .table-responsive-cards {
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.table-responsive-cards .d-none.d-md-block {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-cards-mobile .card .table-card-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.75rem;
align-items: baseline;
font-size: 0.9375rem;
}
.table-cards-mobile .card .table-card-row + .table-card-row {
margin-top: 0.25rem;
}
.table-card-label {
font-size: 0.8125rem;
color: var(--bs-secondary);
min-width: 4.5rem;
flex-shrink: 0;
}
.table-cards-mobile .table-card-row .form-control,
.table-cards-mobile .table-card-row .form-select,
.table-cards-mobile .table-card-row .table-card-control {
flex: 1;
min-width: 0;
}
/* Align checkboxes with other inputs in card mode: control starts at same column */
.table-cards-mobile .table-card-row .table-card-control {
display: flex;
align-items: center;
}
/* Settings gear icon size (Bootstrap Icons) */ /* Settings gear icon size (Bootstrap Icons) */
.bi-gear-sm { .bi-gear-sm {
font-size: 1.125rem; font-size: 1.125rem;
@@ -7,7 +7,8 @@
export function initDialog(dialogElement, dotNetRef) { export function initDialog(dialogElement, dotNetRef) {
if (!dialogElement) return; if (!dialogElement) return;
dialogElement.addEventListener('close', function handler() { dialogElement.addEventListener('close', function handler() {
dotNetRef.invokeMethodAsync('OnDialogClosed', dialogElement.returnValue ?? ''); // Ignore errors on page refresh/unload when the Blazor circuit is already gone
dotNetRef.invokeMethodAsync('OnDialogClosed', dialogElement.returnValue ?? '').catch(() => {});
}); });
} }