diff --git a/src/DnsmasqWebUI.Tests/Models/Hosts/HostsEffectiveNamesDomainRulesTests.cs b/src/DnsmasqWebUI.Tests/Models/Hosts/HostsEffectiveNamesDomainRulesTests.cs new file mode 100644 index 0000000..b3a57f2 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Models/Hosts/HostsEffectiveNamesDomainRulesTests.cs @@ -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); + } +} diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor b/src/DnsmasqWebUI/Components/Pages/Hosts.razor index f18b82c..7a7d577 100644 --- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor +++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor @@ -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;

Effective configuration

@@ -46,14 +49,14 @@ else

@pageState.ManagedHostsUnavailableReason

- @if (_status.AddnHostsPaths?.Count > 0) + @if (_status!.AddnHostsPaths?.Count > 0) {

Dnsmasq loads these addn-hosts files: @RenderAddnHostsList()

} } else { - @if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown") + @if (_status!.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown") {
Dnsmasq service appears to be @_status.DnsmasqStatus. Save will update the file but reload may fail until the service is fixed.
} @@ -93,25 +96,28 @@ else Address Names + @if (showEffectiveNames) + { + + Effective names + + + } Comment - - Effective names - - @foreach (var group in VisibleGroups) { - +
@group.Title @@ -120,116 +126,180 @@ else Inactive }
-
- @if (!string.IsNullOrWhiteSpace(group.Subtitle)) +
+
+ @if (!string.IsNullOrWhiteSpace(group.Subtitle)) + { + @group.Subtitle + } + @if (group.SourceKind == HostsRowSourceKind.Managed && group.IsSourceEditable) + { + + } + else if (group.IsSourceEditable) + { + + } + else + { + + } +
+ @if (IsManagedEditableGroup(group)) { - @group.Subtitle - } - @if (group.SourceKind == HostsRowSourceKind.Managed && group.IsSourceEditable) - { - - } - else if (group.IsSourceEditable) - { - - } - else - { - +
+ @if (IsInlineEditing) + { + + } + else + { + + } + +
}
- @if (group.Rows.Count == 0) + @{ + var showManagedAddSlot = IsManagedEditableGroup(group) && !IsInlineEditing; + } + @if (!showManagedAddSlot && group.Rows.Count == 0) { - + @EmptyGroupMessage } else { - @foreach (var row in group.Rows) + @if (group.Rows.Count > 0) { - @if (IsEditingRow(row)) + @foreach (var row in group.Rows) { - - - - - - - - - - - @string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, pageState.ExpansionDomain)) - - } - else - { - - @row.Address - @string.Join(", ", row.Names) - - @if (string.IsNullOrWhiteSpace(row.InlineComment)) + @if (IsEditingRow(row)) + { + + + + + + + + @if (showEffectiveNames) { - — + @string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, effectiveDomainValues, _editAddress)) + } + + + + + } + else + { + + @{ var effectiveNames = HostsEffectiveNames.Expand(row.Names, pageState.ExpandHostsEnabled, effectiveDomainValues, row.Address); } + @row.Address + @string.Join(", ", row.Names) + @if (showEffectiveNames) + { + + @if (effectiveNames.Count != row.Names.Count) + { + @string.Join(", ", effectiveNames) + } + else + { + @string.Join(", ", effectiveNames) + } + + } + + @if (string.IsNullOrWhiteSpace(row.InlineComment)) + { + — + } + else + { + @row.InlineComment + } + + + } + } + } + @if (showManagedAddSlot) + { + + + + + } } @@ -279,11 +349,11 @@ else System hosts: @pageState.SystemHostsInactiveReason
} - @if (pageState.ExpandHostsEnabled && !string.IsNullOrEmpty(pageState.ExpansionDomain)) + @if (showEffectiveNames) {
- expand-hosts is enabled with domain @pageState.ExpansionDomain. - Simple hostnames (without dots) will be expanded to include the domain suffix. + expand-hosts is enabled and one or more domain= rules are configured. + Simple hostnames (without dots) are expanded using the applicable domain for each row address. The Effective names column shows how dnsmasq will resolve each entry.
} @@ -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 _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; /// When false, invalid state is hidden until the first blur, Escape, or Enter. @@ -351,6 +422,23 @@ else private bool IsInlineEditing => !string.IsNullOrEmpty(_editingRowId); + private int ManagedSelectionCount => _selectedManagedRowIds.Count; + + private IReadOnlyList GetEffectiveDomainValuesForPreview() + { + var pendingDomain = Session.Draft.PendingChanges + .OfType() + .FirstOrDefault(c => string.Equals(c.OptionName, "domain", StringComparison.OrdinalIgnoreCase)) + ?.NewValue as IReadOnlyList; + + return pendingDomain ?? (_status?.EffectiveConfig?.DomainValues ?? Array.Empty()); + } + + 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("import", "/js/modules/hosts-ui.js"); - if (await _hostsUiModule.InvokeAsync("preferEffectiveNamesHelpToast")) - NotificationService.ShowInfo(EffectiveNamesHelpText); - } - catch (JSDisconnectedException) - { - } + if (_selectedManagedRowIds.Count == 0) + return; + var editableIds = new HashSet( + _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 { "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; - } } diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css b/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css index 3d8e6ef..8ddd201 100644 --- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css +++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css @@ -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; diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/PageStates/HostsPageState.cs b/src/DnsmasqWebUI/Models/Dnsmasq/PageStates/HostsPageState.cs index ed999a9..1953e84 100644 --- a/src/DnsmasqWebUI/Models/Dnsmasq/PageStates/HostsPageState.cs +++ b/src/DnsmasqWebUI/Models/Dnsmasq/PageStates/HostsPageState.cs @@ -13,6 +13,10 @@ public sealed record HostsPageState( bool ExpandHostsEnabled, string? ExpansionDomain) { + /// When true, dnsmasq applies expand-hosts with a domain so effective names can differ from stored names. + public bool ShowEffectiveNamesColumn => + ExpandHostsEnabled && !string.IsNullOrWhiteSpace(ExpansionDomain); + public static HostsPageState FromStatus(DnsmasqServiceStatus? status) { if (status == null) diff --git a/src/DnsmasqWebUI/Models/Hosts/HostsEffectiveNames.cs b/src/DnsmasqWebUI/Models/Hosts/HostsEffectiveNames.cs index 8d4b3f4..51104ff 100644 --- a/src/DnsmasqWebUI/Models/Hosts/HostsEffectiveNames.cs +++ b/src/DnsmasqWebUI/Models/Hosts/HostsEffectiveNames.cs @@ -3,6 +3,8 @@ namespace DnsmasqWebUI.Models.Hosts; /// Preview of names after expand-hosts (does not mutate stored names). public static class HostsEffectiveNames { + private sealed record ScopedDomainRule(uint Start, uint End, string Domain); + public static IReadOnlyList Expand( IReadOnlyList names, bool expandHosts, @@ -19,4 +21,139 @@ public static class HostsEffectiveNames .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } + + public static IReadOnlyList Expand( + IReadOnlyList names, + bool expandHosts, + IReadOnlyList? domainValues, + string? address) + { + var domain = ResolveDomainForAddress(domainValues, address); + return Expand(names, expandHosts, domain); + } + + public static string? ResolveDomainForAddress( + IReadOnlyList? domainValues, + string? address) + { + if (domainValues == null || domainValues.Count == 0) + return null; + + string? defaultDomain = null; + var scopedRules = new List(); + + 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 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; + } } diff --git a/src/DnsmasqWebUI/wwwroot/app.css b/src/DnsmasqWebUI/wwwroot/app.css index 14b68cb..0d1b312 100644 --- a/src/DnsmasqWebUI/wwwroot/app.css +++ b/src/DnsmasqWebUI/wwwroot/app.css @@ -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); } diff --git a/src/DnsmasqWebUI/wwwroot/js/modules/hosts-ui.js b/src/DnsmasqWebUI/wwwroot/js/modules/hosts-ui.js deleted file mode 100644 index f9f74fe..0000000 --- a/src/DnsmasqWebUI/wwwroot/js/modules/hosts-ui.js +++ /dev/null @@ -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; - } -}