Hosts page UX: simpler table, projection, and effective-config tweaks

Remove the Status column and row-level badges/actions; keep source-level
Editable on group headers. Double-click managed rows to edit; commit staged
drafts when focus leaves the row via isFocusOutsideElement in the shared
scroll-cue module. Hide inactive system-host rows when dnsmasq ignores them
and move system-hosts / expand-hosts notes below the content area.

Memoize VisibleGroups so BuildGroups runs once per render during search.
Move read-ethers metadata to the DHCP section; adjust Hosts page state copy
and related effective-config tooltips/builders where needed.
This commit is contained in:
2026-03-29 09:31:29 +10:00
parent c7e7844ce7
commit 31c0f65c86
10 changed files with 141 additions and 199 deletions
+79 -63
View File
@@ -8,6 +8,7 @@
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions
@using DnsmasqWebUI.Components.EffectiveConfig
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@implements IDisposable
@inject IStatusClient StatusClient
@@ -37,13 +38,6 @@ else
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Hosts" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
</section>
@if (!pageState.SystemHostsActive && !string.IsNullOrEmpty(pageState.SystemHostsInactiveReason))
{
<div class="alert alert-info">
<strong>System hosts</strong> is inactive: @pageState.SystemHostsInactiveReason
</div>
}
@if (!pageState.ManagedHostsAvailable)
{
<div class="alert alert-warning">
@@ -97,14 +91,13 @@ else
<th scope="col">Address</th>
<th scope="col">Names</th>
<th scope="col">Effective names</th>
<th scope="col">Status</th>
</tr>
</thead>
@foreach (var group in VisibleGroups)
{
<tbody @key="@group.Key">
<tr class="hosts-group-header">
<th colspan="4" scope="colgroup" class="hosts-group-header-cell">
<th colspan="3" scope="colgroup" class="hosts-group-header-cell">
<div class="hosts-group-header__content">
<div class="hosts-group-header__title">
<span>@group.Title</span>
@@ -128,32 +121,25 @@ else
{
@if (IsEditingRow(row))
{
<tr class="hosts-row hosts-row--editing table-info" @key="@("edit:" + row.Id)">
<tr class="hosts-row hosts-row--editing table-info"
@key="@("edit:" + row.Id)"
@ref="_editingRowRef"
@onfocusout="OnEditingRowFocusOut">
<td>
<input class="form-control form-control-sm" @bind="_editAddress" />
<input class="form-control form-control-sm" @bind="_editAddress" autofocus />
</td>
<td>
<input class="form-control form-control-sm" placeholder="Comma-separated names" @bind="_editNames" />
</td>
<td class="text-muted small">@string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, pageState.ExpansionDomain))</td>
<td>
<div class="hosts-status-cell">
<span class="hosts-status-pill hosts-status-pill--editable">Editable</span>
@if (row.IsComment)
{
<span class="hosts-status-pill hosts-status-pill--muted">Commented</span>
}
<div class="hosts-status-actions">
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveEditAsync">Save</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CancelEdit">Cancel</button>
</div>
</div>
</td>
</tr>
}
else
{
<tr class="@RowClass(row)" @key="@row.Id">
<tr class="@RowClass(row)"
@key="@row.Id"
title="@(row.IsEditable ? "Double-click to edit" : string.Empty)"
@ondblclick="@(() => OnRowDoubleClick(row))">
<td><code>@row.Address</code></td>
<td>@string.Join(", ", row.Names)</td>
<td>
@@ -166,30 +152,6 @@ else
@string.Join(", ", row.EffectiveNames)
}
</td>
<td>
<div class="hosts-status-cell">
@if (!row.IsActive)
{
<span class="hosts-status-pill hosts-status-pill--inactive" title="@row.InactiveReason">Inactive</span>
}
@if (row.IsComment)
{
<span class="hosts-status-pill hosts-status-pill--muted">Commented</span>
}
@if (row.IsEditable)
{
<span class="hosts-status-pill hosts-status-pill--editable">Editable</span>
}
else
{
<span class="hosts-status-pill hosts-status-pill--readonly">Read-only</span>
}
@if (row.IsEditable && string.IsNullOrEmpty(_editingRowId))
{
<button type="button" class="btn btn-sm btn-outline-primary hosts-inline-action" @onclick="() => BeginEdit(row)">Edit</button>
}
</div>
</td>
</tr>
}
}
@@ -200,17 +162,25 @@ else
</div>
}
@if (pageState.ExpandHostsEnabled && !string.IsNullOrEmpty(pageState.ExpansionDomain))
{
<div class="alert alert-info mt-3 mb-0">
<strong>expand-hosts</strong> is enabled with domain <code>@pageState.ExpansionDomain</code>.
Simple hostnames (without dots) will be expanded to include the domain suffix.
The <strong>Effective names</strong> column shows how dnsmasq will resolve each entry.
</div>
}
</section>
}
<div class="hosts-page-notes mt-3 d-flex flex-column gap-2">
@if (!pageState.SystemHostsActive && !string.IsNullOrEmpty(pageState.SystemHostsInactiveReason))
{
<div class="alert alert-info mb-0">
<strong>System hosts</strong>: @pageState.SystemHostsInactiveReason
</div>
}
@if (pageState.ExpandHostsEnabled && !string.IsNullOrEmpty(pageState.ExpansionDomain))
{
<div class="alert alert-info mb-0">
<strong>expand-hosts</strong> is enabled with domain <code>@pageState.ExpansionDomain</code>.
Simple hostnames (without dots) will be expanded to include the domain suffix.
The <strong>Effective names</strong> column shows how dnsmasq will resolve each entry.
</div>
}
</div>
}
</div>
@@ -227,10 +197,31 @@ else
private string? _editingRowId;
private string _editAddress = "";
private string _editNames = "";
private ElementReference _editingRowRef;
private IJSObjectReference? _hostsTableScrollCueModule;
private IReadOnlyList<HostsPageGroup> VisibleGroups =>
HostsProjection.BuildGroups(_unifiedRows, BuildQuery());
// Memoize: markup reads VisibleGroups.Count then foreach — avoid building twice per render.
private IReadOnlyList<HostsPageGroup>? _visibleGroupsCache;
private object? _visibleGroupsCacheRowsRef;
private string _visibleGroupsCacheSearch = "\0";
private IReadOnlyList<HostsPageGroup> VisibleGroups
{
get
{
if (_visibleGroupsCache != null
&& ReferenceEquals(_visibleGroupsCacheRowsRef, _unifiedRows)
&& _visibleGroupsCacheSearch == _search)
{
return _visibleGroupsCache;
}
_visibleGroupsCache = HostsProjection.BuildGroups(_unifiedRows, BuildQuery());
_visibleGroupsCacheRowsRef = _unifiedRows;
_visibleGroupsCacheSearch = _search;
return _visibleGroupsCache;
}
}
private HostsPageQueryState BuildQuery()
{
@@ -247,13 +238,15 @@ else
private static string RowClass(HostsPageRow row)
{
var parts = new List<string> { "hosts-row" };
if (row.IsComment)
parts.Add("table-secondary");
if (row.IsEditable)
parts.Add("hosts-row--editable");
if (!row.IsActive)
parts.Add("hosts-row--inactive");
return string.Join(" ", parts);
}
private void OnRowDoubleClick(HostsPageRow row) => BeginEdit(row);
private bool IsEditingRow(HostsPageRow row) =>
!string.IsNullOrEmpty(_editingRowId) && string.Equals(_editingRowId, row.Id, StringComparison.Ordinal);
@@ -276,7 +269,30 @@ else
_editNames = "";
}
private async Task SaveEditAsync()
private async Task OnEditingRowFocusOut(FocusEventArgs _)
{
if (string.IsNullOrEmpty(_editingRowId))
return;
await Task.Delay(10);
if (string.IsNullOrEmpty(_editingRowId))
return;
var outside = true;
try
{
if (_hostsTableScrollCueModule != null)
outside = await _hostsTableScrollCueModule.InvokeAsync<bool>("isFocusOutsideElement", _editingRowRef);
}
catch (JSDisconnectedException)
{
outside = true;
}
if (outside)
await CommitAndEndInlineEditAsync();
}
private async Task CommitAndEndInlineEditAsync()
{
if (string.IsNullOrEmpty(_editingRowId) || _status == null)
return;
@@ -139,6 +139,14 @@
opacity: 0.72;
}
::deep .hosts-row--editable {
cursor: pointer;
}
::deep .hosts-row--editable:hover {
background-color: rgba(13, 110, 253, 0.06);
}
::deep .hosts-row code {
font-size: 0.93em;
color: #0f172a;
@@ -151,66 +159,6 @@
background: #f0f7ff;
}
.hosts-status-cell {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem;
}
.hosts-status-pill {
display: inline-flex;
align-items: center;
padding: 0.22rem 0.48rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
line-height: 1.1;
border: 1px solid transparent;
white-space: nowrap;
}
.hosts-status-pill--active {
color: #0f5132;
background: #d1e7dd;
border-color: #badbcc;
}
.hosts-status-pill--inactive {
color: #5c3d00;
background: #fff3cd;
border-color: #ffe69c;
}
.hosts-status-pill--editable {
color: #084298;
background: #dbeafe;
border-color: #b6d4fe;
}
.hosts-status-pill--readonly {
color: #475569;
background: #f1f5f9;
border-color: #d8e0ea;
}
.hosts-status-pill--muted {
color: #495057;
background: #f3f4f6;
border-color: #e5e7eb;
}
.hosts-inline-action {
margin-left: 0.1rem;
}
.hosts-status-actions {
display: inline-flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-left: 0.1rem;
}
@media (max-width: 991.98px) {
.hosts-controls-bar {
padding: 0.75rem;
@@ -31,13 +31,15 @@ public sealed class HostsPageProjectionService : IHostsPageProjectionService
if (query.ActiveFilter is { } act)
filtered = filtered.Where(r => r.IsActive == act);
// When dnsmasq ignores system hosts (e.g. no-hosts), do not show those rows in the table.
filtered = filtered.Where(r =>
!(r.SourceKind == HostsRowSourceKind.SystemHosts && !r.IsActive));
var list = filtered.ToList();
if (list.Count == 0)
return Array.Empty<HostsPageGroup>();
return query.Grouping == HostsGroupingMode.Activity
? BuildActivityGroups(list, query)
: BuildSourceGroups(list, query);
return BuildSourceGroups(list, query);
}
private static IReadOnlyList<HostsPageGroup> BuildSourceGroups(List<HostsPageRow> rows, HostsPageQueryState query)
@@ -76,40 +78,6 @@ public sealed class HostsPageProjectionService : IHostsPageProjectionService
return result;
}
private static IReadOnlyList<HostsPageGroup> BuildActivityGroups(List<HostsPageRow> rows, HostsPageQueryState query)
{
var orderedGroups = rows
.GroupBy(r => r.IsActive)
.OrderByDescending(g => g.Key);
var result = new List<HostsPageGroup>();
foreach (var g in orderedGroups)
{
var sortedRows = SortRows(g, query.Sort, query.Descending).ToList();
if (sortedRows.Count == 0)
continue;
var active = g.Key;
var title = active ? "Active" : "Inactive";
var subtitle = active
? "Entries from sources dnsmasq loads for hosts."
: "Entries from sources that are ignored or unavailable (e.g. system hosts under no-hosts).";
result.Add(new HostsPageGroup(
Key: active ? "activity:active" : "activity:inactive",
Title: title,
Subtitle: subtitle,
SourceKind: null,
GroupContainsEditableRows: sortedRows.Any(r => r.IsEditable),
IsActive: active,
InactiveReason: null,
VisibleRowCount: sortedRows.Count,
Rows: sortedRows));
}
return result;
}
private static IEnumerable<HostsPageRow> SortRows(IEnumerable<HostsPageRow> rows, HostsSortMode sort, bool descending)
{
IEnumerable<HostsPageRow> ordered = sort switch
@@ -150,7 +150,7 @@ public static class DnsmasqOptionTooltips
[DnsmasqConfKeys.DnssecCheckUnsigned] = "Treat unsigned zones as bogus.",
[DnsmasqConfKeys.DnssecNoTimecheck] = "Do not validate DNSSEC signatures against the current time (e.g. for testing).",
[DnsmasqConfKeys.DnssecDebug] = "Log DNSSEC validation failures at debug level.",
[DnsmasqConfKeys.AddCpeId] = "Add CPE-ID to DHCP requests (for identification).",
[DnsmasqConfKeys.AddCpeId] = "Add an identifying string to DNS queries forwarded upstream (EDNS0).",
[DnsmasqConfKeys.DnssecTimestamp] = "Path to file for DNSSEC timestamp (e.g. for validation).",
[DnsmasqConfKeys.DnssecLimits] = "DNSSEC validation limits (e.g. max signed zones).",
[DnsmasqConfKeys.TrustAnchor] = "DNSSEC trust anchors. Can repeat.",
@@ -163,8 +163,8 @@ public static class DnsmasqOptionTooltips
[DnsmasqConfKeys.MaxTtl] = "Maximum TTL for cached responses (cap on upstream TTL).",
[DnsmasqConfKeys.MaxCacheTtl] = "Maximum TTL for positive cache entries.",
[DnsmasqConfKeys.MinCacheTtl] = "Minimum TTL for positive cache entries.",
[DnsmasqConfKeys.Dumpfile] = "Dump cache to this file (for debugging).",
[DnsmasqConfKeys.Dumpmask] = "Mask for cache dump (which data to include).",
[DnsmasqConfKeys.Dumpfile] = "Pcap file path for packet capture (debugging).",
[DnsmasqConfKeys.Dumpmask] = "Bitmask selecting which packet types are written to the dump file.",
[DnsmasqConfKeys.UseStaleCache] = "Use stale cache when upstream is unavailable. Off, On (key-only), or custom seconds.",
[DnsmasqConfKeys.AddMac] = "Add MAC address to DNS queries. Off, On (key-only), or custom value.",
[DnsmasqConfKeys.StripMac] = "Strip MAC address information from forwarded DNS queries.",
@@ -54,7 +54,8 @@ public sealed class EffectiveConfigFieldBuilder : IEffectiveConfigFieldBuilder
list.AddDescriptor(registry, DnsmasqConfKeys.NoHosts, status, s => Config(s)?.NoHosts, s => Sources(s)?.NoHosts, null);
list.AddDescriptor(registry, DnsmasqConfKeys.AddnHosts, status, null, null, s => ToValueWithSourceList(s?.AddnHostsPaths, Sources(s)?.AddnHostsPaths));
list.AddDescriptor(registry, DnsmasqConfKeys.Hostsdir, status, s => Config(s)?.HostsdirPath, s => Sources(s)?.HostsdirPath, null);
list.AddDescriptor(registry, DnsmasqConfKeys.ReadEthers, status, s => Config(s)?.ReadEthers, s => Sources(s)?.ReadEthers, null);
list.AddDescriptor(registry, DnsmasqConfKeys.ExpandHosts, status, s => Config(s)?.ExpandHosts, s => Sources(s)?.ExpandHosts, null);
list.AddDescriptor(registry, DnsmasqConfKeys.Domain, status, null, null, Items(ec => ec?.DomainValues, src => src?.DomainValues));
// Resolver / DNS
list.AddDescriptor(registry, DnsmasqConfKeys.Server, status, null, null, Items(ec => ec?.ServerValues, src => src?.ServerValues));
@@ -78,7 +79,6 @@ public sealed class EffectiveConfigFieldBuilder : IEffectiveConfigFieldBuilder
list.AddDescriptor(registry, DnsmasqConfKeys.StopDnsRebind, status, s => Config(s)?.StopDnsRebind, s => Sources(s)?.StopDnsRebind, null);
list.AddDescriptor(registry, DnsmasqConfKeys.RebindLocalhostOk, status, s => Config(s)?.RebindLocalhostOk, s => Sources(s)?.RebindLocalhostOk, null);
list.AddDescriptor(registry, DnsmasqConfKeys.ClearOnReload, status, s => Config(s)?.ClearOnReload, s => Sources(s)?.ClearOnReload, null);
list.AddDescriptor(registry, DnsmasqConfKeys.ExpandHosts, status, s => Config(s)?.ExpandHosts, s => Sources(s)?.ExpandHosts, null);
list.AddDescriptor(registry, DnsmasqConfKeys.BogusPriv, status, s => Config(s)?.BogusPriv, s => Sources(s)?.BogusPriv, null);
list.AddDescriptor(registry, DnsmasqConfKeys.RebindDomainOk, status, null, null, Items(ec => ec?.RebindDomainOkValues, src => src?.RebindDomainOkValues));
list.AddDescriptor(registry, DnsmasqConfKeys.BogusNxdomain, status, null, null, Items(ec => ec?.BogusNxdomainValues, src => src?.BogusNxdomainValues));
@@ -96,10 +96,10 @@ public sealed class EffectiveConfigFieldBuilder : IEffectiveConfigFieldBuilder
list.AddDescriptor(registry, DnsmasqConfKeys.NoRoundRobin, status, s => Config(s)?.NoRoundRobin, s => Sources(s)?.NoRoundRobin, null);
list.AddDescriptor(registry, DnsmasqConfKeys.DnsForwardMax, status, s => Config(s)?.DnsForwardMax, s => Sources(s)?.DnsForwardMax, null);
list.AddDescriptor(registry, DnsmasqConfKeys.ConnmarkAllowlist, status, null, null, Items(ec => ec?.ConnmarkAllowlistValues, src => src?.ConnmarkAllowlistValues));
list.AddDescriptor(registry, DnsmasqConfKeys.AddCpeId, status, s => Config(s)?.AddCpeId, s => Sources(s)?.AddCpeId, null);
list.AddDescriptor(registry, DnsmasqConfKeys.Do0x20Encode, status, s => Config(s)?.Do0x20EncodeState, s => Sources(s)?.Do0x20Encode, null);
// DNS records
list.AddDescriptor(registry, DnsmasqConfKeys.Domain, status, null, null, Items(ec => ec?.DomainValues, src => src?.DomainValues));
list.AddDescriptor(registry, DnsmasqConfKeys.Cname, status, null, null, Items(ec => ec?.CnameValues, src => src?.CnameValues));
list.AddDescriptor(registry, DnsmasqConfKeys.MxHost, status, null, null, Items(ec => ec?.MxHostValues, src => src?.MxHostValues));
list.AddDescriptor(registry, DnsmasqConfKeys.Srv, status, null, null, Items(ec => ec?.SrvValues, src => src?.SrvValues));
@@ -121,11 +121,16 @@ public sealed class EffectiveConfigFieldBuilder : IEffectiveConfigFieldBuilder
list.AddDescriptor(registry, DnsmasqConfKeys.Selfmx, status, s => Config(s)?.Selfmx, s => Sources(s)?.Selfmx, null);
// DHCP
list.AddDescriptor(registry, DnsmasqConfKeys.NoDhcpInterface, status, null, null, Items(ec => ec?.NoDhcpInterfaceValues, src => src?.NoDhcpInterfaceValues));
list.AddDescriptor(registry, DnsmasqConfKeys.NoDhcpv4Interface, status, null, null, Items(ec => ec?.NoDhcpv4InterfaceValues, src => src?.NoDhcpv4InterfaceValues));
list.AddDescriptor(registry, DnsmasqConfKeys.NoDhcpv6Interface, status, null, null, Items(ec => ec?.NoDhcpv6InterfaceValues, src => src?.NoDhcpv6InterfaceValues));
list.AddDescriptor(registry, DnsmasqConfKeys.EnableRa, status, s => Config(s)?.EnableRa, s => Sources(s)?.EnableRa, null);
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpAuthoritative, status, s => Config(s)?.DhcpAuthoritative, s => Sources(s)?.DhcpAuthoritative, null);
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpRapidCommit, status, s => Config(s)?.DhcpRapidCommit, s => Sources(s)?.DhcpRapidCommit, null);
list.AddDescriptor(registry, DnsmasqConfKeys.LeasefileRo, status, s => Config(s)?.LeasefileRo, s => Sources(s)?.LeasefileRo, null);
list.AddDescriptor(registry, DnsmasqConfKeys.Leasequery, status, null, null, Items(ec => ec?.LeasequeryValues, src => src?.LeasequeryValues));
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpGenerateNames, status, s => Config(s)?.DhcpGenerateNames, s => Sources(s)?.DhcpGenerateNames, null);
list.AddDescriptor(registry, DnsmasqConfKeys.ReadEthers, status, s => Config(s)?.ReadEthers, s => Sources(s)?.ReadEthers, null);
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpBroadcast, status, s => Config(s)?.DhcpBroadcast, s => Sources(s)?.DhcpBroadcast, null);
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpSequentialIp, status, s => Config(s)?.DhcpSequentialIp, s => Sources(s)?.DhcpSequentialIp, null);
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpIgnoreClid, status, s => Config(s)?.DhcpIgnoreClid, s => Sources(s)?.DhcpIgnoreClid, null);
@@ -186,7 +191,6 @@ public sealed class EffectiveConfigFieldBuilder : IEffectiveConfigFieldBuilder
list.AddDescriptor(registry, DnsmasqConfKeys.DnssecNoTimecheck, status, s => Config(s)?.DnssecNoTimecheck, s => Sources(s)?.DnssecNoTimecheck, null);
list.AddDescriptor(registry, DnsmasqConfKeys.DnssecDebug, status, s => Config(s)?.DnssecDebug, s => Sources(s)?.DnssecDebug, null);
list.AddDescriptor(registry, DnsmasqConfKeys.ProxyDnssec, status, s => Config(s)?.ProxyDnssec, s => Sources(s)?.ProxyDnssec, null);
list.AddDescriptor(registry, DnsmasqConfKeys.AddCpeId, status, s => Config(s)?.AddCpeId, s => Sources(s)?.AddCpeId, null);
list.AddDescriptor(registry, DnsmasqConfKeys.DnssecTimestamp, status, s => Config(s)?.DnssecTimestamp, s => Sources(s)?.DnssecTimestamp, null);
list.AddDescriptor(registry, DnsmasqConfKeys.DnssecLimits, status, s => Config(s)?.DnssecLimits, s => Sources(s)?.DnssecLimits, null);
list.AddDescriptor(registry, DnsmasqConfKeys.TrustAnchor, status, null, null, Items(ec => ec?.TrustAnchorValues, src => src?.TrustAnchorValues));
@@ -196,8 +200,6 @@ public sealed class EffectiveConfigFieldBuilder : IEffectiveConfigFieldBuilder
list.AddDescriptor(registry, DnsmasqConfKeys.CacheSize, status, s => Config(s)?.CacheSize, s => Sources(s)?.CacheSize, null);
list.AddDescriptor(registry, DnsmasqConfKeys.LocalTtl, status, s => Config(s)?.LocalTtl, s => Sources(s)?.LocalTtl, null);
list.AddDescriptor(registry, DnsmasqConfKeys.NoNegcache, status, s => Config(s)?.NoNegcache, s => Sources(s)?.NoNegcache, null);
list.AddDescriptor(registry, DnsmasqConfKeys.Dumpfile, status, s => Config(s)?.DumpfilePath, s => Sources(s)?.DumpfilePath, null);
list.AddDescriptor(registry, DnsmasqConfKeys.Dumpmask, status, s => Config(s)?.Dumpmask, s => Sources(s)?.Dumpmask, null);
list.AddDescriptor(registry, DnsmasqConfKeys.UseStaleCache, status, s => Config(s)?.UseStaleCache, s => Sources(s)?.UseStaleCache, null);
list.AddDescriptor(registry, DnsmasqConfKeys.AddMac, status, s => Config(s)?.AddMac, s => Sources(s)?.AddMac, null);
list.AddDescriptor(registry, DnsmasqConfKeys.StripMac, status, s => Config(s)?.StripMac, s => Sources(s)?.StripMac, null);
@@ -220,16 +222,14 @@ public sealed class EffectiveConfigFieldBuilder : IEffectiveConfigFieldBuilder
list.AddDescriptor(registry, DnsmasqConfKeys.ListenAddress, status, null, null, Items(ec => ec?.ListenAddresses, src => src?.ListenAddresses));
list.AddDescriptor(registry, DnsmasqConfKeys.ExceptInterface, status, null, null, Items(ec => ec?.ExceptInterfaces, src => src?.ExceptInterfaces));
list.AddDescriptor(registry, DnsmasqConfKeys.AuthServer, status, null, null, Items(ec => ec?.AuthServerValues, src => src?.AuthServerValues));
list.AddDescriptor(registry, DnsmasqConfKeys.NoDhcpInterface, status, null, null, Items(ec => ec?.NoDhcpInterfaceValues, src => src?.NoDhcpInterfaceValues));
list.AddDescriptor(registry, DnsmasqConfKeys.NoDhcpv4Interface, status, null, null, Items(ec => ec?.NoDhcpv4InterfaceValues, src => src?.NoDhcpv4InterfaceValues));
list.AddDescriptor(registry, DnsmasqConfKeys.NoDhcpv6Interface, status, null, null, Items(ec => ec?.NoDhcpv6InterfaceValues, src => src?.NoDhcpv6InterfaceValues));
list.AddDescriptor(registry, DnsmasqConfKeys.Dumpfile, status, s => Config(s)?.DumpfilePath, s => Sources(s)?.DumpfilePath, null);
list.AddDescriptor(registry, DnsmasqConfKeys.Dumpmask, status, s => Config(s)?.Dumpmask, s => Sources(s)?.Dumpmask, null);
list.AddDescriptor(registry, DnsmasqConfKeys.PidFile, status, s => Config(s)?.PidFilePath, s => Sources(s)?.PidFilePath, null);
list.AddDescriptor(registry, DnsmasqConfKeys.User, status, s => Config(s)?.User, s => Sources(s)?.User, null);
list.AddDescriptor(registry, DnsmasqConfKeys.Group, status, s => Config(s)?.Group, s => Sources(s)?.Group, null);
list.AddDescriptor(registry, DnsmasqConfKeys.LogFacility, status, s => Config(s)?.LogFacility, s => Sources(s)?.LogFacility, null);
list.AddDescriptor(registry, DnsmasqConfKeys.EnableDbus, status, s => Config(s)?.EnableDbus, s => Sources(s)?.EnableDbus, null);
list.AddDescriptor(registry, DnsmasqConfKeys.EnableUbus, status, s => Config(s)?.EnableUbus, s => Sources(s)?.EnableUbus, null);
list.AddDescriptor(registry, DnsmasqConfKeys.EnableRa, status, s => Config(s)?.EnableRa, s => Sources(s)?.EnableRa, null);
list.AddDescriptor(registry, DnsmasqConfKeys.LogDhcp, status, s => Config(s)?.LogDhcp, s => Sources(s)?.LogDhcp, null);
list.AddDescriptor(registry, DnsmasqConfKeys.KeepInForeground, status, s => Config(s)?.KeepInForeground, s => Sources(s)?.KeepInForeground, null);
list.AddDescriptor(registry, DnsmasqConfKeys.NoDaemon, status, s => Config(s)?.NoDaemon, s => Sources(s)?.NoDaemon, null);
@@ -29,6 +29,8 @@ public static class EffectiveConfigSections
DnsmasqConfKeys.NoHosts,
DnsmasqConfKeys.AddnHosts,
DnsmasqConfKeys.Hostsdir,
DnsmasqConfKeys.ExpandHosts,
DnsmasqConfKeys.Domain,
]),
new SectionDef(SectionResolver, "Resolver / DNS", [
DnsmasqConfKeys.Server,
@@ -52,7 +54,6 @@ public static class EffectiveConfigSections
DnsmasqConfKeys.StopDnsRebind,
DnsmasqConfKeys.RebindLocalhostOk,
DnsmasqConfKeys.ClearOnReload,
DnsmasqConfKeys.ExpandHosts,
DnsmasqConfKeys.BogusPriv,
DnsmasqConfKeys.RebindDomainOk,
DnsmasqConfKeys.BogusNxdomain,
@@ -70,10 +71,10 @@ public static class EffectiveConfigSections
DnsmasqConfKeys.NoRoundRobin,
DnsmasqConfKeys.DnsForwardMax,
DnsmasqConfKeys.ConnmarkAllowlist,
DnsmasqConfKeys.AddCpeId,
DnsmasqConfKeys.Do0x20Encode,
]),
new SectionDef(SectionDnsRecords, "DNS records", [
DnsmasqConfKeys.Domain,
DnsmasqConfKeys.Cname,
DnsmasqConfKeys.MxHost,
DnsmasqConfKeys.Srv,
@@ -95,6 +96,10 @@ public static class EffectiveConfigSections
DnsmasqConfKeys.Selfmx,
]),
new SectionDef(SectionDhcp, "DHCP", [
DnsmasqConfKeys.NoDhcpInterface,
DnsmasqConfKeys.NoDhcpv4Interface,
DnsmasqConfKeys.NoDhcpv6Interface,
DnsmasqConfKeys.EnableRa,
DnsmasqConfKeys.DhcpAuthoritative,
DnsmasqConfKeys.DhcpRapidCommit,
DnsmasqConfKeys.LeasefileRo,
@@ -161,7 +166,6 @@ public static class EffectiveConfigSections
DnsmasqConfKeys.DnssecNoTimecheck,
DnsmasqConfKeys.DnssecDebug,
DnsmasqConfKeys.ProxyDnssec,
DnsmasqConfKeys.AddCpeId,
DnsmasqConfKeys.DnssecTimestamp,
DnsmasqConfKeys.DnssecLimits,
DnsmasqConfKeys.TrustAnchor,
@@ -175,8 +179,6 @@ public static class EffectiveConfigSections
DnsmasqConfKeys.MaxTtl,
DnsmasqConfKeys.MaxCacheTtl,
DnsmasqConfKeys.MinCacheTtl,
DnsmasqConfKeys.Dumpfile,
DnsmasqConfKeys.Dumpmask,
DnsmasqConfKeys.UseStaleCache,
DnsmasqConfKeys.AddMac,
DnsmasqConfKeys.StripMac,
@@ -195,16 +197,14 @@ public static class EffectiveConfigSections
DnsmasqConfKeys.ListenAddress,
DnsmasqConfKeys.ExceptInterface,
DnsmasqConfKeys.AuthServer,
DnsmasqConfKeys.NoDhcpInterface,
DnsmasqConfKeys.NoDhcpv4Interface,
DnsmasqConfKeys.NoDhcpv6Interface,
DnsmasqConfKeys.Dumpfile,
DnsmasqConfKeys.Dumpmask,
DnsmasqConfKeys.PidFile,
DnsmasqConfKeys.User,
DnsmasqConfKeys.Group,
DnsmasqConfKeys.LogFacility,
DnsmasqConfKeys.EnableDbus,
DnsmasqConfKeys.EnableUbus,
DnsmasqConfKeys.EnableRa,
DnsmasqConfKeys.LogDhcp,
DnsmasqConfKeys.KeepInForeground,
DnsmasqConfKeys.NoDaemon,
@@ -38,9 +38,8 @@ public static class EffectiveConfigViews
DnsmasqConfKeys.NoHosts,
DnsmasqConfKeys.AddnHosts,
DnsmasqConfKeys.Hostsdir,
]),
new(EffectiveConfigSections.SectionResolver, "Resolver / DNS", [
DnsmasqConfKeys.ExpandHosts,
DnsmasqConfKeys.Domain,
]),
];
@@ -71,10 +71,9 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
private void RegisterFlagDisplays()
{
RegisterFlags(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.NoHosts);
RegisterFlags(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.ExpandHosts);
RegisterFlags(
EffectiveConfigSections.SectionResolver,
DnsmasqConfKeys.ExpandHosts,
DnsmasqConfKeys.BogusPriv,
DnsmasqConfKeys.StrictOrder,
DnsmasqConfKeys.AllServers,
@@ -91,6 +90,7 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
DnsmasqConfKeys.NoRoundRobin);
RegisterFlags(
EffectiveConfigSections.SectionDhcp,
DnsmasqConfKeys.EnableRa,
DnsmasqConfKeys.DhcpAuthoritative,
DnsmasqConfKeys.ReadEthers,
DnsmasqConfKeys.DhcpRapidCommit,
@@ -125,7 +125,6 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
DnsmasqConfKeys.BindInterfaces,
DnsmasqConfKeys.BindDynamic,
DnsmasqConfKeys.LogDebug,
DnsmasqConfKeys.EnableRa,
DnsmasqConfKeys.LogDhcp,
DnsmasqConfKeys.KeepInForeground,
DnsmasqConfKeys.NoDaemon,
@@ -177,11 +176,9 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
RegisterSemanticSingles(
EffectiveConfigSections.SectionDnssec,
DnsmasqConfKeys.DnssecTimestamp);
RegisterSemanticSingles(
EffectiveConfigSections.SectionCache,
DnsmasqConfKeys.Dumpfile);
RegisterSemanticSingles(
EffectiveConfigSections.SectionProcess,
DnsmasqConfKeys.Dumpfile,
DnsmasqConfKeys.PidFile);
}
@@ -193,7 +190,10 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
behavior: new DistinctMultiValueEditBehavior());
RegisterSemanticMultiDescriptor(EffectiveConfigSections.SectionProcess, DnsmasqConfKeys.ListenAddress);
RegisterSemanticMultis(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.AddnHosts);
RegisterSemanticMultis(
EffectiveConfigSections.SectionHosts,
DnsmasqConfKeys.AddnHosts,
DnsmasqConfKeys.Domain);
RegisterSemanticMultis(
EffectiveConfigSections.SectionResolver,
DnsmasqConfKeys.Local,
@@ -209,6 +209,9 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
DnsmasqConfKeys.ConnmarkAllowlist);
RegisterSemanticMultis(
EffectiveConfigSections.SectionDhcp,
DnsmasqConfKeys.NoDhcpInterface,
DnsmasqConfKeys.NoDhcpv4Interface,
DnsmasqConfKeys.NoDhcpv6Interface,
DnsmasqConfKeys.DhcpRange,
DnsmasqConfKeys.DhcpHost,
DnsmasqConfKeys.DhcpOption,
@@ -239,7 +242,6 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
DnsmasqConfKeys.FilterRr);
RegisterSemanticMultis(
EffectiveConfigSections.SectionDnsRecords,
DnsmasqConfKeys.Domain,
DnsmasqConfKeys.Cname,
DnsmasqConfKeys.MxHost,
DnsmasqConfKeys.PtrRecord,
@@ -273,10 +275,7 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
EffectiveConfigSections.SectionProcess,
DnsmasqConfKeys.Interface,
DnsmasqConfKeys.ExceptInterface,
DnsmasqConfKeys.AuthServer,
DnsmasqConfKeys.NoDhcpInterface,
DnsmasqConfKeys.NoDhcpv4Interface,
DnsmasqConfKeys.NoDhcpv6Interface);
DnsmasqConfKeys.AuthServer);
}
private void RegisterComponents(string sectionId, Type componentType, params string[] optionNames)
@@ -39,11 +39,11 @@ public sealed record HostsPageState(
: "Hosts editing is unavailable: managed hosts path is not configured. Set Dnsmasq:MainConfigPath (and optionally Dnsmasq:ManagedHostsFileName) so the app can create and edit the managed hosts file.",
SystemHostsActive: systemHostsActive,
SystemHostsInactiveReason: noHosts
? "System hosts is ignored because no-hosts is enabled in dnsmasq config."
? "Dnsmasq is ignoring system hosts (no-hosts is enabled), so system hosts rows are hidden from this table."
: string.IsNullOrEmpty(status.SystemHostsPath)
? "System hosts path is not configured."
? "System hosts path is not configured; system hosts rows are hidden from this table."
: !status.SystemHostsPathExists
? $"System hosts file not found: {status.SystemHostsPath}"
? $"System hosts file not found ({status.SystemHostsPath}); those rows are hidden from this table."
: null,
ExpandHostsEnabled: expandHosts,
ExpansionDomain: expandHosts ? domain : null);
@@ -158,3 +158,15 @@ export function disposeTableScrollCue() {
if (right) right.remove();
});
}
/**
* Returns true if the active element is not inside `container` (e.g. focus left a table row).
* Use after a short delay following focusout so document.activeElement has updated.
* @param {HTMLElement | null} container
*/
export function isFocusOutsideElement(container) {
if (!container) return true;
const active = document.activeElement;
if (!active) return true;
return !container.contains(active);
}