Hosts page UX, effective names, and draft sync

- Managed hosts: add slot row, selection with Ctrl/Cmd, Edit toggles to Save
  while editing, Remove, double-click to edit; header toolbar layout
- Column order: Address, Names, Effective names (when expand-hosts + domains),
  Comment; scroll cue radius and table styling tweaks
- Effective names: resolve domain suffix per row IPv4 from scoped domain=
  rules (CIDR/range) with dnsmasq-style precedence; IPv6 uses default;
  preview uses pending domain option when present
- Enter edit mode on Remove; clear stale inline edit when row disappears;
  deactivate effective-config field when interacting with hosts table
- Remove hosts-ui.js; effective names help uses notifications always
- Tests for HostsEffectiveNames domain rule resolution
This commit is contained in:
2026-04-01 23:50:21 +10:00
parent eae29de045
commit 9627926a6a
7 changed files with 638 additions and 165 deletions
@@ -0,0 +1,55 @@
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Tests.Models.Hosts;
public class HostsEffectiveNamesDomainRulesTests
{
[Fact]
public void Expand_UsesMatchingScopedDomainForIpv4()
{
var names = new[] { "router" };
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
var result = HostsEffectiveNames.Expand(names, expandHosts: true, domains, "10.20.1.9");
Assert.Equal(new[] { "router", "router.corp.lan" }, result);
}
[Fact]
public void Expand_FallsBackToDefaultDomainWhenNoScopedMatch()
{
var names = new[] { "cache" };
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
var result = HostsEffectiveNames.Expand(names, expandHosts: true, domains, "10.99.1.9");
Assert.Equal(new[] { "cache", "cache.home.lan" }, result);
}
[Fact]
public void Expand_UsesLastParsedScopedRuleWhenRangesOverlap()
{
var names = new[] { "app" };
var domains = new[]
{
"home.lan",
"corp-wide.lan,10.0.0.0/8",
"corp-subnet.lan,10.20.0.0/16"
};
var result = HostsEffectiveNames.Expand(names, expandHosts: true, domains, "10.20.1.5");
Assert.Equal(new[] { "app", "app.corp-subnet.lan" }, result);
}
[Fact]
public void Expand_UsesDefaultDomainForIpv6Address()
{
var names = new[] { "node" };
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
var result = HostsEffectiveNames.Expand(names, expandHosts: true, domains, "2001:db8::1");
Assert.Equal(new[] { "node", "node.home.lan" }, result);
}
}
+362 -147
View File
@@ -35,6 +35,9 @@
else
{
var pageState = HostsPageState.FromStatus(_status);
var effectiveDomainValues = GetEffectiveDomainValuesForPreview();
var showEffectiveNames = pageState.ExpandHostsEnabled && effectiveDomainValues.Count > 0;
var hostsTableColSpan = showEffectiveNames ? 4 : 3;
<section class="page-section">
<h2 class="page-section-title">Effective configuration</h2>
@@ -46,14 +49,14 @@ else
<div class="alert alert-warning">
<p class="mb-0">@pageState.ManagedHostsUnavailableReason</p>
</div>
@if (_status.AddnHostsPaths?.Count > 0)
@if (_status!.AddnHostsPaths?.Count > 0)
{
<p class="text-muted small">Dnsmasq loads these addn-hosts files: @RenderAddnHostsList()</p>
}
}
else
{
@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>
}
@@ -93,25 +96,28 @@ else
<tr>
<th scope="col">Address</th>
<th scope="col">Names</th>
@if (showEffectiveNames)
{
<th scope="col" class="hosts-effective-names-th">
<span class="align-middle">Effective names</span>
<button type="button"
class="btn btn-link btn-sm text-muted hosts-effective-names-help p-0 ms-1 align-middle border-0"
title="@EffectiveNamesHelpText"
aria-label="@EffectiveNamesHelpText"
@onclick="OnEffectiveNamesHelpClick"
@onclick:stopPropagation="true">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</th>
}
<th scope="col" class="hosts-comment-col" title="Inline hosts(5) comment: written after # on the same line when saved.">Comment</th>
<th scope="col" class="hosts-effective-names-th">
<span class="align-middle">Effective names</span>
<button type="button"
class="btn btn-link btn-sm text-muted hosts-effective-names-help p-0 ms-1 align-middle border-0"
title="@EffectiveNamesHelpText"
aria-label="@EffectiveNamesHelpText"
@onclick="OnEffectiveNamesHelpClickAsync"
@onclick:stopPropagation="true">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</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="@hostsTableColSpan" scope="colgroup" class="hosts-group-header-cell">
<div class="hosts-group-header__content">
<div class="hosts-group-header__title">
<span>@group.Title</span>
@@ -120,116 +126,180 @@ else
<span class="badge bg-warning text-dark">Inactive</span>
}
</div>
<div class="hosts-group-header__path">
@if (!string.IsNullOrWhiteSpace(group.Subtitle))
<div class="hosts-group-header__path-row">
<div class="hosts-group-header__path">
@if (!string.IsNullOrWhiteSpace(group.Subtitle))
{
<span class="hosts-group-header__subtitle text-muted small">@group.Subtitle</span>
}
@if (group.SourceKind == HostsRowSourceKind.Managed && group.IsSourceEditable)
{
<StatusBadge Kind="StatusBadgeKind.Editable"
Text="editable"
Class="hosts-group-header__badge" />
}
else if (group.IsSourceEditable)
{
<StatusBadge Kind="StatusBadgeKind.Editable"
Text="editable"
Class="hosts-group-header__badge" />
}
else
{
<StatusBadge Kind="StatusBadgeKind.ReadOnly"
Text="read-only"
Class="hosts-group-header__badge" />
}
</div>
@if (IsManagedEditableGroup(group))
{
<span class="hosts-group-header__subtitle text-muted small">@group.Subtitle</span>
}
@if (group.SourceKind == HostsRowSourceKind.Managed && group.IsSourceEditable)
{
<StatusBadge Kind="StatusBadgeKind.Editable"
Text="editable"
Class="hosts-group-header__badge"
Clickable="true"
Disabled="@IsInlineEditing"
Title="@(IsInlineEditing ? "Finish editing the current row before adding another." : "Add a host entry")"
AriaLabel="@(IsInlineEditing ? "Finish the current row edit first" : "Add a host entry")"
OnClick="AddManagedHostFromBadge" />
}
else if (group.IsSourceEditable)
{
<StatusBadge Kind="StatusBadgeKind.Editable"
Text="editable"
Class="hosts-group-header__badge" />
}
else
{
<StatusBadge Kind="StatusBadgeKind.ReadOnly"
Text="read-only"
Class="hosts-group-header__badge" />
<div class="hosts-group-header__managed-actions btn-group btn-group-sm" role="group" aria-label="@(IsInlineEditing ? "Save or finish editing this row" : "Managed hosts row actions")">
@if (IsInlineEditing)
{
<button type="button"
class="btn btn-primary"
title="Save this row"
@onclick="OnManagedHeaderSaveAsync">
<i class="bi bi-check-lg" aria-hidden="true"></i><span class="ms-1 d-none d-sm-inline">Save</span>
</button>
}
else
{
<button type="button"
class="btn btn-outline-secondary"
disabled="@(ManagedSelectionCount != 1)"
title="@EditButtonTitle"
@onclick="OnManagedHeaderEditAsync">
<i class="bi bi-pencil" aria-hidden="true"></i><span class="ms-1 d-none d-sm-inline">Edit</span>
</button>
}
<button type="button"
class="btn btn-outline-danger"
disabled="@(IsInlineEditing || ManagedSelectionCount == 0)"
title="@(IsInlineEditing ? "Finish the current row edit first." : ManagedSelectionCount == 0 ? "Select one or more rows to remove." : "Remove selected host row(s)")"
@onclick="OnManagedHeaderRemoveAsync">
<i class="bi bi-trash" aria-hidden="true"></i><span class="ms-1 d-none d-sm-inline">Remove</span>
</button>
</div>
}
</div>
</div>
</th>
</tr>
@if (group.Rows.Count == 0)
@{
var showManagedAddSlot = IsManagedEditableGroup(group) && !IsInlineEditing;
}
@if (!showManagedAddSlot && group.Rows.Count == 0)
{
<tr class="hosts-row hosts-row--empty" @key="@("empty:" + group.Key)">
<td colspan="4" class="text-muted small text-center hosts-empty-group-message py-3">
<td colspan="@hostsTableColSpan" class="text-muted small text-center hosts-empty-group-message py-3">
@EmptyGroupMessage
</td>
</tr>
}
else
{
@foreach (var row in group.Rows)
@if (group.Rows.Count > 0)
{
@if (IsEditingRow(row))
@foreach (var row in group.Rows)
{
<tr class="hosts-row hosts-row--editing table-info"
@key="@("edit:" + row.Id)"
@ref="_editingRowRef"
@onfocusout="OnEditingRowFocusOut">
<td>
<input @ref="_editAddressInputRef"
class="form-control form-control-sm @(_showHostFieldValidation && CurrentAddressFieldErrors.Count > 0 ? "is-invalid" : "")"
title="@HostEditAddressTooltip"
aria-invalid="@(_showHostFieldValidation && CurrentAddressFieldErrors.Count > 0 ? "true" : null)"
@bind="_editAddress"
@bind:event="oninput"
@onkeydown="OnEditFieldKeyDown"
autofocus />
</td>
<td>
<input class="form-control form-control-sm @(_showHostFieldValidation && CurrentHostnameFieldErrors.Count > 0 ? "is-invalid" : "")"
placeholder="Comma-separated names"
title="@HostEditNamesTooltip"
aria-invalid="@(_showHostFieldValidation && CurrentHostnameFieldErrors.Count > 0 ? "true" : null)"
@bind="_editNames"
@bind:event="oninput"
@onkeydown="OnEditFieldKeyDown" />
</td>
<td>
<input class="form-control form-control-sm"
placeholder="Optional"
title="Saved after # on the same line as this entry (inline hosts comment)."
@bind="_editInlineComment"
@bind:event="oninput"
@onkeydown="OnEditFieldKeyDown" />
</td>
<td class="text-muted small">@string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, pageState.ExpansionDomain))</td>
</tr>
}
else
{
<tr class="@RowClass(row)"
@key="@row.Id"
title="@RowTitle(row)"
@ondblclick="@(async () => await OnRowDoubleClickAsync(row))">
<td><code>@row.Address</code></td>
<td>@string.Join(", ", row.Names)</td>
<td class="text-muted small hosts-comment-col-cell">
@if (string.IsNullOrWhiteSpace(row.InlineComment))
@if (IsEditingRow(row))
{
<tr class="hosts-row hosts-row--editing table-info"
@key="@("edit:" + row.Id)"
@ref="_editingRowRef"
@onfocusout="OnEditingRowFocusOut">
<td>
<input @ref="_editAddressInputRef"
class="form-control form-control-sm @(_showHostFieldValidation && CurrentAddressFieldErrors.Count > 0 ? "is-invalid" : "")"
title="@HostEditAddressTooltip"
aria-invalid="@(_showHostFieldValidation && CurrentAddressFieldErrors.Count > 0 ? "true" : null)"
@bind="_editAddress"
@bind:event="oninput"
@onkeydown="OnEditFieldKeyDown"
autofocus />
</td>
<td>
<input class="form-control form-control-sm @(_showHostFieldValidation && CurrentHostnameFieldErrors.Count > 0 ? "is-invalid" : "")"
placeholder="Comma-separated names"
title="@HostEditNamesTooltip"
aria-invalid="@(_showHostFieldValidation && CurrentHostnameFieldErrors.Count > 0 ? "true" : null)"
@bind="_editNames"
@bind:event="oninput"
@onkeydown="OnEditFieldKeyDown" />
</td>
@if (showEffectiveNames)
{
<span class="hosts-comment-empty">—</span>
<td class="text-muted small">@string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, effectiveDomainValues, _editAddress))</td>
}
<td>
<input class="form-control form-control-sm"
placeholder="Optional"
title="Saved after # on the same line as this entry (inline hosts comment)."
@bind="_editInlineComment"
@bind:event="oninput"
@onkeydown="OnEditFieldKeyDown" />
</td>
</tr>
}
else
{
<tr class="@RowClass(row)"
@key="@row.Id"
title="@RowTitle(row)"
aria-selected="@(IsManagedRowSelected(row) ? "true" : "false")"
@onclick="@(e => OnManagedRowClick(row, e))"
@ondblclick="@(async _ => await OnManagedRowDoubleClickAsync(row))">
@{ var effectiveNames = HostsEffectiveNames.Expand(row.Names, pageState.ExpandHostsEnabled, effectiveDomainValues, row.Address); }
<td><code>@row.Address</code></td>
<td>@string.Join(", ", row.Names)</td>
@if (showEffectiveNames)
{
<td>
@if (effectiveNames.Count != row.Names.Count)
{
<span class="text-primary">@string.Join(", ", effectiveNames)</span>
}
else
{
@string.Join(", ", effectiveNames)
}
</td>
}
<td class="text-muted small hosts-comment-col-cell">
@if (string.IsNullOrWhiteSpace(row.InlineComment))
{
<span class="hosts-comment-empty">—</span>
}
else
{
<span class="hosts-row-inline-comment">@row.InlineComment</span>
}
</td>
</tr>
}
}
}
@if (showManagedAddSlot)
{
<tr class="@ManagedAddSlotRowClass(group.Rows.Count > 0)" @key="@("add-slot:" + group.Key)">
<td colspan="@hostsTableColSpan" class="p-0">
<button type="button"
class="btn hosts-add-slot-btn w-100 rounded-0 border-0 text-muted small py-3"
title="Add a new host entry"
aria-label="@(group.Rows.Count == 0 ? ManagedAddSlotEmptyLead + ManagedAddSlotEmptyAction : "Add a new host entry")"
@onclick="OnManagedAddSlotClick">
@if (group.Rows.Count == 0)
{
<span>@ManagedAddSlotEmptyLead</span><span class="hosts-add-slot__action">@ManagedAddSlotEmptyAction</span>
}
else
{
<span class="hosts-row-inline-comment">@row.InlineComment</span>
<span class="hosts-add-slot__action"><i class="bi bi-plus-lg" aria-hidden="true"></i> Add host</span>
}
</td>
<td>
@if (row.EffectiveNames.Count != row.Names.Count)
{
<span class="text-primary">@string.Join(", ", row.EffectiveNames)</span>
}
else
{
@string.Join(", ", row.EffectiveNames)
}
</td>
</tr>
}
</button>
</td>
</tr>
}
}
</tbody>
@@ -279,11 +349,11 @@ else
<strong>System hosts</strong>: @pageState.SystemHostsInactiveReason
</div>
}
@if (pageState.ExpandHostsEnabled && !string.IsNullOrEmpty(pageState.ExpansionDomain))
@if (showEffectiveNames)
{
<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.
<strong>expand-hosts</strong> is enabled and one or more <code>domain=</code> rules are configured.
Simple hostnames (without dots) are expanded using the applicable domain for each row address.
The <strong>Effective names</strong> column shows how dnsmasq will resolve each entry.
</div>
}
@@ -293,7 +363,7 @@ else
@code {
private const string EffectiveNamesHelpText =
"Effective names show how dnsmasq resolves this row from your Address and Names columns. If expand-hosts and a domain are enabled, short hostnames get that suffix. This preview updates as you type.";
"Effective names list how dnsmasq resolves each row when expand-hosts is on: short hostnames (no dots) also get the configured domain suffix.";
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private DnsmasqServiceStatus? _status;
@@ -304,6 +374,8 @@ else
private string _search = "";
private readonly HashSet<string> _selectedManagedRowIds = new(StringComparer.Ordinal);
private string? _editingRowId;
private string _editAddress = "";
private string _editNames = "";
@@ -311,7 +383,6 @@ else
private ElementReference _editingRowRef;
private ElementReference _editAddressInputRef;
private IJSObjectReference? _hostsTableScrollCueModule;
private IJSObjectReference? _hostsUiModule;
private bool _showInvalidHostEditModal;
private bool _refocusEditAfterRender;
/// <summary>When false, invalid state is hidden until the first blur, Escape, or Enter.</summary>
@@ -351,6 +422,23 @@ else
private bool IsInlineEditing => !string.IsNullOrEmpty(_editingRowId);
private int ManagedSelectionCount => _selectedManagedRowIds.Count;
private IReadOnlyList<string> GetEffectiveDomainValuesForPreview()
{
var pendingDomain = Session.Draft.PendingChanges
.OfType<PendingOptionChange>()
.FirstOrDefault(c => string.Equals(c.OptionName, "domain", StringComparison.OrdinalIgnoreCase))
?.NewValue as IReadOnlyList<string>;
return pendingDomain ?? (_status?.EffectiveConfig?.DomainValues ?? Array.Empty<string>());
}
private string EditButtonTitle =>
ManagedSelectionCount == 0 ? "Select a row to edit."
: ManagedSelectionCount > 1 ? "Select exactly one row to edit."
: "Edit selected row";
private bool _editingRowIsNew =>
!string.IsNullOrEmpty(_editingRowId) && _editingRowId.StartsWith("new:", StringComparison.Ordinal);
@@ -420,31 +508,49 @@ else
_visibleGroupsCache = null;
}
private async Task OnEffectiveNamesHelpClickAsync()
private static bool IsManagedEditableGroup(HostsPageGroup group) =>
group.SourceKind == HostsRowSourceKind.Managed && group.IsSourceEditable;
private void PruneManagedSelectionToExistingEditableRows()
{
try
{
_hostsUiModule ??= await JSRuntime.InvokeAsync<IJSObjectReference>("import", "/js/modules/hosts-ui.js");
if (await _hostsUiModule.InvokeAsync<bool>("preferEffectiveNamesHelpToast"))
NotificationService.ShowInfo(EffectiveNamesHelpText);
}
catch (JSDisconnectedException)
{
}
if (_selectedManagedRowIds.Count == 0)
return;
var editableIds = new HashSet<string>(
_unifiedRows.Where(r => r.IsEditable).Select(r => r.Id),
StringComparer.Ordinal);
_selectedManagedRowIds.RemoveWhere(id => !editableIds.Contains(id));
}
private static string RowClass(HostsPageRow row)
private void OnEffectiveNamesHelpClick() =>
NotificationService.ShowInfo(EffectiveNamesHelpText);
private string RowClass(HostsPageRow row)
{
var parts = new List<string> { "hosts-row" };
if (row.IsEditable)
parts.Add("hosts-row--editable");
if (!row.IsActive)
parts.Add("hosts-row--inactive");
if (IsManagedRowSelected(row))
parts.Add("hosts-row--selected");
return string.Join(" ", parts);
}
private bool IsManagedRowSelected(HostsPageRow row) =>
row.IsEditable && _selectedManagedRowIds.Contains(row.Id);
private string RowTitle(HostsPageRow row) =>
row.IsEditable ? "Double-click to edit" : string.Empty;
row.IsEditable
? "Click to select only this row, or click again to deselect. Double-click to edit. Ctrl or ⌘+click toggles rows in the selection. Add a row below; the header Edit control becomes Save while you edit; Remove deletes selected rows."
: string.Empty;
private string ManagedAddSlotEmptyLead =>
string.IsNullOrWhiteSpace(_search) ? "No entries. " : "No matching entries. ";
private const string ManagedAddSlotEmptyAction = "Click to add a host.";
private static string ManagedAddSlotRowClass(bool afterDataRows) =>
afterDataRows ? "hosts-row hosts-row--add-slot hosts-row--add-slot--after-rows" : "hosts-row hosts-row--add-slot";
private string EmptyGroupMessage =>
string.IsNullOrWhiteSpace(_search) ? "No entries." : "No matching entries.";
@@ -452,8 +558,100 @@ else
private bool IsEditingRow(HostsPageRow row) =>
!string.IsNullOrEmpty(_editingRowId) && string.Equals(_editingRowId, row.Id, StringComparison.Ordinal);
private async Task OnRowDoubleClickAsync(HostsPageRow row)
private void OnManagedRowClick(HostsPageRow row, MouseEventArgs e)
{
DeactivateEffectiveConfigFieldIfActive();
if (!row.IsEditable || IsInlineEditing)
return;
// Second (and third) click of a multi-click fires click with Detail > 1; skip so double-click does not toggle selection before edit.
if (e.Detail > 1)
return;
if (e.CtrlKey || e.MetaKey)
{
if (!_selectedManagedRowIds.Add(row.Id))
_selectedManagedRowIds.Remove(row.Id);
}
else if (_selectedManagedRowIds.Contains(row.Id))
{
_selectedManagedRowIds.Remove(row.Id);
}
else
{
_selectedManagedRowIds.Clear();
_selectedManagedRowIds.Add(row.Id);
}
StateHasChanged();
}
private async Task OnManagedRowDoubleClickAsync(HostsPageRow row)
{
DeactivateEffectiveConfigFieldIfActive();
if (!row.IsEditable || IsInlineEditing)
return;
await BeginEditingManagedRowAsync(row);
}
private void OnManagedAddSlotClick()
{
DeactivateEffectiveConfigFieldIfActive();
if (IsInlineEditing)
return;
AddManagedHost();
}
private async Task OnManagedHeaderSaveAsync()
{
await TryFinishHostEditFromKeyboardAsync();
}
private async Task OnManagedHeaderEditAsync()
{
DeactivateEffectiveConfigFieldIfActive();
if (_status == null || ManagedSelectionCount != 1 || IsInlineEditing)
return;
var id = _selectedManagedRowIds.First();
var row = _unifiedRows.FirstOrDefault(r => r.IsEditable && string.Equals(r.Id, id, StringComparison.Ordinal));
if (row == null)
return;
await BeginEditingManagedRowAsync(row);
}
private async Task OnManagedHeaderRemoveAsync()
{
DeactivateEffectiveConfigFieldIfActive();
if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath) || IsInlineEditing)
return;
if (_selectedManagedRowIds.Count == 0)
return;
var list = CloneEntries(CurrentManagedEntries).ToList();
var removed = list.RemoveAll(e =>
{
var id = string.IsNullOrEmpty(e.Id) ? $"managed:{e.LineNumber}" : e.Id;
return _selectedManagedRowIds.Contains(id);
});
if (removed == 0)
return;
if (!Session.Ui.IsEditMode)
Session.Ui.EnterEditMode();
Session.Draft.SetManagedHostsDraft(
CloneEntries(_baselineManagedEntries),
list,
_status.ManagedHostsFilePath);
RebuildManagedOverlay();
InvalidateVisibleGroupsCache();
_selectedManagedRowIds.Clear();
await InvokeAsync(StateHasChanged);
}
private async Task BeginEditingManagedRowAsync(HostsPageRow row)
{
DeactivateEffectiveConfigFieldIfActive();
if (!row.IsEditable || _status == null)
return;
@@ -484,18 +682,14 @@ else
_editAddress = entry.Address;
_editNames = string.Join(", ", entry.Names);
_editInlineComment = entry.InlineComment ?? "";
_selectedManagedRowIds.Clear();
_selectedManagedRowIds.Add(row.Id);
await InvokeAsync(StateHasChanged);
}
private void AddManagedHostFromBadge()
{
if (IsInlineEditing)
return;
AddManagedHost();
}
private void AddManagedHost()
{
DeactivateEffectiveConfigFieldIfActive();
if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath) || IsInlineEditing)
return;
if (!Session.Ui.IsEditMode)
@@ -525,6 +719,8 @@ else
_editAddress = "";
_editNames = "";
_editInlineComment = "";
_selectedManagedRowIds.Clear();
_selectedManagedRowIds.Add(id);
StateHasChanged();
}
@@ -539,6 +735,12 @@ else
private void RevealHostFieldValidation() => _showHostFieldValidation = true;
private void DeactivateEffectiveConfigFieldIfActive()
{
if (Session.Ui.ActiveFieldKey != null)
Session.Ui.DeactivateField();
}
private void OnInvalidEditContinue()
{
_showInvalidHostEditModal = false;
@@ -558,9 +760,10 @@ else
var list = CloneEntries(CurrentManagedEntries).ToList();
var entry = list.FirstOrDefault(MatchesEditingId);
if (entry != null && entry.Id.StartsWith("new:", StringComparison.Ordinal))
var wasNew = entry != null && entry.Id.StartsWith("new:", StringComparison.Ordinal);
if (wasNew)
{
list.Remove(entry);
list.Remove(entry!);
Session.Draft.SetManagedHostsDraft(
CloneEntries(_baselineManagedEntries),
list,
@@ -570,7 +773,17 @@ else
}
_showInvalidHostEditModal = false;
var prevId = _editingRowId;
CancelEdit();
if (wasNew)
_selectedManagedRowIds.Clear();
else
{
_selectedManagedRowIds.Clear();
if (!string.IsNullOrEmpty(prevId))
_selectedManagedRowIds.Add(prevId);
}
await InvokeAsync(StateHasChanged);
}
@@ -671,6 +884,7 @@ else
RebuildManagedOverlay();
InvalidateVisibleGroupsCache();
CancelEdit();
PruneManagedSelectionToExistingEditableRows();
await InvokeAsync(StateHasChanged);
}
@@ -823,6 +1037,7 @@ else
}
InvalidateVisibleGroupsCache();
_selectedManagedRowIds.Clear();
}
catch (Exception ex)
{
@@ -861,13 +1076,29 @@ else
}
_unifiedRows = managedRows.Concat(_restUnifiedRows).ToList();
ClearStaleInlineEditState();
PruneManagedSelectionToExistingEditableRows();
}
private void ClearStaleInlineEditState()
{
if (string.IsNullOrEmpty(_editingRowId))
return;
var editingRowStillExists = _unifiedRows.Any(r =>
string.Equals(r.Id, _editingRowId, StringComparison.Ordinal));
if (editingRowStillExists)
return;
CancelEdit();
_showInvalidHostEditModal = false;
}
public void Dispose()
{
Session.Changed -= OnSessionChanged;
_ = CleanupHostsTableScrollCueAsync();
_ = CleanupHostsUiModuleAsync();
}
private async Task CleanupHostsTableScrollCueAsync()
@@ -886,20 +1117,4 @@ else
_hostsTableScrollCueModule = null;
}
private async Task CleanupHostsUiModuleAsync()
{
if (_hostsUiModule == null)
return;
try
{
await _hostsUiModule.DisposeAsync();
}
catch (JSDisconnectedException)
{
}
_hostsUiModule = null;
}
}
@@ -158,16 +158,32 @@
min-width: 0;
}
.hosts-group-header__path-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 0.5rem 0.75rem;
width: 100%;
min-width: 0;
}
.hosts-group-header__path {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
min-width: 0;
width: 100%;
flex: 1 1 auto;
font-weight: 500;
}
.hosts-group-header__managed-actions {
flex: 0 0 auto;
align-self: flex-end;
margin-left: auto;
}
.hosts-group-header__badge {
flex-shrink: 0;
}
@@ -182,7 +198,7 @@
max-width: min(38rem, 50vw);
}
::deep .hosts-row--inactive {
::deep .hosts-row--inactive:not(.hosts-row--selected) {
opacity: 0.72;
}
@@ -194,11 +210,64 @@
background-color: rgba(13, 110, 253, 0.06);
}
/* One accent bar on the row (first cell only); same fill on all cells so borders stay even */
::deep .hosts-table .hosts-row--selected td {
background-color: rgba(var(--bs-primary-rgb), 0.07);
}
::deep .hosts-table .hosts-row--selected td:first-child {
box-shadow: inset 3px 0 0 var(--bs-primary);
}
::deep .hosts-table .hosts-row--selected.hosts-row--editable:hover td {
background-color: rgba(var(--bs-primary-rgb), 0.11);
}
::deep .hosts-table .hosts-row--empty td.hosts-empty-group-message {
background: #fafbfc;
font-style: italic;
}
::deep .hosts-table .hosts-row--add-slot td {
padding: 0;
vertical-align: middle;
background: #fafbfc;
}
::deep .hosts-table .hosts-row--add-slot--after-rows td {
border-top: 1px dashed #dbe3ec;
}
.hosts-add-slot-btn {
font-weight: 500;
line-height: 1.35;
background: transparent;
box-shadow: none;
}
.hosts-add-slot-btn:hover,
.hosts-add-slot-btn:focus-visible {
background: rgba(13, 110, 253, 0.08);
color: var(--bs-primary);
}
.hosts-add-slot-btn:focus-visible {
outline: 0;
box-shadow: inset 0 0 0 2px rgba(13, 110, 253, 0.35);
z-index: 1;
position: relative;
}
.hosts-add-slot__action {
font-weight: 600;
color: #475569;
}
.hosts-add-slot-btn:hover .hosts-add-slot__action,
.hosts-add-slot-btn:focus-visible .hosts-add-slot__action {
color: var(--bs-primary);
}
::deep .hosts-row code {
font-size: 0.93em;
color: #0f172a;
@@ -13,6 +13,10 @@ public sealed record HostsPageState(
bool ExpandHostsEnabled,
string? ExpansionDomain)
{
/// <summary>When true, dnsmasq applies expand-hosts with a domain so effective names can differ from stored names.</summary>
public bool ShowEffectiveNamesColumn =>
ExpandHostsEnabled && !string.IsNullOrWhiteSpace(ExpansionDomain);
public static HostsPageState FromStatus(DnsmasqServiceStatus? status)
{
if (status == null)
@@ -3,6 +3,8 @@ namespace DnsmasqWebUI.Models.Hosts;
/// <summary>Preview of names after <c>expand-hosts</c> (does not mutate stored names).</summary>
public static class HostsEffectiveNames
{
private sealed record ScopedDomainRule(uint Start, uint End, string Domain);
public static IReadOnlyList<string> Expand(
IReadOnlyList<string> names,
bool expandHosts,
@@ -19,4 +21,139 @@ public static class HostsEffectiveNames
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
public static IReadOnlyList<string> Expand(
IReadOnlyList<string> names,
bool expandHosts,
IReadOnlyList<string>? domainValues,
string? address)
{
var domain = ResolveDomainForAddress(domainValues, address);
return Expand(names, expandHosts, domain);
}
public static string? ResolveDomainForAddress(
IReadOnlyList<string>? domainValues,
string? address)
{
if (domainValues == null || domainValues.Count == 0)
return null;
string? defaultDomain = null;
var scopedRules = new List<ScopedDomainRule>();
foreach (var raw in domainValues)
ParseDomainValue(raw, scopedRules, ref defaultDomain);
if (!TryParseIpv4ToUInt(address, out var ip))
return defaultDomain;
// dnsmasq prepends conditional domains as it parses, then scans first-match.
for (var i = scopedRules.Count - 1; i >= 0; i--)
{
var rule = scopedRules[i];
if (ip >= rule.Start && ip <= rule.End)
return rule.Domain;
}
return defaultDomain;
}
private static void ParseDomainValue(
string? raw,
ICollection<ScopedDomainRule> scopedRules,
ref string? defaultDomain)
{
if (string.IsNullOrWhiteSpace(raw))
return;
var parts = raw.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
return;
var domain = NormalizeDomain(parts[0]);
if (string.IsNullOrWhiteSpace(domain))
return;
if (parts.Length == 1)
{
defaultDomain = domain;
return;
}
if (TryParseScopedRange(parts, out var start, out var end))
scopedRules.Add(new ScopedDomainRule(start, end, domain));
}
private static string? NormalizeDomain(string value)
{
var domain = value.Trim();
if (domain.Length == 0 || domain == "#")
return null;
return domain;
}
private static bool TryParseScopedRange(string[] parts, out uint start, out uint end)
{
start = 0;
end = 0;
if (parts.Length < 2)
return false;
var second = parts[1];
if (TryParseIpv4Cidr(second, out start, out end))
return true;
if (TryParseIpv4ToUInt(second, out start))
{
// domain=a.b.c.d,ip-start,ip-end
if (parts.Length >= 3 && TryParseIpv4ToUInt(parts[2], out end))
{
if (end < start)
(start, end) = (end, start);
return true;
}
end = start;
return true;
}
return false;
}
private static bool TryParseIpv4Cidr(string value, out uint start, out uint end)
{
start = 0;
end = 0;
var slashIndex = value.IndexOf('/');
if (slashIndex <= 0 || slashIndex >= value.Length - 1)
return false;
var ipPart = value[..slashIndex];
var prefixPart = value[(slashIndex + 1)..];
if (!TryParseIpv4ToUInt(ipPart, out var ip) || !int.TryParse(prefixPart, out var prefix) || prefix < 0 || prefix > 32)
return false;
var mask = prefix == 0 ? 0u : uint.MaxValue << (32 - prefix);
start = ip & mask;
end = start | ~mask;
return true;
}
private static bool TryParseIpv4ToUInt(string? value, out uint result)
{
result = 0;
if (string.IsNullOrWhiteSpace(value) || !System.Net.IPAddress.TryParse(value, out var ip))
return false;
var bytes = ip.GetAddressBytes();
if (bytes.Length != 4)
return false;
result = ((uint)bytes[0] << 24)
| ((uint)bytes[1] << 16)
| ((uint)bytes[2] << 8)
| bytes[3];
return true;
}
}
+9 -4
View File
@@ -260,11 +260,12 @@ h1:focus {
/* Shared table shell for rich data tables that need horizontal scrolling. */
.app-table-scroll-shell {
--app-table-scroll-radius: 16px;
position: relative;
width: 100%;
overflow: visible;
overflow: hidden;
border: 1px solid #e7ebf0;
border-radius: 16px;
border-radius: var(--app-table-scroll-radius);
background: #fff;
box-shadow: 0 12px 28px -22px rgba(15, 23, 42, 0.45);
}
@@ -274,7 +275,7 @@ h1:focus {
overflow-x: auto;
overflow-y: clip;
-webkit-overflow-scrolling: touch;
border-radius: 16px;
border-radius: var(--app-table-scroll-radius);
scrollbar-width: thin;
scrollbar-color: #b8c3d1 #eef2f6;
}
@@ -285,7 +286,7 @@ h1:focus {
.app-table-scroll-region::-webkit-scrollbar-track {
background: #eef2f6;
border-radius: 0 0 16px 16px;
border-radius: 0 0 var(--app-table-scroll-radius) var(--app-table-scroll-radius);
}
.app-table-scroll-region::-webkit-scrollbar-thumb {
@@ -319,6 +320,8 @@ h1:focus {
display: inline-flex;
align-items: center;
justify-content: center;
/* Match .app-table-scroll-shell corners so gradients don’t look square */
overflow: hidden;
}
.app-table-cue-btn:hover {
@@ -332,6 +335,7 @@ h1:focus {
.app-table-scroll-shell .app-table-cue-btn--left {
left: 0;
border-radius: var(--app-table-scroll-radius) 0 0 var(--app-table-scroll-radius);
background: linear-gradient(to right, rgba(248, 250, 252, 0.98), rgba(248, 250, 252, 0.68), transparent);
}
@@ -341,6 +345,7 @@ h1:focus {
.app-table-scroll-shell .app-table-cue-btn--right {
right: 0;
border-radius: 0 var(--app-table-scroll-radius) var(--app-table-scroll-radius) 0;
background: linear-gradient(to left, rgba(248, 250, 252, 0.98), rgba(248, 250, 252, 0.68), transparent);
}
@@ -1,12 +0,0 @@
/**
* Touch / coarse-pointer devices: show Effective names help as a toast instead of relying on hover.
*/
export function preferEffectiveNamesHelpToast() {
try {
if (window.matchMedia("(pointer: coarse)").matches) return true;
if (window.matchMedia("(hover: none)").matches) return true;
return false;
} catch {
return false;
}
}