Effective config refactor, remove dead parser path, add source line numbers

Effective config:
- Add EffectiveConfigFieldDescriptor (metadata + value/source/items delegates)
- Add EffectiveConfigFieldBuilder with Config/Sources/Items helpers; build all
  fields when EffectiveConfig present (empty = not set); empty list when null
- Add EffectiveConfigSectionPanel + EffectiveConfigFieldDisplay; section CSS
  uses ::deep for child markup, open-state styling for headers
- Remove Kind from descriptor (use DnsmasqConfOptionRegistry.GetKind(OptionName)
  when needed)

Dead code removal:
- Remove DnsmasqOptionModels, ParsedDirective (DnsmasqConfDirective),
  DnsmasqOptionKind, DnsmasqConfOptionRegistry
- Remove ParseLine from DnsmasqConfDirectiveParser; keep StripComment and
  TryParseKeyValue (used by DnsmasqConfIncludeParser)
- Simplify DnsmasqConfDirectiveParserTests; remove DnsmasqConfOptionRegistryTests

ConfigValueSource:
- Add LineNumber (int?); IncludeParser passes 1-based line to MakeSource
- GetReadOnlyTooltip includes 'line N' when LineNumber set
- Add ConfigValueSourceTests; assert LineNumber in IncludeParser WithSource tests

Also: .github/workflows/release.yml, scripts/install.sh, script tweaks
This commit is contained in:
2026-02-04 20:23:59 +10:00
parent 368489e5f2
commit 633c1ef827
23 changed files with 783 additions and 776 deletions
@@ -0,0 +1,18 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.EffectiveConfig
@if (Descriptor.IsMultiValue)
{
var items = Descriptor.GetItems();
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" />
}
else
{
var value = Descriptor.GetValue();
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" />
}
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
}
@@ -0,0 +1,15 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.EffectiveConfig
<span class="d-inline-block me-2" title="@(Source?.GetReadOnlyTooltip())">
@Value
@if (Source?.IsReadOnly == true)
{
<span class="badge bg-secondary ms-1">readonly</span>
}
</span>
@code {
[Parameter] public string Value { get; set; } = "";
[Parameter] public ConfigValueSource? Source { get; set; }
}
@@ -1,10 +1,24 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.EffectiveConfig
<li>
<strong>@Label:</strong> @Content
<strong>@Label:</strong>
@if (Items?.Count > 0)
{
@for (var i = 0; i < Items.Count; i++)
{
if (i > 0) { <span class="me-2">, </span> }
var item = Items[i];
<EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" />
}
}
else
{
<span class="text-muted">(none)</span>
}
</li>
@code {
[Parameter] public string Label { get; set; } = "";
[Parameter] public MarkupString Content { get; set; }
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
}
@@ -5,136 +5,24 @@
@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 descriptors = EffectiveConfigFieldBuilder.BuildFieldDescriptors(Status);
var sections = new[]
{
(EffectiveConfigFieldBuilder.SectionHosts, "Hosts"),
(EffectiveConfigFieldBuilder.SectionResolver, "Resolver / DNS"),
(EffectiveConfigFieldBuilder.SectionDhcp, "DHCP"),
(EffectiveConfigFieldBuilder.SectionCache, "Cache"),
(EffectiveConfigFieldBuilder.SectionProcess, "Process & networking")
};
}
@foreach (var (sectionId, title) in sections)
{
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>
var sectionDescriptors = descriptors.Where(d => d.SectionId == sectionId).ToList();
if (sectionDescriptors.Count > 0)
{
<EffectiveConfigSectionPanel SectionId="@sectionId" Title="@title" Descriptors="@sectionDescriptors" IsOpen="@IsOpen(sectionId)" OnToggle="@OnSectionToggle" />
}
}
</div>
}
@@ -146,33 +34,11 @@
private bool IsOpen(string id) => _openPanels.Contains(id);
private void TogglePanel(string id)
private void OnSectionToggle(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));
}
}
@@ -1,28 +1,30 @@
/* Effective config sections – mobile-first, touch-friendly */
/* Effective config sections – mobile-first, touch-friendly.
Use ::deep so styles apply to EffectiveConfigSectionPanel (child component) markup. */
.ec-sections {
display: flex;
flex-direction: column;
gap: 0;
}
.ec-section {
::deep .ec-section {
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
}
.ec-section:last-child {
::deep .ec-section:last-child {
border-bottom: none;
}
/* Touch-friendly header: min 44px tap target (WCAG 2.5.5) */
.ec-section-header {
::deep .ec-section-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 2.75rem;
padding: 0.4rem 0;
padding: 0.5rem 0;
margin: 0;
border: none;
border-radius: 0.25rem;
background: transparent;
color: inherit;
text-align: left;
@@ -30,30 +32,49 @@
font-size: inherit;
line-height: 1.3;
-webkit-tap-highlight-color: transparent;
transition: background-color 0.15s ease, color 0.15s ease;
}
.ec-section-header:hover {
::deep .ec-section-header:hover {
color: var(--bs-primary, #0d6efd);
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.04));
}
.ec-section-header:focus-visible {
::deep .ec-section-header:focus-visible {
outline: 2px solid var(--bs-primary, #0d6efd);
outline-offset: 2px;
}
.ec-section-title {
/* Open state: header reads as "active" and clearly separates from body */
::deep .ec-section-open .ec-section-header {
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.05));
color: var(--bs-emphasis-color, inherit);
font-weight: 600;
}
::deep .ec-section-open .ec-section-header:hover {
background-color: var(--bs-tertiary-bg, rgba(0, 0, 0, 0.08));
color: var(--bs-primary, #0d6efd);
}
::deep .ec-section-title {
flex: 1;
min-width: 0;
}
.ec-section-chevron {
::deep .ec-section-chevron {
flex-shrink: 0;
font-size: 0.75rem;
opacity: 0.7;
margin-left: 0.25rem;
font-size: 0.8rem;
opacity: 0.8;
margin-left: 0.35rem;
transition: opacity 0.2s ease;
}
.ec-section-body {
::deep .ec-section-open .ec-section-chevron {
opacity: 1;
}
::deep .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));
@@ -62,18 +83,18 @@
}
/* Long paths/values wrap on small screens; badges stay inline */
.ec-section-body ul {
::deep .ec-section-body ul {
overflow-wrap: break-word;
word-break: break-word;
}
.ec-section-body li {
::deep .ec-section-body li {
overflow-wrap: break-word;
word-break: break-word;
}
@media (max-width: 767.98px) {
.ec-section-body {
::deep .ec-section-body {
padding-left: 0.35rem;
}
}
@@ -0,0 +1,29 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.EffectiveConfig
<div class="ec-section @(IsOpen ? "ec-section-open" : "")">
<button type="button" class="ec-section-header" @onclick="ToggleSection" aria-expanded="@IsOpen">
<span class="ec-section-title fw-bold">@Title</span>
<i class="bi @(IsOpen ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
</button>
<div class="collapse @(IsOpen ? "show" : "")">
<div class="ec-section-body">
<ul class="list-unstyled mb-0">
@foreach (var f in Descriptors)
{
<EffectiveConfigFieldDisplay Descriptor="@f" />
}
</ul>
</div>
</div>
</div>
@code {
[Parameter] public string SectionId { get; set; } = "";
[Parameter] public string Title { get; set; } = "";
[Parameter] public IReadOnlyList<EffectiveConfigFieldDescriptor> Descriptors { get; set; } = null!;
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<string> OnToggle { get; set; }
private async Task ToggleSection() => await OnToggle.InvokeAsync(SectionId);
}