Dnsmasq/Hosts UI tweaks, test harness: down before redeploy, exclude leases from mount

- Dnsmasq page: Config (Main config, Managed file, Config set) under Service configuration; remove Paths/Leases (in Effective config DHCP); EffectiveConfigSection collapsible groups; StatusSection flex-wrap
- Hosts: HostsFileSection always shows table; 'No host entries' in tbody when empty
- Models: reorganize into sub-namespaces (Config, EffectiveConfig, Hosts, Dhcp, Status); move EffectiveConfig/Status/Logs components under Dnsmasq
- Test harness: prepare-test-mount.sh runs 'docker compose down' before mount+up so all containers (including DHCP clients) restart; exclude testdata/leases from sync (unit tests only); testdata/README
This commit is contained in:
2026-02-03 01:35:26 +10:00
parent a0863a663e
commit a8ddcdec4e
70 changed files with 542 additions and 303 deletions
@@ -1,4 +1,4 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
<li>
<strong>@Label:</strong> @Content
@@ -1,4 +1,4 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
<li title="@(Source?.GetReadOnlyTooltip())">
<strong>@Label:</strong> @DisplayValue
@@ -0,0 +1,178 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Status
@using DnsmasqWebUI.Models.EffectiveConfig
@if (Status != null)
{
<div class="ec-sections small">
@* Hosts *@
<div class="ec-section">
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("hosts")' aria-expanded="@IsOpen("hosts")">
<span class="ec-section-title fw-bold">Hosts</span>
<i class="bi @(IsOpen("hosts") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
</button>
<div class="collapse @(IsOpen("hosts") ? "show" : "")">
<div class="ec-section-body">
<ul class="list-unstyled mb-0">
<EffectiveConfigOptionItem Label="no-hosts" DisplayValue="@(Status.NoHosts ? "Yes (hosts files disabled)" : "No")" Source="@(Status.EffectiveConfigSources?.NoHosts)" />
<li>
<strong>addn-hosts:</strong>
@if (Status.AddnHostsPaths?.Count > 0)
{
var srcList = Status.EffectiveConfigSources?.AddnHostsPaths;
if (srcList != null && srcList.Count == Status.AddnHostsPaths.Count)
{
@foreach (var entry in srcList)
{
<span class="d-inline-block me-2" title="@(entry.Source?.GetReadOnlyTooltip())">@entry.Path @(entry.Source?.IsReadOnly == true ? " (readonly)" : "")</span>
}
}
else
{
var deduped = (Status.AddnHostsPaths ?? Array.Empty<string>()).Distinct().ToList();
@(deduped.Count == 1 ? deduped[0] : string.Join(", ", deduped))
}
}
else { <span class="text-muted">(none)</span> }
</li>
@if (Status.ManagedHostsFilePath != null) { <li><strong>Managed hosts file (editable):</strong> @Status.ManagedHostsFilePath @(Status.ManagedHostsPathExists ? "✓" : "(not created yet)")</li> }
@if (Status.SystemHostsPath != null) { <li><strong>System hosts (read-only):</strong> @Status.SystemHostsPath @(Status.SystemHostsPathExists ? "✓" : "(missing)")</li> }
</ul>
</div>
</div>
</div>
@if (Status.EffectiveConfig != null)
{
var ec = Status.EffectiveConfig;
var src = Status.EffectiveConfigSources;
@* Resolver / DNS *@
<div class="ec-section">
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("resolver")' aria-expanded="@IsOpen("resolver")">
<span class="ec-section-title fw-bold">Resolver / DNS</span>
<i class="bi @(IsOpen("resolver") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
</button>
<div class="collapse @(IsOpen("resolver") ? "show" : "")">
<div class="ec-section-body">
<ul class="list-unstyled mb-0">
<EffectiveConfigOptionItem Label="expand-hosts" DisplayValue="@(ec.ExpandHosts ? "Yes" : "No")" Source="@(src?.ExpandHosts)" />
<EffectiveConfigOptionItem Label="bogus-priv" DisplayValue="@(ec.BogusPriv ? "Yes" : "No")" Source="@(src?.BogusPriv)" />
<EffectiveConfigOptionItem Label="strict-order" DisplayValue="@(ec.StrictOrder ? "Yes" : "No")" Source="@(src?.StrictOrder)" />
<EffectiveConfigOptionItem Label="no-resolv" DisplayValue="@(ec.NoResolv ? "Yes" : "No")" Source="@(src?.NoResolv)" />
<EffectiveConfigOptionItem Label="domain-needed" DisplayValue="@(ec.DomainNeeded ? "Yes" : "No")" Source="@(src?.DomainNeeded)" />
<EffectiveConfigOptionItem Label="port" DisplayValue="@(ec.Port switch { null => "53 (default)", 0 => "0 (no DNS listener)", _ => ec.Port.Value.ToString() })" Source="@(src?.Port)" />
@if (ec.ServerLocalValues?.Count > 0) { <EffectiveConfigMultiValueRow Label="server / local" Content="@((MarkupString)RenderMultiValueList(ec.ServerLocalValues, src?.ServerLocalValues))" /> }
@if (ec.AddressValues?.Count > 0) { <EffectiveConfigMultiValueRow Label="address" Content="@((MarkupString)RenderMultiValueList(ec.AddressValues, src?.AddressValues))" /> }
@if (ec.ResolvFiles?.Count > 0) { <EffectiveConfigMultiValueRow Label="resolv-file" Content="@((MarkupString)RenderMultiValueList(ec.ResolvFiles, src?.ResolvFiles))" /> }
</ul>
</div>
</div>
</div>
@* DHCP *@
<div class="ec-section">
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("dhcp")' aria-expanded="@IsOpen("dhcp")">
<span class="ec-section-title fw-bold">DHCP</span>
<i class="bi @(IsOpen("dhcp") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
</button>
<div class="collapse @(IsOpen("dhcp") ? "show" : "")">
<div class="ec-section-body">
<ul class="list-unstyled mb-0">
<EffectiveConfigOptionItem Label="dhcp-authoritative" DisplayValue="@(ec.DhcpAuthoritative ? "Yes" : "No")" Source="@(src?.DhcpAuthoritative)" />
<EffectiveConfigOptionItem Label="leasefile-ro" DisplayValue="@(ec.LeasefileRo ? "Yes" : "No")" Source="@(src?.LeasefileRo)" />
<EffectiveConfigOptionItem Label="dhcp-leasefile" DisplayValue="@(ec.DhcpLeaseFilePath ?? "(default)")" Source="@(src?.DhcpLeaseFilePath)" />
@if (ec.DhcpLeaseMax.HasValue) { <EffectiveConfigOptionItem Label="dhcp-lease-max" DisplayValue="@ec.DhcpLeaseMax.Value.ToString()" Source="@(src?.DhcpLeaseMax)" /> }
@if (ec.DhcpTtl.HasValue) { <EffectiveConfigOptionItem Label="dhcp-ttl" DisplayValue="@ec.DhcpTtl.Value.ToString()" Source="@(src?.DhcpTtl)" /> }
@if (ec.DhcpRanges?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-range" Content="@((MarkupString)RenderMultiValueList(ec.DhcpRanges, src?.DhcpRanges))" /> }
@if (ec.DhcpHostLines?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-host" Content="@((MarkupString)RenderMultiValueList(ec.DhcpHostLines, src?.DhcpHostLines))" /> }
@if (ec.DhcpOptionLines?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-option" Content="@((MarkupString)RenderMultiValueList(ec.DhcpOptionLines, src?.DhcpOptionLines))" /> }
</ul>
</div>
</div>
</div>
@* Cache *@
<div class="ec-section">
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("cache")' aria-expanded="@IsOpen("cache")">
<span class="ec-section-title fw-bold">Cache</span>
<i class="bi @(IsOpen("cache") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
</button>
<div class="collapse @(IsOpen("cache") ? "show" : "")">
<div class="ec-section-body">
<ul class="list-unstyled mb-0">
@if (ec.CacheSize.HasValue) { <EffectiveConfigOptionItem Label="cache-size" DisplayValue="@ec.CacheSize.Value.ToString()" Source="@(src?.CacheSize)" /> }
@if (ec.LocalTtl.HasValue) { <EffectiveConfigOptionItem Label="local-ttl" DisplayValue="@ec.LocalTtl.Value.ToString()" Source="@(src?.LocalTtl)" /> }
<EffectiveConfigOptionItem Label="no-negcache" DisplayValue="@(ec.NoNegcache ? "Yes" : "No")" Source="@(src?.NoNegcache)" />
@if (ec.NegTtl.HasValue) { <EffectiveConfigOptionItem Label="neg-ttl" DisplayValue="@ec.NegTtl.Value.ToString()" Source="@(src?.NegTtl)" /> }
@if (ec.MaxTtl.HasValue) { <EffectiveConfigOptionItem Label="max-ttl" DisplayValue="@ec.MaxTtl.Value.ToString()" Source="@(src?.MaxTtl)" /> }
@if (ec.MaxCacheTtl.HasValue) { <EffectiveConfigOptionItem Label="max-cache-ttl" DisplayValue="@ec.MaxCacheTtl.Value.ToString()" Source="@(src?.MaxCacheTtl)" /> }
@if (ec.MinCacheTtl.HasValue) { <EffectiveConfigOptionItem Label="min-cache-ttl" DisplayValue="@ec.MinCacheTtl.Value.ToString()" Source="@(src?.MinCacheTtl)" /> }
</ul>
</div>
</div>
</div>
@* Process & networking *@
<div class="ec-section">
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("process")' aria-expanded="@IsOpen("process")">
<span class="ec-section-title fw-bold">Process &amp; networking</span>
<i class="bi @(IsOpen("process") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
</button>
<div class="collapse @(IsOpen("process") ? "show" : "")">
<div class="ec-section-body">
<ul class="list-unstyled mb-0">
<EffectiveConfigOptionItem Label="no-poll" DisplayValue="@(ec.NoPoll ? "Yes" : "No")" Source="@(src?.NoPoll)" />
<EffectiveConfigOptionItem Label="bind-interfaces" DisplayValue="@(ec.BindInterfaces ? "Yes" : "No")" Source="@(src?.BindInterfaces)" />
@if (ec.Interfaces?.Count > 0) { <EffectiveConfigMultiValueRow Label="interface" Content="@((MarkupString)RenderMultiValueList(ec.Interfaces, src?.Interfaces))" /> }
@if (ec.ListenAddresses?.Count > 0) { <EffectiveConfigMultiValueRow Label="listen-address" Content="@((MarkupString)RenderMultiValueList(ec.ListenAddresses, src?.ListenAddresses))" /> }
@if (ec.ExceptInterfaces?.Count > 0) { <EffectiveConfigMultiValueRow Label="except-interface" Content="@((MarkupString)RenderMultiValueList(ec.ExceptInterfaces, src?.ExceptInterfaces))" /> }
@if (ec.PidFilePath != null) { <EffectiveConfigOptionItem Label="pid-file" DisplayValue="@ec.PidFilePath" Source="@(src?.PidFilePath)" /> }
@if (ec.User != null) { <EffectiveConfigOptionItem Label="user" DisplayValue="@ec.User" Source="@(src?.User)" /> }
@if (ec.Group != null) { <EffectiveConfigOptionItem Label="group" DisplayValue="@ec.Group" Source="@(src?.Group)" /> }
@if (ec.LogFacility != null) { <EffectiveConfigOptionItem Label="log-facility" DisplayValue="@ec.LogFacility" Source="@(src?.LogFacility)" /> }
</ul>
</div>
</div>
</div>
}
</div>
}
@code {
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
private HashSet<string> _openPanels = new();
private bool IsOpen(string id) => _openPanels.Contains(id);
private void TogglePanel(string id)
{
if (_openPanels.Contains(id))
_openPanels.Remove(id);
else
_openPanels.Add(id);
}
private static MarkupString RenderMultiValueList(IReadOnlyList<string>? values, IReadOnlyList<ValueWithSource>? sources)
{
if (values == null || values.Count == 0)
return (MarkupString)"(none)";
if (sources != null && sources.Count == values.Count)
{
var parts = sources.Select(e =>
{
var s = e.Source;
var valueEnc = System.Net.WebUtility.HtmlEncode(e.Value);
if (s?.IsReadOnly == true)
{
var tipEnc = System.Net.WebUtility.HtmlEncode(s.GetReadOnlyTooltip() ?? "");
return $"{valueEnc} <span class=\"badge bg-secondary ms-1\" title=\"{tipEnc}\">readonly</span>";
}
return valueEnc;
});
return (MarkupString)string.Join(", ", parts);
}
return (MarkupString)System.Net.WebUtility.HtmlEncode(string.Join(", ", values));
}
}
@@ -0,0 +1,79 @@
/* Effective config sections – mobile-first, touch-friendly */
.ec-sections {
display: flex;
flex-direction: column;
gap: 0;
}
.ec-section {
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
}
.ec-section:last-child {
border-bottom: none;
}
/* Touch-friendly header: min 44px tap target (WCAG 2.5.5) */
.ec-section-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 2.75rem;
padding: 0.4rem 0;
margin: 0;
border: none;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
font-size: inherit;
line-height: 1.3;
-webkit-tap-highlight-color: transparent;
}
.ec-section-header:hover {
color: var(--bs-primary, #0d6efd);
}
.ec-section-header:focus-visible {
outline: 2px solid var(--bs-primary, #0d6efd);
outline-offset: 2px;
}
.ec-section-title {
flex: 1;
min-width: 0;
}
.ec-section-chevron {
flex-shrink: 0;
font-size: 0.75rem;
opacity: 0.7;
margin-left: 0.25rem;
}
.ec-section-body {
padding: 0.25rem 0 0.5rem 0;
padding-left: 0.5rem;
border-left: 2px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
overflow-wrap: break-word;
word-break: break-word;
}
/* Long paths/values wrap on small screens; badges stay inline */
.ec-section-body ul {
overflow-wrap: break-word;
word-break: break-word;
}
.ec-section-body li {
overflow-wrap: break-word;
word-break: break-word;
}
@media (max-width: 767.98px) {
.ec-section-body {
padding-left: 0.35rem;
}
}
@@ -1,5 +1,5 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Status;
using Microsoft.AspNetCore.Components;
namespace DnsmasqWebUI.Components.Dnsmasq;
@@ -1,9 +1,15 @@
@namespace DnsmasqWebUI.Components.Dnsmasq
@* Service status — output of StatusShowCommand; only this component re-renders on its timer *@
@if (_status?.StatusShowConfigured == true)
@if (_status != null && _status.StatusShowConfigured)
{
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between">
<strong>Service status</strong>
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between flex-wrap gap-1">
<span class="d-flex align-items-center gap-1">
<strong>Service status</strong>
@{
var isActive = _status.DnsmasqStatus == "active";
}
<span class="status-dot d-inline-block rounded-circle flex-shrink-0 @(isActive ? "bg-success" : "bg-danger")" style="width: 0.5rem; height: 0.5rem;" title="@(isActive ? "Running" : "Not running")" aria-hidden="true"></span>
</span>
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Service status polling settings" @onclick="() => OnOpenSettings.InvokeAsync()" title="Configure service status polling interval">
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
</button>
@@ -1,5 +1,5 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Status;
using Microsoft.AspNetCore.Components;
namespace DnsmasqWebUI.Components.Dnsmasq;
@@ -1,3 +1,4 @@
@namespace DnsmasqWebUI.Components.Hosts
<div class="hosts-file-section">
<h3 class="h6 @(IsFirst ? "mb-1" : "mt-3 mb-1") @(IsEditable ? "" : "text-muted")">
@Path
@@ -17,27 +18,41 @@
<tr><th>#</th><th>Address</th><th>Names</th></tr>
</thead>
<tbody>
@foreach (var e in (Entries ?? Array.Empty<HostEntry>()).Where(x => !x.IsPassthrough))
@if (_visibleEntries.Count == 0)
{
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="@(e.Id ?? ("line:" + e.LineNumber))">
<td>@e.LineNumber</td>
<td>@e.Address</td>
<td>@string.Join(", ", e.Names)</td>
</tr>
<tr><td colspan="3" class="text-muted small">No host entries.</td></tr>
}
else
{
@foreach (var e in _visibleEntries)
{
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="@(e.Id ?? ("line:" + e.LineNumber))">
<td>@e.LineNumber</td>
<td>@e.Address</td>
<td>@string.Join(", ", e.Names)</td>
</tr>
}
}
</tbody>
</table>
</div>
<div class="d-md-none table-cards-mobile">
@foreach (var e in (Entries ?? Array.Empty<HostEntry>()).Where(x => !x.IsPassthrough))
@if (_visibleEntries.Count == 0)
{
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "")" @key="@(e.Id ?? ("line:" + e.LineNumber))">
<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>
<p class="text-muted small mb-0">No host entries.</p>
}
else
{
@foreach (var e in _visibleEntries)
{
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "")" @key="@(e.Id ?? ("line:" + e.LineNumber))">
<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>
@@ -48,4 +63,11 @@
[Parameter] public IReadOnlyList<HostEntry>? Entries { get; set; }
[Parameter] public bool IsEditable { get; set; }
[Parameter] public bool IsFirst { get; set; }
private IReadOnlyList<HostEntry> _visibleEntries = Array.Empty<HostEntry>();
protected override void OnParametersSet()
{
_visibleEntries = (Entries ?? Array.Empty<HostEntry>()).Where(x => !x.IsPassthrough).ToList();
}
}
@@ -1 +0,0 @@
@namespace DnsmasqWebUI.Components.Hosts
+27 -172
View File
@@ -21,18 +21,33 @@ else if (_error != null)
else if (_status != null)
{
<section class="page-section">
<h2 class="page-section-title">Service</h2>
<div class="alert alert-@(_status.DnsmasqStatus == "active" ? "success" : "secondary") py-2 mb-2">
<strong>Dnsmasq:</strong> @GetStatusLabel(_status)
@if (_status.StatusCommandExitCode.HasValue)
{
<span class="ms-1 text-muted small">(exit @_status.StatusCommandExitCode.Value)</span>
}
@if (!string.IsNullOrEmpty(_status.StatusCommandStderr))
{
<span class="ms-1 text-muted small">— @_status.StatusCommandStderr</span>
}
</div>
<h2 class="page-section-title">Service configuration</h2>
<ul class="list-unstyled small mb-0">
<li><strong>Main config:</strong> @(_status.MainConfigPath ?? "—") @(_status.MainConfigPathExists ? "✓" : "(missing)")</li>
<li><strong>Managed file:</strong> @(_status.ManagedFilePath ?? "—") @(_status.ManagedFilePathExists ? "✓" : "(not created yet)")</li>
</ul>
@if (_configSet != null && _configSet.Files?.Count > 0)
{
<p class="mb-1 mt-2"><strong>Config set (load order):</strong></p>
<ul class="list-unstyled small ms-2 mb-0">
@foreach (var f in _configSet.Files)
{
<li>
@if (f.IsManaged)
{
<span class="badge bg-primary me-1">managed</span>
}
else
{
<span class="badge bg-secondary me-1">@(f.Source.ToString().ToLowerInvariant())</span>
}
@f.FileName
<span class="text-muted">(@f.Path)</span>
</li>
}
</ul>
}
<EffectiveConfigSection Status="_status" />
@if (_status.DnsmasqStatus != "active" && (_status.StatusCommandStdout != null || _status.StatusCommandStderr != null))
{
<pre class="pre-block mt-2 mb-0" style="max-height: 12rem;">@(_status.StatusCommandStdout != null ? _status.StatusCommandStdout : "")@(_status.StatusCommandStdout != null && _status.StatusCommandStderr != null ? "\n" : "")@(_status.StatusCommandStderr ?? "")</pre>
@@ -63,135 +78,6 @@ else if (_status != null)
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
</section>
<section class="page-section">
<h2 class="page-section-title">Effective config</h2>
<p class="text-muted small mb-2">What dnsmasq uses after reading all config files (main + conf-file + conf-dir). Values from a non-managed file are readonly (edit that file to change).</p>
<ul class="list-unstyled small">
<EffectiveConfigOptionItem Label="no-hosts" DisplayValue="@(_status.NoHosts ? "Yes (hosts files disabled)" : "No")" Source="@(_status.EffectiveConfigSources?.NoHosts)" />
<li>
<strong>addn-hosts:</strong>
@if (_status.AddnHostsPaths?.Count > 0)
{
var srcList = _status.EffectiveConfigSources?.AddnHostsPaths;
if (srcList != null && srcList.Count == _status.AddnHostsPaths.Count)
{
@foreach (var entry in srcList)
{
<span class="d-inline-block me-2" title="@(entry.Source?.GetReadOnlyTooltip())">@entry.Path @(entry.Source?.IsReadOnly == true ? " (readonly)" : "")</span>
}
}
else
{
var deduped = (_status.AddnHostsPaths ?? Array.Empty<string>()).Distinct().ToList();
@(deduped.Count == 1 ? deduped[0] : string.Join(", ", deduped))
}
}
else
{
<span class="text-muted">(none)</span>
}
</li>
@if (_status.ManagedHostsFilePath != null)
{
<li><strong>Managed hosts file (editable):</strong> @_status.ManagedHostsFilePath @(_status.ManagedHostsPathExists ? "✓" : "(not created yet)")</li>
}
@if (_status.SystemHostsPath != null)
{
<li><strong>System hosts (read-only):</strong> @_status.SystemHostsPath @(_status.SystemHostsPathExists ? "✓" : "(missing)")</li>
}
</ul>
@if (_status.EffectiveConfig != null)
{
var ec = _status.EffectiveConfig;
var src = _status.EffectiveConfigSources;
<p class="mb-1 mt-2 text-muted small"><strong>Options</strong> (last wins for single-value; readonly when from a non-managed file):</p>
<ul class="list-unstyled small ms-2">
<EffectiveConfigOptionItem Label="expand-hosts" DisplayValue="@(ec.ExpandHosts ? "Yes" : "No")" Source="@(src?.ExpandHosts)" />
<EffectiveConfigOptionItem Label="bogus-priv" DisplayValue="@(ec.BogusPriv ? "Yes" : "No")" Source="@(src?.BogusPriv)" />
<EffectiveConfigOptionItem Label="strict-order" DisplayValue="@(ec.StrictOrder ? "Yes" : "No")" Source="@(src?.StrictOrder)" />
<EffectiveConfigOptionItem Label="no-resolv" DisplayValue="@(ec.NoResolv ? "Yes" : "No")" Source="@(src?.NoResolv)" />
<EffectiveConfigOptionItem Label="domain-needed" DisplayValue="@(ec.DomainNeeded ? "Yes" : "No")" Source="@(src?.DomainNeeded)" />
<EffectiveConfigOptionItem Label="dhcp-authoritative" DisplayValue="@(ec.DhcpAuthoritative ? "Yes" : "No")" Source="@(src?.DhcpAuthoritative)" />
<EffectiveConfigOptionItem Label="no-poll" DisplayValue="@(ec.NoPoll ? "Yes" : "No")" Source="@(src?.NoPoll)" />
<EffectiveConfigOptionItem Label="bind-interfaces" DisplayValue="@(ec.BindInterfaces ? "Yes" : "No")" Source="@(src?.BindInterfaces)" />
<EffectiveConfigOptionItem Label="no-negcache" DisplayValue="@(ec.NoNegcache ? "Yes" : "No")" Source="@(src?.NoNegcache)" />
<EffectiveConfigOptionItem Label="leasefile-ro" DisplayValue="@(ec.LeasefileRo ? "Yes" : "No")" Source="@(src?.LeasefileRo)" />
<EffectiveConfigOptionItem Label="dhcp-leasefile" DisplayValue="@(ec.DhcpLeaseFilePath ?? "(default)")" Source="@(src?.DhcpLeaseFilePath)" />
@if (ec.CacheSize.HasValue) { <EffectiveConfigOptionItem Label="cache-size" DisplayValue="@ec.CacheSize.Value.ToString()" Source="@(src?.CacheSize)" /> }
<EffectiveConfigOptionItem Label="port" DisplayValue="@(ec.Port switch { null => "53 (default)", 0 => "0 (no DNS listener)", _ => ec.Port.Value.ToString() })" Source="@(src?.Port)" />
@if (ec.LocalTtl.HasValue) { <EffectiveConfigOptionItem Label="local-ttl" DisplayValue="@ec.LocalTtl.Value.ToString()" Source="@(src?.LocalTtl)" /> }
@if (ec.PidFilePath != null) { <EffectiveConfigOptionItem Label="pid-file" DisplayValue="@ec.PidFilePath" Source="@(src?.PidFilePath)" /> }
@if (ec.User != null) { <EffectiveConfigOptionItem Label="user" DisplayValue="@ec.User" Source="@(src?.User)" /> }
@if (ec.Group != null) { <EffectiveConfigOptionItem Label="group" DisplayValue="@ec.Group" Source="@(src?.Group)" /> }
@if (ec.LogFacility != null) { <EffectiveConfigOptionItem Label="log-facility" DisplayValue="@ec.LogFacility" Source="@(src?.LogFacility)" /> }
@if (ec.DhcpLeaseMax.HasValue) { <EffectiveConfigOptionItem Label="dhcp-lease-max" DisplayValue="@ec.DhcpLeaseMax.Value.ToString()" Source="@(src?.DhcpLeaseMax)" /> }
@if (ec.NegTtl.HasValue) { <EffectiveConfigOptionItem Label="neg-ttl" DisplayValue="@ec.NegTtl.Value.ToString()" Source="@(src?.NegTtl)" /> }
@if (ec.MaxTtl.HasValue) { <EffectiveConfigOptionItem Label="max-ttl" DisplayValue="@ec.MaxTtl.Value.ToString()" Source="@(src?.MaxTtl)" /> }
@if (ec.MaxCacheTtl.HasValue) { <EffectiveConfigOptionItem Label="max-cache-ttl" DisplayValue="@ec.MaxCacheTtl.Value.ToString()" Source="@(src?.MaxCacheTtl)" /> }
@if (ec.MinCacheTtl.HasValue) { <EffectiveConfigOptionItem Label="min-cache-ttl" DisplayValue="@ec.MinCacheTtl.Value.ToString()" Source="@(src?.MinCacheTtl)" /> }
@if (ec.DhcpTtl.HasValue) { <EffectiveConfigOptionItem Label="dhcp-ttl" DisplayValue="@ec.DhcpTtl.Value.ToString()" Source="@(src?.DhcpTtl)" /> }
</ul>
<p class="mb-1 mt-2 text-muted small"><strong>Multi-value options</strong> (server/local, address, interface, etc.; each with source when known):</p>
<ul class="list-unstyled small ms-2">
@if (ec.ServerLocalValues?.Count > 0) { <EffectiveConfigMultiValueRow Label="server / local" Content="@((MarkupString)RenderMultiValueList(ec.ServerLocalValues, src?.ServerLocalValues))" /> }
@if (ec.AddressValues?.Count > 0) { <EffectiveConfigMultiValueRow Label="address" Content="@((MarkupString)RenderMultiValueList(ec.AddressValues, src?.AddressValues))" /> }
@if (ec.Interfaces?.Count > 0) { <EffectiveConfigMultiValueRow Label="interface" Content="@((MarkupString)RenderMultiValueList(ec.Interfaces, src?.Interfaces))" /> }
@if (ec.ListenAddresses?.Count > 0) { <EffectiveConfigMultiValueRow Label="listen-address" Content="@((MarkupString)RenderMultiValueList(ec.ListenAddresses, src?.ListenAddresses))" /> }
@if (ec.ExceptInterfaces?.Count > 0) { <EffectiveConfigMultiValueRow Label="except-interface" Content="@((MarkupString)RenderMultiValueList(ec.ExceptInterfaces, src?.ExceptInterfaces))" /> }
@if (ec.DhcpRanges?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-range" Content="@((MarkupString)RenderMultiValueList(ec.DhcpRanges, src?.DhcpRanges))" /> }
@if (ec.DhcpHostLines?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-host" Content="@((MarkupString)RenderMultiValueList(ec.DhcpHostLines, src?.DhcpHostLines))" /> }
@if (ec.DhcpOptionLines?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-option" Content="@((MarkupString)RenderMultiValueList(ec.DhcpOptionLines, src?.DhcpOptionLines))" /> }
@if (ec.ResolvFiles?.Count > 0) { <EffectiveConfigMultiValueRow Label="resolv-file" Content="@((MarkupString)RenderMultiValueList(ec.ResolvFiles, src?.ResolvFiles))" /> }
</ul>
}
</section>
<section class="page-section">
<h2 class="page-section-title">Config</h2>
<ul class="list-unstyled small">
<li><strong>Main config:</strong> @(_status.MainConfigPath ?? "—") @(_status.MainConfigPathExists ? "✓" : "(missing)")</li>
<li><strong>Managed file:</strong> @(_status.ManagedFilePath ?? "—") @(_status.ManagedFilePathExists ? "✓" : "(not created yet)")</li>
</ul>
@if (_configSet != null && _configSet.Files?.Count > 0)
{
<p class="mb-1 mt-2"><strong>Config set (load order):</strong></p>
<ul class="list-unstyled small ms-2">
@foreach (var f in _configSet.Files)
{
<li>
@if (f.IsManaged)
{
<span class="badge bg-primary me-1">managed</span>
}
else
{
<span class="badge bg-secondary me-1">@(f.Source.ToString().ToLowerInvariant())</span>
}
@f.FileName
<span class="text-muted">(@f.Path)</span>
</li>
}
</ul>
}
</section>
<section class="page-section">
<h2 class="page-section-title">Paths</h2>
<ul class="list-unstyled small">
<li>
<strong>Leases file:</strong> @(_status.LeasesPath ?? "—")
@if (_status.LeasesPathConfigured)
{
<span>@(_status.LeasesPathExists ? "✓" : "(missing)")</span>
}
else
{
<span class="text-muted">(not configured)</span>
}
</li>
</ul>
</section>
}
@code {
@@ -254,18 +140,6 @@ else if (_status != null)
private void OpenRecentLogsSettings() => OpenSettingsModal(SettingsModalContext.LogsPolling, "Recent logs polling");
private static string GetStatusLabel(DnsmasqServiceStatus s)
{
return s.DnsmasqStatus switch
{
"active" => "Running",
"inactive" => "Not running",
"unknown" => "Unknown",
"notConfigured" => "Status check not configured",
_ => s.DnsmasqStatus ?? "—"
};
}
private async Task ReloadDnsmasq()
{
if (_status == null || !_status.ReloadCommandConfigured || _reloading) return;
@@ -331,25 +205,6 @@ else if (_status != null)
StateHasChanged();
}
/// <summary>Renders a multi-value list with per-value source (readonly/editable + tooltip) when EffectiveConfigSources matches; otherwise comma-separated. Returns MarkupString so Blazor renders raw HTML.</summary>
private static MarkupString RenderMultiValueList(IReadOnlyList<string>? values, IReadOnlyList<ValueWithSource>? sources)
{
if (values == null || values.Count == 0)
return (MarkupString)"(none)";
if (sources != null && sources.Count == values.Count)
{
var parts = sources.Select(e =>
{
var src = e.Source;
var tip = src?.GetReadOnlyTooltip() ?? "";
var label = src == null ? "?" : (src.IsReadOnly ? "from " + src.FileName + ", readonly" : "editable");
return $"{System.Net.WebUtility.HtmlEncode(e.Value)} <span class=\"text-muted\" title=\"{System.Net.WebUtility.HtmlEncode(tip)}\">({System.Net.WebUtility.HtmlEncode(label)})</span>";
});
return (MarkupString)string.Join(", ", parts);
}
return (MarkupString)System.Net.WebUtility.HtmlEncode(string.Join(", ", values));
}
public void Dispose()
{
_cts.Cancel();
@@ -38,10 +38,7 @@ else
<div class="alert alert-success">@_message</div>
}
<p class="text-muted small mb-2">Each hosts file below is rendered with the same component; only the managed file is editable. System hosts is excluded when <strong>no-hosts</strong> is set (dnsmasq does not read it then).</p>
<HostsFileSection Path="@(_status.ManagedHostsFilePath ?? "")" Entries="_entries" IsEditable="true" IsFirst="true" />
<p class="text-muted small">@_status.ManagedHostsFilePath @(!_status.ManagedHostsPathExists ? "(not created yet; will be created on first save)" : "")</p>
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
@if (_readOnlyHosts?.Count > 0)
+6 -2
View File
@@ -11,10 +11,14 @@
@using DnsmasqWebUI.Client.Services.Abstractions
@using DnsmasqWebUI.Components
@using DnsmasqWebUI.Components.Modals
@using DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Components.Hosts
@using DnsmasqWebUI.Components.Dnsmasq
@using DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Components.Hosts
@using DnsmasqWebUI.Models
@using DnsmasqWebUI.Models.EffectiveConfig
@using DnsmasqWebUI.Models.Hosts
@using DnsmasqWebUI.Models.Dhcp
@using DnsmasqWebUI.Models.Status
@using DnsmasqWebUI.Configuration
@using DnsmasqWebUI.Client.Http.Abstractions
@using DnsmasqWebUI.Services.Abstractions