From c7e7844ce79e7e063edb7a96aa326241a5dba737 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Thu, 26 Mar 2026 00:24:21 +1000 Subject: [PATCH] Update hosts workflow and align DHCP metadata. Redesign the Hosts page around source-aware grouped rows with shared table shell styling, fix effective-config save session synchronization, and move read-ethers into the DHCP model where dnsmasq actually applies it. --- .../Modals/EffectiveConfigSaveFlow.razor | 13 + src/DnsmasqWebUI/Components/Pages/Dhcp.razor | 128 +++--- src/DnsmasqWebUI/Components/Pages/Hosts.razor | 375 +++++++++++++++++- .../Components/Pages/Hosts.razor.css | 191 +++++++++ .../Controllers/HostsController.cs | 31 +- .../Client/Abstractions/IHostsClient.cs | 8 + .../Infrastructure/Client/HostsClient.cs | 16 + .../Dnsmasq/Hosts/Abstractions/IHostsCache.cs | 12 +- .../IHostsPageProjectionService.cs | 12 + .../Services/Dnsmasq/Hosts/HostsCache.cs | 153 ++++++- .../Hosts/HostsPageProjectionService.cs | 156 ++++++++ .../IEffectiveConfigEditSession.cs | 7 + .../EffectiveConfigEditSession.cs | 17 +- .../Metadata/EffectiveConfigSections.cs | 2 +- .../Metadata/EffectiveConfigViews.cs | 3 +- .../EffectiveConfigRenderFragmentRegistry.cs | 3 +- .../Dnsmasq/PageStates/DhcpPageState.cs | 53 +++ .../Dnsmasq/PageStates/HostsPageState.cs | 51 +++ .../Models/Hosts/HostsEffectiveNames.cs | 22 + .../Models/Hosts/HostsPageGroup.cs | 15 + src/DnsmasqWebUI/Models/Hosts/HostsPageRow.cs | 32 ++ src/DnsmasqWebUI/Models/Hosts/HostsPageUi.cs | 27 ++ src/DnsmasqWebUI/wwwroot/app.css | 133 +++++++ .../wwwroot/js/modules/table-scroll-cue.js | 160 ++++++++ 24 files changed, 1541 insertions(+), 79 deletions(-) create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/Abstractions/IHostsPageProjectionService.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsPageProjectionService.cs create mode 100644 src/DnsmasqWebUI/Models/Dnsmasq/PageStates/DhcpPageState.cs create mode 100644 src/DnsmasqWebUI/Models/Dnsmasq/PageStates/HostsPageState.cs create mode 100644 src/DnsmasqWebUI/Models/Hosts/HostsEffectiveNames.cs create mode 100644 src/DnsmasqWebUI/Models/Hosts/HostsPageGroup.cs create mode 100644 src/DnsmasqWebUI/Models/Hosts/HostsPageRow.cs create mode 100644 src/DnsmasqWebUI/Models/Hosts/HostsPageUi.cs create mode 100644 src/DnsmasqWebUI/wwwroot/js/modules/table-scroll-cue.js diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor index 9b5fb6e..d437869 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor @@ -84,6 +84,13 @@ var result = await Session.ApplyAsync(); _saveResult = result; + if (result.Saved) + { + // Changes were written to disk, so clear them from session state. + // Whether to stay in edit mode depends on whether restart succeeded. + Session.AcceptAppliedChanges(stayInEditMode: result.IsValidateFailed || !result.Restarted); + } + if (result.Saved && result.Restarted) { var message = result.UserMessage ?? "Config saved and dnsmasq restarted."; @@ -118,6 +125,12 @@ var result = await SaveService.RestoreAsync(backups); _restoreResult = result; + if (result.Restored) + { + // Restore wrote to disk, so clear any pending changes from session. + Session.AcceptAppliedChanges(stayInEditMode: false); + } + if (result.Restored && result.Restarted) { var message = result.UserMessage ?? "Backup restored and dnsmasq restarted."; diff --git a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor index ae85e7e..d2b0013 100644 --- a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor +++ b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor @@ -2,7 +2,9 @@ @page "/leases" @rendermode InteractiveServer @namespace DnsmasqWebUI.Components.Pages +@using DnsmasqWebUI.Models.Dnsmasq @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Models.Dnsmasq.PageStates @using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions @using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata @using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions @@ -31,12 +33,38 @@ } else { -
- -
+ var pageState = DhcpPageState.FromStatus(_status); - @* ---- Static hosts (dhcp-host= in managed file) ---- *@ -
+ @if (!pageState.DhcpAvailable) + { +
+

DHCP not available

+

@pageState.DhcpUnavailableReason

+
+
+

Advanced configuration

+

DHCP-specific options are not available, but you can view the effective config below.

+ +
+ } + else + { +
+

Effective configuration

+

Use the editor below for full dnsmasq flexibility. The workflow UI appears underneath.

+ +
+ + @if (pageState.ReadEthersEnabled) + { +
+ read-ethers is enabled. Dnsmasq reads /etc/ethers and + treats its entries like dhcp-host mappings for DHCP clients. +
+ } + + @* ---- Static hosts (dhcp-host= in managed file) ---- *@ +

Static hosts

@if (!_status.ManagedFilePathExists) { @@ -234,42 +262,50 @@ else }
- @* ---- Leases (dhcp-leasefile, read-only) ---- *@ -
-
-

Leases

- @if (_status.LeasesPathConfigured) - { - - } -
- @if (_status.LeasesPathConfigured && !string.IsNullOrEmpty(_status.LeasesPath)) - { -

Path: @_status.LeasesPath (read-only).

- } - @if (!_status.LeasesPathConfigured) - { -

Leases not configured (no dhcp-leasefile in config).

- } - else if (!_status.LeasesPathExists) - { -

Leases file not found: @_status.LeasesPath. DHCP may be disabled or path may be wrong.

- } - else if (_leases == null) - { -

Could not read leases file.

- } - else if (_leases.Count == 0) - { -

No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.

-
- + @* ---- Leases (dhcp-leasefile, read-only) ---- *@ +
+
+

Leases

+ @if (pageState.LeasesAvailable && _status.LeasesPathConfigured) + { + + }
- } - else - { + @if (!pageState.LeasesAvailable) + { +
+

@pageState.LeasesUnavailableReason

+
+ } + else + { + @if (_status.LeasesPathConfigured && !string.IsNullOrEmpty(_status.LeasesPath)) + { +

Path: @_status.LeasesPath (read-only).

+ } + @if (!_status.LeasesPathConfigured) + { +

Leases not configured (no dhcp-leasefile in config).

+ } + else if (!_status.LeasesPathExists) + { +

Leases file not found: @_status.LeasesPath. DHCP may be disabled or path may be wrong.

+ } + else if (_leases == null) + { +

Could not read leases file.

+ } + else if (_leases.Count == 0) + { +

No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.

+
+ +
+ } + else + {
@@ -329,12 +365,14 @@ else }
-
- -
- } -
+
+ +
+ } + } +
+ } } @code { diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor b/src/DnsmasqWebUI/Components/Pages/Hosts.razor index 4458503..6c0ece8 100644 --- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor +++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor @@ -1,14 +1,24 @@ @page "/hosts" @rendermode InteractiveServer @namespace DnsmasqWebUI.Components.Pages +@using DnsmasqWebUI.Models.Dnsmasq @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Models.Dnsmasq.PageStates +@using DnsmasqWebUI.Models.Hosts @using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions +@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions +@using DnsmasqWebUI.Components.EffectiveConfig +@using Microsoft.JSInterop +@implements IDisposable @inject IStatusClient StatusClient @inject IHostsClient HostsClient @inject IEffectiveConfigEditSession Session +@inject IHostsPageProjectionService HostsProjection +@inject IJSRuntime JSRuntime @inject IOptions AppOptions Hosts | @AppTitle +

Hosts Configuration

@if (_status == null) @@ -20,12 +30,25 @@ } else { -
+ var pageState = HostsPageState.FromStatus(_status); + +
+

Effective configuration

- @if (string.IsNullOrEmpty(_status.ManagedHostsFilePath)) + + @if (!pageState.SystemHostsActive && !string.IsNullOrEmpty(pageState.SystemHostsInactiveReason)) { -

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.

+
+ System hosts is inactive: @pageState.SystemHostsInactiveReason +
+ } + + @if (!pageState.ManagedHostsAvailable) + { +
+

@pageState.ManagedHostsUnavailableReason

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

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

@@ -42,30 +65,254 @@ else
@_error
} - -

Save changes via the toolbar above.

+
+

Hosts entries

- @if (_readOnlyHosts?.Count > 0) - { - @foreach (var ro in _readOnlyHosts) +
+
+ +
+
+ + @if (_unifiedRows == null || _unifiedRows.Count == 0) { - +

No hosts entries found.

} - } + else if (VisibleGroups.Count == 0) + { +

No rows match the current search.

+ } + else + { +
+
+ + + + + + + + + + + @foreach (var group in VisibleGroups) + { + + + + + @foreach (var row in group.Rows) + { + @if (IsEditingRow(row)) + { + + + + + + + } + else + { + + + + + + + } + } + + } +
Hosts entries grouped for scanning and editing
AddressNamesEffective namesStatus
+
+
+ @group.Title + @if (group.GroupContainsEditableRows) + { + Editable + } + @if (!group.IsActive) + { + Inactive + } + @if (!string.IsNullOrWhiteSpace(group.Subtitle)) + { + @group.Subtitle + } +
+
+
+ + + + @string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, pageState.ExpansionDomain)) +
+ Editable + @if (row.IsComment) + { + Commented + } +
+ + +
+
+
@row.Address@string.Join(", ", row.Names) + @if (row.EffectiveNames.Count != row.Names.Count) + { + @string.Join(", ", row.EffectiveNames) + } + else + { + @string.Join(", ", row.EffectiveNames) + } + +
+ @if (!row.IsActive) + { + Inactive + } + @if (row.IsComment) + { + Commented + } + @if (row.IsEditable) + { + Editable + } + else + { + Read-only + } + @if (row.IsEditable && string.IsNullOrEmpty(_editingRowId)) + { + + } +
+
+
+
+ } + + @if (pageState.ExpandHostsEnabled && !string.IsNullOrEmpty(pageState.ExpansionDomain)) + { +
+ expand-hosts is enabled with domain @pageState.ExpansionDomain. + Simple hostnames (without dots) will be expanded to include the domain suffix. + The Effective names column shows how dnsmasq will resolve each entry. +
+ } + +
} } +
@code { private string AppTitle => AppOptions!.Value!.EffectiveTitle; private DnsmasqServiceStatus? _status; - private List _entries = new(); + private IReadOnlyList _unifiedRows = Array.Empty(); + private List _managedEntries = new(); private List _originalManagedEntries = new(); - private IReadOnlyList? _readOnlyHosts; private string? _error; - private static List CloneEntries(IReadOnlyList entries) + private string _search = ""; + + private string? _editingRowId; + private string _editAddress = ""; + private string _editNames = ""; + private IJSObjectReference? _hostsTableScrollCueModule; + + private IReadOnlyList VisibleGroups => + HostsProjection.BuildGroups(_unifiedRows, BuildQuery()); + + private HostsPageQueryState BuildQuery() { - return entries.Select(e => new HostEntry + return new HostsPageQueryState( + Search: _search, + Grouping: HostsGroupingMode.Source, + Sort: HostsSortMode.LineNumber, + Descending: false, + SourceKindFilter: null, + EditableFilter: null, + ActiveFilter: null); + } + + private static string RowClass(HostsPageRow row) + { + var parts = new List { "hosts-row" }; + if (row.IsComment) + parts.Add("table-secondary"); + if (!row.IsActive) + parts.Add("hosts-row--inactive"); + return string.Join(" ", parts); + } + + private bool IsEditingRow(HostsPageRow row) => + !string.IsNullOrEmpty(_editingRowId) && string.Equals(_editingRowId, row.Id, StringComparison.Ordinal); + + private void BeginEdit(HostsPageRow row) + { + if (!row.IsEditable || _status == null) + return; + var entry = FindManagedEntry(row); + if (entry == null) + return; + _editingRowId = row.Id; + _editAddress = entry.Address; + _editNames = string.Join(", ", entry.Names); + } + + private void CancelEdit() + { + _editingRowId = null; + _editAddress = ""; + _editNames = ""; + } + + private async Task SaveEditAsync() + { + if (string.IsNullOrEmpty(_editingRowId) || _status == null) + return; + var entry = _managedEntries.FirstOrDefault(MatchesEditingId); + if (entry == null) + { + CancelEdit(); + return; + } + + entry.Address = _editAddress.Trim(); + entry.Names = ParseNamesList(_editNames); + ApplyManagedOverlay(); + SyncManagedHostsDraft(); + CancelEdit(); + await InvokeAsync(StateHasChanged); + } + + private bool MatchesEditingId(HostEntry e) + { + var id = string.IsNullOrEmpty(e.Id) ? $"managed:{e.LineNumber}" : e.Id; + return string.Equals(id, _editingRowId, StringComparison.Ordinal); + } + + private HostEntry? FindManagedEntry(HostsPageRow row) => + _managedEntries.FirstOrDefault(e => + string.Equals(e.Id, row.Id, StringComparison.Ordinal) || + string.Equals($"managed:{e.LineNumber}", row.Id, StringComparison.Ordinal)); + + private static List ParseNamesList(string raw) => + raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(s => !string.IsNullOrEmpty(s)) + .ToList(); + + private static List CloneEntries(IReadOnlyList entries) => + entries.Select(e => new HostEntry { LineNumber = e.LineNumber, Id = e.Id, @@ -75,13 +322,12 @@ else IsComment = e.IsComment, IsPassthrough = e.IsPassthrough }).ToList(); - } private void SyncManagedHostsDraft() { if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath)) return; - var draft = CloneEntries(_entries); + var draft = CloneEntries(_managedEntries); var original = CloneEntries(_originalManagedEntries); var pending = new PendingManagedHostsChange(original, draft, _status.ManagedHostsFilePath); if (pending.HasChanges) @@ -90,7 +336,6 @@ else Session.RevertManagedHostsChange(); } - /// Returns MarkupString so Blazor renders raw HTML (path + source label). private MarkupString RenderAddnHostsList() { if (_status?.AddnHostsPaths == null || _status.AddnHostsPaths.Count == 0) @@ -114,6 +359,27 @@ else await Load(); } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _hostsTableScrollCueModule = await JSRuntime.InvokeAsync( + "import", + "/js/modules/table-scroll-cue.js"); + await _hostsTableScrollCueModule.InvokeVoidAsync("initTableScrollCue"); + } + else if (_hostsTableScrollCueModule != null) + { + try + { + await _hostsTableScrollCueModule.InvokeVoidAsync("updateTableScrollCue"); + } + catch (JSDisconnectedException) + { + } + } + } + private async Task RefreshStatusAsync() { try @@ -130,10 +396,23 @@ else try { _status = await StatusClient.GetStatusAsync(); - var entries = await HostsClient.GetHostsAsync(); - _entries = entries?.ToList() ?? new List(); - _originalManagedEntries = CloneEntries(_entries); - _readOnlyHosts = await HostsClient.GetReadOnlyHostsAsync(); + _managedEntries = (await HostsClient.GetHostsAsync()).ToList(); + _originalManagedEntries = CloneEntries(_managedEntries); + + if (_status?.EffectiveConfig != null && !string.IsNullOrEmpty(_status.ManagedHostsFilePath)) + { + var expandHosts = _status.EffectiveConfig.ExpandHosts; + var domain = _status.EffectiveConfig.DomainValues?.FirstOrDefault(); + var noHosts = _status.NoHosts; + var fromApi = await HostsClient.GetUnifiedRowsAsync(expandHosts, domain, noHosts, _status.ManagedHostsFilePath); + _unifiedRows = fromApi.ToList(); + ApplyManagedOverlay(); + } + else + { + _unifiedRows = Array.Empty(); + } + SyncManagedHostsDraft(); } catch (Exception ex) @@ -142,4 +421,58 @@ else } } + private void ApplyManagedOverlay() + { + if (_status?.EffectiveConfig == null || string.IsNullOrEmpty(_status.ManagedHostsFilePath)) + return; + + var expandHosts = _status.EffectiveConfig.ExpandHosts; + var domain = _status.EffectiveConfig.DomainValues?.FirstOrDefault(); + var path = _status.ManagedHostsFilePath; + var managedRows = new List(); + + foreach (var e in _managedEntries.Where(x => !x.IsPassthrough)) + { + var names = e.Names ?? (IReadOnlyList)Array.Empty(); + var effective = HostsEffectiveNames.Expand(names, expandHosts, domain); + var id = string.IsNullOrEmpty(e.Id) ? $"managed:{e.LineNumber}" : e.Id; + managedRows.Add(new HostsPageRow( + id, + HostsRowSourceKind.Managed, + path, + IsEditable: true, + IsActive: true, + InactiveReason: null, + Address: e.Address ?? "", + Names: names, + EffectiveNames: effective, + IsComment: e.IsComment, + LineNumber: e.LineNumber)); + } + + var rest = _unifiedRows.Where(r => r.SourceKind != HostsRowSourceKind.Managed).ToList(); + _unifiedRows = managedRows.Concat(rest).ToList(); + } + + public void Dispose() + { + _ = CleanupHostsTableScrollCueAsync(); + } + + private async Task CleanupHostsTableScrollCueAsync() + { + if (_hostsTableScrollCueModule == null) + return; + + try + { + await _hostsTableScrollCueModule.InvokeVoidAsync("disposeTableScrollCue"); + await _hostsTableScrollCueModule.DisposeAsync(); + } + catch (JSDisconnectedException) + { + } + + _hostsTableScrollCueModule = null; + } } diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css b/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css index 2ee0fe7..a44f506 100644 --- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css +++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css @@ -28,6 +28,10 @@ vertical-align: middle; } +.hosts-page { + display: block; +} + @media (max-width: 640.98px) { ::deep .table { font-size: 0.875rem; @@ -38,3 +42,190 @@ padding: 0.5rem; } } + +/* Toolbar */ +.hosts-controls-bar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1rem; + padding: 0.875rem 1rem; + background: linear-gradient(180deg, #fbfcfe 0%, #f6f8fb 100%); + border: 1px solid #e7ebf0; + border-radius: 14px; + box-shadow: 0 10px 24px -18px rgba(15, 23, 42, 0.35); +} + +.hosts-toolbar-search { + min-width: 14rem; + max-width: 24rem; +} + +.hosts-toolbar-select { + min-width: 9rem; + max-width: 14rem; +} + +.hosts-summary-strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); + gap: 0.75rem; +} + +.hosts-summary-stat { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.85rem 0.95rem; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + border: 1px solid #e7ebf0; + border-radius: 14px; + box-shadow: 0 8px 20px -18px rgba(15, 23, 42, 0.45); +} + +.hosts-summary-stat__value { + font-size: 1.15rem; + font-weight: 700; + line-height: 1.1; + color: #0f172a; +} + +.hosts-summary-stat__label { + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + color: #64748b; +} + +/* Group header row (colspan, not rowspan) */ +::deep .hosts-table .hosts-group-header-cell { + background: linear-gradient(180deg, #f8fafc 0%, #f6f8fb 100%); + border-top: 1px solid #dbe3ec; + border-bottom: 1px solid #dbe3ec; + font-weight: 600; + vertical-align: middle; + padding-top: 0.6rem; + padding-bottom: 0.6rem; +} + +.hosts-group-header__content { + width: 100%; +} + +.hosts-group-header__title { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + color: #0f172a; +} + +.hosts-group-header__subtitle { + display: inline-flex; + align-items: center; + line-height: 1.2; + margin-left: 0.25rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: min(38rem, 50vw); +} + +::deep .hosts-row--inactive { + opacity: 0.72; +} + +::deep .hosts-row code { + font-size: 0.93em; + color: #0f172a; + background: #f8fafc; + padding: 0.15rem 0.35rem; + border-radius: 6px; +} + +::deep .hosts-row--editing td { + 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; + border-radius: 12px; + } + + .hosts-toolbar-search, + .hosts-toolbar-select { + min-width: 0; + max-width: none; + width: 100%; + } + + .hosts-group-header__subtitle { + max-width: 100%; + margin-left: 0; + } +} diff --git a/src/DnsmasqWebUI/Controllers/HostsController.cs b/src/DnsmasqWebUI/Controllers/HostsController.cs index 81380a9..412e635 100644 --- a/src/DnsmasqWebUI/Controllers/HostsController.cs +++ b/src/DnsmasqWebUI/Controllers/HostsController.cs @@ -1,4 +1,5 @@ using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions; using DnsmasqWebUI.Models.Hosts; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -11,12 +12,18 @@ public class HostsController : ControllerBase { private readonly IHostsFileService _hostsService; private readonly IHostsCache _hostsCache; + private readonly IDnsmasqConfigSetService _configSetService; private readonly ILogger _logger; - public HostsController(IHostsFileService hostsService, IHostsCache hostsCache, ILogger logger) + public HostsController( + IHostsFileService hostsService, + IHostsCache hostsCache, + IDnsmasqConfigSetService configSetService, + ILogger logger) { _hostsService = hostsService; _hostsCache = hostsCache; + _configSetService = configSetService; _logger = logger; } @@ -52,4 +59,26 @@ public class HostsController : ControllerBase return StatusCode(500, new { error = ex.Message }); } } + + /// Returns unified rows for the Hosts page, preserving source awareness. + [HttpGet("unified")] + public async Task>> GetUnified( + [FromQuery] bool expandHosts, + [FromQuery] string? domain, + [FromQuery] bool noHosts, + [FromQuery] string? managedHostsPath, + CancellationToken ct) + { + try + { + _logger.LogDebug("Get unified hosts rows"); + var rows = await _hostsCache.GetUnifiedRowsAsync(expandHosts, domain, noHosts, managedHostsPath, ct); + return Ok(rows); + } + catch (Exception ex) + { + _logger.LogError(ex, "Get unified hosts failed"); + return StatusCode(500, new { error = ex.Message }); + } + } } diff --git a/src/DnsmasqWebUI/Infrastructure/Client/Abstractions/IHostsClient.cs b/src/DnsmasqWebUI/Infrastructure/Client/Abstractions/IHostsClient.cs index 09a4a09..2977045 100644 --- a/src/DnsmasqWebUI/Infrastructure/Client/Abstractions/IHostsClient.cs +++ b/src/DnsmasqWebUI/Infrastructure/Client/Abstractions/IHostsClient.cs @@ -12,4 +12,12 @@ public interface IHostsClient /// Gets read-only hosts files (system + addn-hosts) from GET api/hosts/readonly. Task> GetReadOnlyHostsAsync(CancellationToken ct = default); + + /// Gets unified hosts rows (managed + read-only) from GET api/hosts/unified. + Task> GetUnifiedRowsAsync( + bool expandHosts, + string? domain, + bool noHosts, + string? managedHostsPath, + CancellationToken ct = default); } diff --git a/src/DnsmasqWebUI/Infrastructure/Client/HostsClient.cs b/src/DnsmasqWebUI/Infrastructure/Client/HostsClient.cs index ddd163a..b69675f 100644 --- a/src/DnsmasqWebUI/Infrastructure/Client/HostsClient.cs +++ b/src/DnsmasqWebUI/Infrastructure/Client/HostsClient.cs @@ -22,4 +22,20 @@ public sealed class HostsClient : IHostsClient var list = await _http.GetFromJsonAsync>("api/hosts/readonly", ApiJsonOptions.ClientOptions, ct); return list ?? new List(); } + + public async Task> GetUnifiedRowsAsync( + bool expandHosts, + string? domain, + bool noHosts, + string? managedHostsPath, + CancellationToken ct = default) + { + var queryParams = $"?expandHosts={expandHosts}&noHosts={noHosts}"; + if (!string.IsNullOrEmpty(domain)) + queryParams += $"&domain={Uri.EscapeDataString(domain)}"; + if (!string.IsNullOrEmpty(managedHostsPath)) + queryParams += $"&managedHostsPath={Uri.EscapeDataString(managedHostsPath)}"; + var list = await _http.GetFromJsonAsync>($"api/hosts/unified{queryParams}", ApiJsonOptions.ClientOptions, ct); + return list ?? new List(); + } } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/Abstractions/IHostsCache.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/Abstractions/IHostsCache.cs index f2b7e22..a631111 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/Abstractions/IHostsCache.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/Abstractions/IHostsCache.cs @@ -5,7 +5,7 @@ using DnsmasqWebUI.Models.Hosts; namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions; /// -/// Singleton cache for hosts: managed hosts file + read-only (system + addn-hosts). Invalidates on watchers, staleness, or Invalidate. +/// Singleton cache for hosts: managed hosts file + read-only (system + addn-hosts + hostsdir). Invalidates on watchers, staleness, or Invalidate. /// Call after the app writes the managed hosts file so the cache updates in place and ignores the next watcher event. /// public interface IHostsCache : IApplicationSingleton @@ -13,4 +13,14 @@ public interface IHostsCache : IApplicationSingleton Task GetSnapshotAsync(CancellationToken ct = default); void Invalidate(); void NotifyWeWroteManagedHosts(IReadOnlyList entries); + + /// + /// Builds unified page rows from the snapshot, preserving source awareness. + /// + Task> GetUnifiedRowsAsync( + bool expandHosts, + string? domain, + bool noHosts, + string? managedHostsPath, + CancellationToken ct = default); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/Abstractions/IHostsPageProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/Abstractions/IHostsPageProjectionService.cs new file mode 100644 index 0000000..b35df72 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/Abstractions/IHostsPageProjectionService.cs @@ -0,0 +1,12 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; +using DnsmasqWebUI.Models.Hosts; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions; + +/// +/// Projects flat into grouped, filtered, sorted Hosts page groups. +/// +public interface IHostsPageProjectionService : IApplicationSingleton +{ + IReadOnlyList BuildGroups(IReadOnlyList rows, HostsPageQueryState query); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsCache.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsCache.cs index ee7113b..6f64473 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsCache.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsCache.cs @@ -80,7 +80,8 @@ public sealed class HostsCache : IHostsCache, IDisposable TryEnsureWatchers(managedPath); var managedEntries = ReadManagedEntries(managedPath); - var readOnlyFiles = ReadReadOnlyFiles(managedPath, addnPaths, noHosts, ct); + var hostsdirPath = effectiveConfig.HostsdirPath; + var readOnlyFiles = ReadReadOnlyFiles(managedPath, addnPaths, noHosts, hostsdirPath, ct); _lastManagedHostsPath = !string.IsNullOrEmpty(managedPath) ? Path.GetFullPath(managedPath) : null; _snapshot = new HostsSnapshot(managedEntries, readOnlyFiles); @@ -185,13 +186,14 @@ public sealed class HostsCache : IHostsCache, IDisposable return entries; } - private List ReadReadOnlyFiles(string? managedPath, IReadOnlyList addnPaths, bool noHosts, CancellationToken ct) + private List ReadReadOnlyFiles(string? managedPath, IReadOnlyList addnPaths, bool noHosts, string? hostsdirPath, CancellationToken ct) { var result = new List(); var managedPathFull = !string.IsNullOrEmpty(managedPath) ? Path.GetFullPath(managedPath) : null; var systemPath = _options.SystemHostsPath?.Trim(); var systemPathFull = !string.IsNullOrEmpty(systemPath) ? Path.GetFullPath(systemPath) : null; + // System hosts (only if no-hosts is not set) if (!noHosts && !string.IsNullOrEmpty(systemPathFull) && File.Exists(systemPathFull)) { try @@ -213,6 +215,7 @@ public sealed class HostsCache : IHostsCache, IDisposable } } + // Addn-hosts files foreach (var p in addnPaths) { ct.ThrowIfCancellationRequested(); @@ -241,9 +244,155 @@ public sealed class HostsCache : IHostsCache, IDisposable } } + // Hostsdir files (enumerated from directory) + if (!string.IsNullOrEmpty(hostsdirPath) && Directory.Exists(hostsdirPath)) + { + try + { + var files = Directory.GetFiles(hostsdirPath) + .OrderBy(f => f, StringComparer.Ordinal) + .ToList(); + foreach (var filePath in files) + { + ct.ThrowIfCancellationRequested(); + var fullPath = Path.GetFullPath(filePath); + if (managedPathFull != null && string.Equals(fullPath, managedPathFull, StringComparison.Ordinal)) + continue; + if (systemPathFull != null && string.Equals(fullPath, systemPathFull, StringComparison.Ordinal)) + continue; + if (addnPaths.Any(p => string.Equals(Path.GetFullPath(p), fullPath, StringComparison.Ordinal))) + continue; + if (!File.Exists(fullPath)) + continue; + try + { + var lines = File.ReadAllLines(fullPath, Encoding.UTF8); + var entries = new List(); + for (var i = 0; i < lines.Length; i++) + { + var entry = HostsFileLineParser.ParseLine(lines[i], i + 1); + if (entry != null) + entries.Add(entry); + } + result.Add(new ReadOnlyHostsFile(fullPath, entries)); + } + catch + { + // Skip unreadable + } + } + } + catch + { + // Skip if directory is unreadable + } + } + return result; } + public async Task> GetUnifiedRowsAsync( + bool expandHosts, + string? domain, + bool noHosts, + string? managedHostsPath, + CancellationToken ct = default) + { + var snapshot = await GetSnapshotAsync(ct); + var rows = new List(); + var managedPathFull = !string.IsNullOrEmpty(managedHostsPath) ? Path.GetFullPath(managedHostsPath) : null; + + // Get config to determine hostsdir path + var configSnapshot = await _configSetCache.GetSnapshotAsync(ct); + var hostsdirPath = configSnapshot.Config?.HostsdirPath; + var hostsdirPathFull = !string.IsNullOrEmpty(hostsdirPath) ? Path.GetFullPath(hostsdirPath) : null; + + // Managed hosts (editable) + foreach (var entry in snapshot.ManagedEntries) + { + if (entry.IsPassthrough) continue; + var names = entry.Names ?? (IReadOnlyList)Array.Empty(); + var effectiveNames = HostsEffectiveNames.Expand(names, expandHosts, domain); + rows.Add(new HostsPageRow( + Id: entry.Id ?? $"managed:{entry.LineNumber}", + SourceKind: HostsRowSourceKind.Managed, + SourcePath: managedHostsPath ?? "", + IsEditable: true, + IsActive: true, + InactiveReason: null, + Address: entry.Address ?? "", + Names: names, + EffectiveNames: effectiveNames, + IsComment: entry.IsComment, + LineNumber: entry.LineNumber)); + } + + // Read-only files (system, addn-hosts, hostsdir) + foreach (var file in snapshot.ReadOnlyFiles) + { + var sourceKind = DetermineSourceKind(file.Path, managedPathFull, _options.SystemHostsPath, hostsdirPathFull); + var isActive = sourceKind == HostsRowSourceKind.SystemHosts ? !noHosts : true; + var inactiveReason = sourceKind == HostsRowSourceKind.SystemHosts && noHosts + ? "System hosts is ignored because no-hosts is enabled in dnsmasq config." + : null; + + foreach (var entry in file.Entries) + { + if (entry.IsPassthrough) continue; + var names = entry.Names ?? (IReadOnlyList)Array.Empty(); + var effectiveNames = HostsEffectiveNames.Expand(names, expandHosts, domain); + rows.Add(new HostsPageRow( + Id: $"{sourceKind}:{file.Path}:{entry.LineNumber}", + SourceKind: sourceKind, + SourcePath: file.Path, + IsEditable: false, + IsActive: isActive, + InactiveReason: inactiveReason, + Address: entry.Address ?? "", + Names: names, + EffectiveNames: effectiveNames, + IsComment: entry.IsComment, + LineNumber: entry.LineNumber)); + } + } + + return rows; + } + + private static HostsRowSourceKind DetermineSourceKind( + string filePath, + string? managedPathFull, + string? systemHostsPath, + string? hostsdirPathFull) + { + var fullPath = Path.GetFullPath(filePath); + if (systemHostsPath != null && string.Equals(Path.GetFullPath(systemHostsPath), fullPath, StringComparison.Ordinal)) + return HostsRowSourceKind.SystemHosts; + if (hostsdirPathFull != null && IsFileUnderDirectory(fullPath, hostsdirPathFull)) + return HostsRowSourceKind.Hostsdir; + return HostsRowSourceKind.AddnHosts; + } + + /// + /// True if is the directory itself or a path inside it. + /// Uses a directory separator after the parent prefix so a hostsdir of /etc/dnsmasq/hosts + /// does not match /etc/dnsmasq/hosts-extra/file (plain would). + /// + private static bool IsFileUnderDirectory(string fileFullPath, string directoryFullPath) + { + var dir = directoryFullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (dir.Length == 0) + return false; + if (string.Equals(fileFullPath, dir, StringComparison.Ordinal)) + return true; + if (fileFullPath.StartsWith(dir + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + return true; + if (Path.DirectorySeparatorChar != Path.AltDirectorySeparatorChar + && fileFullPath.StartsWith(dir + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)) + return true; + return false; + } + public void Dispose() { _watcherManaged?.Dispose(); diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsPageProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsPageProjectionService.cs new file mode 100644 index 0000000..f186c25 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Hosts/HostsPageProjectionService.cs @@ -0,0 +1,156 @@ +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions; +using DnsmasqWebUI.Models.Hosts; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts; + +public sealed class HostsPageProjectionService : IHostsPageProjectionService +{ + public IReadOnlyList BuildGroups(IReadOnlyList rows, HostsPageQueryState query) + { + if (rows.Count == 0) + return Array.Empty(); + + IEnumerable filtered = rows; + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var s = query.Search.Trim(); + filtered = filtered.Where(r => + r.Address.Contains(s, StringComparison.OrdinalIgnoreCase) || + r.Names.Any(n => n.Contains(s, StringComparison.OrdinalIgnoreCase)) || + r.EffectiveNames.Any(n => n.Contains(s, StringComparison.OrdinalIgnoreCase)) || + r.SourcePath.Contains(s, StringComparison.OrdinalIgnoreCase)); + } + + if (query.SourceKindFilter is { } sk) + filtered = filtered.Where(r => r.SourceKind == sk); + + if (query.EditableFilter is { } ed) + filtered = filtered.Where(r => r.IsEditable == ed); + + if (query.ActiveFilter is { } act) + filtered = filtered.Where(r => r.IsActive == act); + + var list = filtered.ToList(); + if (list.Count == 0) + return Array.Empty(); + + return query.Grouping == HostsGroupingMode.Activity + ? BuildActivityGroups(list, query) + : BuildSourceGroups(list, query); + } + + private static IReadOnlyList BuildSourceGroups(List rows, HostsPageQueryState query) + { + var orderedGroups = rows + .GroupBy(r => (r.SourceKind, r.SourcePath)) + .OrderBy(g => GetSourceOrder(g.Key.SourceKind)) + .ThenBy(g => g.Key.SourcePath, StringComparer.OrdinalIgnoreCase); + + var result = new List(); + foreach (var g in orderedGroups) + { + var sortedRows = SortRows(g, query.Sort, query.Descending).ToList(); + if (sortedRows.Count == 0) + continue; + + var first = sortedRows[0]; + var title = TitleForSourceKind(first.SourceKind); + var subtitle = BuildSubtitle(first); + var inactiveReason = first.SourceKind == HostsRowSourceKind.SystemHosts && !first.IsActive + ? first.InactiveReason + : null; + + result.Add(new HostsPageGroup( + Key: $"{first.SourceKind}:{first.SourcePath}", + Title: title, + Subtitle: subtitle, + SourceKind: first.SourceKind, + GroupContainsEditableRows: sortedRows.Any(r => r.IsEditable), + IsActive: sortedRows.Any(r => r.IsActive), + InactiveReason: inactiveReason, + VisibleRowCount: sortedRows.Count, + Rows: sortedRows)); + } + + return result; + } + + private static IReadOnlyList BuildActivityGroups(List rows, HostsPageQueryState query) + { + var orderedGroups = rows + .GroupBy(r => r.IsActive) + .OrderByDescending(g => g.Key); + + var result = new List(); + 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 SortRows(IEnumerable rows, HostsSortMode sort, bool descending) + { + IEnumerable ordered = sort switch + { + HostsSortMode.Name => rows.OrderBy( + x => x.Names.FirstOrDefault() ?? "", + StringComparer.OrdinalIgnoreCase), + HostsSortMode.EffectiveName => rows.OrderBy( + x => x.EffectiveNames.FirstOrDefault() ?? "", + StringComparer.OrdinalIgnoreCase), + HostsSortMode.LineNumber => rows.OrderBy(x => x.LineNumber), + _ => rows.OrderBy(x => x.Address, StringComparer.OrdinalIgnoreCase) + }; + + return descending ? ordered.Reverse() : ordered; + } + + private static int GetSourceOrder(HostsRowSourceKind kind) => kind switch + { + HostsRowSourceKind.Managed => 0, + HostsRowSourceKind.SystemHosts => 1, + HostsRowSourceKind.AddnHosts => 2, + HostsRowSourceKind.Hostsdir => 3, + _ => 99 + }; + + private static string TitleForSourceKind(HostsRowSourceKind kind) => kind switch + { + HostsRowSourceKind.Managed => "Managed hosts", + HostsRowSourceKind.SystemHosts => "System hosts", + HostsRowSourceKind.AddnHosts => "Additional hosts file", + HostsRowSourceKind.Hostsdir => "Hosts directory file", + _ => "Hosts" + }; + + private static string? BuildSubtitle(HostsPageRow first) + { + if (string.IsNullOrWhiteSpace(first.SourcePath)) + return null; + if (!first.IsActive && !string.IsNullOrWhiteSpace(first.InactiveReason)) + return $"{first.SourcePath} — {first.InactiveReason}"; + return first.SourcePath; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs index 8a14d69..cd8a890 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs @@ -41,4 +41,11 @@ public interface IEffectiveConfigEditSession : IApplicationScopedService IReadOnlyList GetValidationSummary(); Task ApplyAsync(CancellationToken ct = default); + + /// + /// Accepts that changes were applied to disk. Clears pending changes and optionally exits edit mode. + /// Call this after a successful write (even if restart failed), so the session state matches disk state. + /// + /// If true, keep edit mode active; if false, exit edit mode. + void AcceptAppliedChanges(bool stayInEditMode); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs index 5f87c98..dd1ab4a 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs @@ -143,12 +143,17 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession if (_pending.Count == 0) return EffectiveConfigSaveResult.NoChanges(); - var result = await _saveService.SaveAsync(_pending.ToList(), ct); - if (result.Saved && result.Restarted) - ExitEditModeDiscard(); - else - NotifyChanged(); - return result; + return await _saveService.SaveAsync(_pending.ToList(), ct); + } + + public void AcceptAppliedChanges(bool stayInEditMode) + { + _pending.Clear(); + _fieldIssues.Clear(); + _crossOptionIssues.Clear(); + ActiveFieldKey = null; + IsEditMode = stayInEditMode; + NotifyChanged(); } private static IReadOnlyList? AsStringList(object? value) diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigSections.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigSections.cs index e77cf2a..1cc0332 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigSections.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigSections.cs @@ -29,7 +29,6 @@ public static class EffectiveConfigSections DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.AddnHosts, DnsmasqConfKeys.Hostsdir, - DnsmasqConfKeys.ReadEthers, ]), new SectionDef(SectionResolver, "Resolver / DNS", [ DnsmasqConfKeys.Server, @@ -101,6 +100,7 @@ public static class EffectiveConfigSections DnsmasqConfKeys.LeasefileRo, DnsmasqConfKeys.Leasequery, DnsmasqConfKeys.DhcpGenerateNames, + DnsmasqConfKeys.ReadEthers, DnsmasqConfKeys.DhcpBroadcast, DnsmasqConfKeys.DhcpSequentialIp, DnsmasqConfKeys.DhcpIgnoreClid, diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigViews.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigViews.cs index 4a257d8..3ab70ee 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigViews.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigViews.cs @@ -38,7 +38,8 @@ public static class EffectiveConfigViews DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.AddnHosts, DnsmasqConfKeys.Hostsdir, - DnsmasqConfKeys.ReadEthers, + ]), + new(EffectiveConfigSections.SectionResolver, "Resolver / DNS", [ DnsmasqConfKeys.ExpandHosts, ]), ]; diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Rendering/EffectiveConfigRenderFragmentRegistry.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Rendering/EffectiveConfigRenderFragmentRegistry.cs index fc1c384..0e25f34 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Rendering/EffectiveConfigRenderFragmentRegistry.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Rendering/EffectiveConfigRenderFragmentRegistry.cs @@ -71,7 +71,7 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm private void RegisterFlagDisplays() { - RegisterFlags(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.ReadEthers); + RegisterFlags(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.NoHosts); RegisterFlags( EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.ExpandHosts, @@ -92,6 +92,7 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm RegisterFlags( EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.DhcpAuthoritative, + DnsmasqConfKeys.ReadEthers, DnsmasqConfKeys.DhcpRapidCommit, DnsmasqConfKeys.LeasefileRo, DnsmasqConfKeys.DhcpSequentialIp, diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/PageStates/DhcpPageState.cs b/src/DnsmasqWebUI/Models/Dnsmasq/PageStates/DhcpPageState.cs new file mode 100644 index 0000000..0a3aad7 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dnsmasq/PageStates/DhcpPageState.cs @@ -0,0 +1,53 @@ +using DnsmasqWebUI.Models.Dnsmasq; + +namespace DnsmasqWebUI.Models.Dnsmasq.PageStates; + +/// +/// Page state for the DHCP specialized page. Derived from DnsmasqServiceStatus to keep Razor markup clean. +/// +public sealed record DhcpPageState( + bool DhcpAvailable, + string? DhcpUnavailableReason, + bool TftpAvailable, + string? TftpUnavailableReason, + bool LeasesAvailable, + string? LeasesUnavailableReason, + bool ReadEthersEnabled) +{ + public static DhcpPageState FromStatus(DnsmasqServiceStatus? status) + { + if (status == null) + { + return new DhcpPageState( + DhcpAvailable: false, + DhcpUnavailableReason: "Status not available.", + TftpAvailable: false, + TftpUnavailableReason: "Status not available.", + LeasesAvailable: false, + LeasesUnavailableReason: "Status not available.", + ReadEthersEnabled: false); + } + + var dhcpAvailable = status.DnsmasqSupportsDhcp; + var tftpAvailable = status.DnsmasqSupportsTftp && dhcpAvailable; // TFTP requires DHCP + + return new DhcpPageState( + DhcpAvailable: dhcpAvailable, + DhcpUnavailableReason: dhcpAvailable + ? null + : "Your dnsmasq was built without DHCP support. Install or build dnsmasq with DHCP enabled to use DHCP features.", + TftpAvailable: tftpAvailable, + TftpUnavailableReason: dhcpAvailable && !tftpAvailable + ? "Your dnsmasq was built without TFTP support. Install or build dnsmasq with TFTP enabled to use PXE/TFTP features." + : !dhcpAvailable + ? "TFTP requires DHCP support." + : null, + LeasesAvailable: dhcpAvailable && status.LeasesPathConfigured, + LeasesUnavailableReason: !dhcpAvailable + ? "DHCP support is required for leases." + : !status.LeasesPathConfigured + ? "Leases are not configured (no dhcp-leasefile in config)." + : null, + ReadEthersEnabled: status.EffectiveConfig?.ReadEthers ?? false); + } +} diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/PageStates/HostsPageState.cs b/src/DnsmasqWebUI/Models/Dnsmasq/PageStates/HostsPageState.cs new file mode 100644 index 0000000..aebbc1c --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dnsmasq/PageStates/HostsPageState.cs @@ -0,0 +1,51 @@ +using DnsmasqWebUI.Models.Dnsmasq; + +namespace DnsmasqWebUI.Models.Dnsmasq.PageStates; + +/// +/// Page state for the Hosts specialized page. Derived from DnsmasqServiceStatus to keep Razor markup clean. +/// +public sealed record HostsPageState( + bool ManagedHostsAvailable, + string? ManagedHostsUnavailableReason, + bool SystemHostsActive, + string? SystemHostsInactiveReason, + bool ExpandHostsEnabled, + string? ExpansionDomain) +{ + public static HostsPageState FromStatus(DnsmasqServiceStatus? status) + { + if (status == null) + { + return new HostsPageState( + ManagedHostsAvailable: false, + ManagedHostsUnavailableReason: "Status not available.", + SystemHostsActive: false, + SystemHostsInactiveReason: "Status not available.", + ExpandHostsEnabled: false, + ExpansionDomain: null); + } + + var managedHostsAvailable = !string.IsNullOrEmpty(status.ManagedHostsFilePath); + var noHosts = status.NoHosts; + var systemHostsActive = !noHosts && !string.IsNullOrEmpty(status.SystemHostsPath) && status.SystemHostsPathExists; + var expandHosts = status.EffectiveConfig?.ExpandHosts ?? false; + var domain = status.EffectiveConfig?.DomainValues?.FirstOrDefault(); + + return new HostsPageState( + ManagedHostsAvailable: managedHostsAvailable, + ManagedHostsUnavailableReason: managedHostsAvailable + ? null + : "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." + : string.IsNullOrEmpty(status.SystemHostsPath) + ? "System hosts path is not configured." + : !status.SystemHostsPathExists + ? $"System hosts file not found: {status.SystemHostsPath}" + : null, + ExpandHostsEnabled: expandHosts, + ExpansionDomain: expandHosts ? domain : null); + } +} diff --git a/src/DnsmasqWebUI/Models/Hosts/HostsEffectiveNames.cs b/src/DnsmasqWebUI/Models/Hosts/HostsEffectiveNames.cs new file mode 100644 index 0000000..8d4b3f4 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Hosts/HostsEffectiveNames.cs @@ -0,0 +1,22 @@ +namespace DnsmasqWebUI.Models.Hosts; + +/// Preview of names after expand-hosts (does not mutate stored names). +public static class HostsEffectiveNames +{ + public static IReadOnlyList Expand( + IReadOnlyList names, + bool expandHosts, + string? domain) + { + if (!expandHosts || string.IsNullOrWhiteSpace(domain)) + return names; + + return names + .SelectMany(name => + name.Contains('.') + ? new[] { name } + : new[] { name, $"{name}.{domain}" }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} diff --git a/src/DnsmasqWebUI/Models/Hosts/HostsPageGroup.cs b/src/DnsmasqWebUI/Models/Hosts/HostsPageGroup.cs new file mode 100644 index 0000000..42078b4 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Hosts/HostsPageGroup.cs @@ -0,0 +1,15 @@ +namespace DnsmasqWebUI.Models.Hosts; + +/// +/// A logical group of for the Hosts page (by source file or by active/inactive). +/// +public sealed record HostsPageGroup( + string Key, + string Title, + string? Subtitle, + HostsRowSourceKind? SourceKind, + bool GroupContainsEditableRows, + bool IsActive, + string? InactiveReason, + int VisibleRowCount, + IReadOnlyList Rows); diff --git a/src/DnsmasqWebUI/Models/Hosts/HostsPageRow.cs b/src/DnsmasqWebUI/Models/Hosts/HostsPageRow.cs new file mode 100644 index 0000000..ee008ae --- /dev/null +++ b/src/DnsmasqWebUI/Models/Hosts/HostsPageRow.cs @@ -0,0 +1,32 @@ +namespace DnsmasqWebUI.Models.Hosts; + +/// +/// Source kind for a hosts row. Used to distinguish editable managed rows from read-only sources. +/// +public enum HostsRowSourceKind +{ + /// Managed hosts file (editable by the app). + Managed, + /// System hosts file (e.g. /etc/hosts). + SystemHosts, + /// Additional hosts file (addn-hosts=). + AddnHosts, + /// Hosts directory (hostsdir=). + Hostsdir +} + +/// +/// Unified row model for the Hosts page. Preserves source awareness while allowing a single rich table. +/// +public sealed record HostsPageRow( + string Id, + HostsRowSourceKind SourceKind, + string SourcePath, + bool IsEditable, + bool IsActive, + string? InactiveReason, + string Address, + IReadOnlyList Names, + IReadOnlyList EffectiveNames, + bool IsComment, + int LineNumber); diff --git a/src/DnsmasqWebUI/Models/Hosts/HostsPageUi.cs b/src/DnsmasqWebUI/Models/Hosts/HostsPageUi.cs new file mode 100644 index 0000000..a78c13a --- /dev/null +++ b/src/DnsmasqWebUI/Models/Hosts/HostsPageUi.cs @@ -0,0 +1,27 @@ +namespace DnsmasqWebUI.Models.Hosts; + +/// How rows are grouped on the Hosts page. +public enum HostsGroupingMode +{ + Source, + Activity +} + +/// Sort key for rows within each group. +public enum HostsSortMode +{ + Address, + Name, + EffectiveName, + LineNumber +} + +/// Filter and sort state for . +public sealed record HostsPageQueryState( + string Search, + HostsGroupingMode Grouping, + HostsSortMode Sort, + bool Descending, + HostsRowSourceKind? SourceKindFilter, + bool? EditableFilter, + bool? ActiveFilter); diff --git a/src/DnsmasqWebUI/wwwroot/app.css b/src/DnsmasqWebUI/wwwroot/app.css index f382ec3..14b68cb 100644 --- a/src/DnsmasqWebUI/wwwroot/app.css +++ b/src/DnsmasqWebUI/wwwroot/app.css @@ -258,6 +258,139 @@ h1:focus { align-items: center; } +/* Shared table shell for rich data tables that need horizontal scrolling. */ +.app-table-scroll-shell { + position: relative; + width: 100%; + overflow: visible; + border: 1px solid #e7ebf0; + border-radius: 16px; + background: #fff; + box-shadow: 0 12px 28px -22px rgba(15, 23, 42, 0.45); +} + +.app-table-scroll-region { + width: 100%; + overflow-x: auto; + overflow-y: clip; + -webkit-overflow-scrolling: touch; + border-radius: 16px; + scrollbar-width: thin; + scrollbar-color: #b8c3d1 #eef2f6; +} + +.app-table-scroll-region::-webkit-scrollbar { + height: 8px; +} + +.app-table-scroll-region::-webkit-scrollbar-track { + background: #eef2f6; + border-radius: 0 0 16px 16px; +} + +.app-table-scroll-region::-webkit-scrollbar-thumb { + background: #c6cfda; + border-radius: 999px; +} + +.app-table-scroll-region::-webkit-scrollbar-thumb:hover { + background: #aeb9c7; +} + +.app-table-scroll-region:focus { + outline: 2px solid rgba(13, 110, 253, 0.25); + outline-offset: 2px; +} + +.app-table-cue-btn { + position: absolute; + top: 0; + bottom: 0; + width: 34px; + border: none; + padding: 0; + cursor: pointer; + z-index: 2; + opacity: 0.92; + transition: opacity 0.15s ease; + color: #1f2937; + font-size: 1.1rem; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.app-table-cue-btn:hover { + opacity: 1; +} + +.app-table-cue-btn:focus { + outline: 2px solid rgba(13, 110, 253, 0.25); + outline-offset: 2px; +} + +.app-table-scroll-shell .app-table-cue-btn--left { + left: 0; + background: linear-gradient(to right, rgba(248, 250, 252, 0.98), rgba(248, 250, 252, 0.68), transparent); +} + +.app-table-scroll-shell:not(.can-scroll-left) .app-table-cue-btn--left { + display: none; +} + +.app-table-scroll-shell .app-table-cue-btn--right { + right: 0; + background: linear-gradient(to left, rgba(248, 250, 252, 0.98), rgba(248, 250, 252, 0.68), transparent); +} + +.app-table-scroll-shell:not(.can-scroll-right) .app-table-cue-btn--right { + display: none; +} + +.app-data-table { + margin-bottom: 0; + border-collapse: separate; + border-spacing: 0; +} + +.app-data-table thead th { + position: sticky; + top: 0; + z-index: 1; + background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%); + color: #334155; + font-size: 0.825rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + border-top: none; + white-space: nowrap; +} + +.app-data-table td, +.app-data-table th { + padding: 0.85rem 1rem; + border-color: #e7ebf0; + vertical-align: middle; +} + +.app-data-table tbody tr:hover { + background-color: #f8fbff; +} + +@media (max-width: 991.98px) { + .app-table-cue-btn { + width: 28px; + font-size: 1rem; + } + + .app-data-table td, + .app-data-table th { + padding: 0.7rem 0.8rem; + } +} + /* Settings gear icon size (Bootstrap Icons) */ .bi-gear-sm { font-size: 1.125rem; diff --git a/src/DnsmasqWebUI/wwwroot/js/modules/table-scroll-cue.js b/src/DnsmasqWebUI/wwwroot/js/modules/table-scroll-cue.js new file mode 100644 index 0000000..5b76e36 --- /dev/null +++ b/src/DnsmasqWebUI/wwwroot/js/modules/table-scroll-cue.js @@ -0,0 +1,160 @@ +// Generic table scroll cues for horizontally scrollable tables. +// Works with reusable data attributes: +// host: [data-table-scroll-cue] +// region: [data-table-scroll-region] + +const OUTER_SELECTOR = '[data-table-scroll-cue]'; +const WRAPPER_SELECTOR = '[data-table-scroll-region]'; +const POLL_MS = 100; + +const BTN_LEFT_CLASS = 'app-table-cue-btn--left'; +const BTN_RIGHT_CLASS = 'app-table-cue-btn--right'; +const CUE_BTN_CLASS = 'app-table-cue-btn'; + +let pollId = null; +let resizeListener = null; +const scrollListeners = new WeakMap(); + +function applyScrollCue(scrollEl, hostEl) { + if (!scrollEl || !hostEl) return; + + const scrollWidth = scrollEl.scrollWidth; + const clientWidth = scrollEl.clientWidth; + const scrollLeft = scrollEl.scrollLeft; + const scrollable = scrollWidth > clientWidth; + const atEnd = scrollable && scrollLeft >= scrollWidth - clientWidth - 1; + const atStart = scrollLeft <= 0; + const canScrollRight = scrollable && !atEnd; + const canScrollLeft = !atStart; + + hostEl.classList.toggle('can-scroll-right', canScrollRight); + hostEl.classList.toggle('at-end', atEnd); + hostEl.classList.toggle('at-start', atStart); + hostEl.classList.toggle('can-scroll-left', canScrollLeft); +} + +function scrollToSide(scrollEl, side) { + if (!scrollEl) return; + + const maxScroll = scrollEl.scrollWidth - scrollEl.clientWidth; + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const behavior = reducedMotion ? 'auto' : 'smooth'; + + scrollEl.scrollTo({ + left: side === 'left' ? 0 : maxScroll, + behavior + }); +} + +function ensureCueButtons(hostEl) { + const scrollEl = hostEl.querySelector(WRAPPER_SELECTOR); + if (!scrollEl) return; + + let leftBtn = hostEl.querySelector('.' + BTN_LEFT_CLASS); + let rightBtn = hostEl.querySelector('.' + BTN_RIGHT_CLASS); + + if (!leftBtn) { + leftBtn = document.createElement('button'); + leftBtn.type = 'button'; + leftBtn.className = CUE_BTN_CLASS + ' ' + BTN_LEFT_CLASS; + leftBtn.setAttribute('aria-label', 'Scroll table left'); + leftBtn.addEventListener('click', function () { + scrollToSide(hostEl.querySelector(WRAPPER_SELECTOR), 'left'); + }); + hostEl.appendChild(leftBtn); + } + + if (!rightBtn) { + rightBtn = document.createElement('button'); + rightBtn.type = 'button'; + rightBtn.className = CUE_BTN_CLASS + ' ' + BTN_RIGHT_CLASS; + rightBtn.setAttribute('aria-label', 'Scroll table right'); + rightBtn.addEventListener('click', function () { + scrollToSide(hostEl.querySelector(WRAPPER_SELECTOR), 'right'); + }); + hostEl.appendChild(rightBtn); + } + + if (!scrollListeners.has(hostEl)) { + const onScroll = function () { + applyScrollCue(scrollEl, hostEl); + }; + scrollEl.addEventListener('scroll', onScroll); + scrollListeners.set(hostEl, { scroll: onScroll }); + } +} + +function pollScrollCue() { + const outers = document.querySelectorAll(OUTER_SELECTOR); + outers.forEach(function (hostEl) { + const scrollEl = hostEl.querySelector(WRAPPER_SELECTOR); + if (scrollEl) { + ensureCueButtons(hostEl); + applyScrollCue(scrollEl, hostEl); + } + }); +} + +function startPolling() { + if (pollId != null) return; + pollScrollCue(); + pollId = setInterval(pollScrollCue, POLL_MS); +} + +function stopPolling() { + if (pollId != null) { + clearInterval(pollId); + pollId = null; + } +} + +function setupListeners() { + if (resizeListener != null) return; + resizeListener = pollScrollCue; + window.addEventListener('resize', resizeListener); +} + +function removeHostListeners(hostEl) { + const scrollEl = hostEl.querySelector(WRAPPER_SELECTOR); + const entry = scrollListeners.get(hostEl); + if (entry) { + if (scrollEl) { + scrollEl.removeEventListener('scroll', entry.scroll); + } + scrollListeners.delete(hostEl); + } +} + +export function initTableScrollCue() { + disposeTableScrollCue(); + startPolling(); + setupListeners(); +} + +export function updateTableScrollCue() { + pollScrollCue(); +} + +export function clearTableSelection() { + try { + window.getSelection()?.removeAllRanges(); + } catch (_) { + } +} + +export function disposeTableScrollCue() { + stopPolling(); + + if (resizeListener) { + window.removeEventListener('resize', resizeListener); + resizeListener = null; + } + + document.querySelectorAll(OUTER_SELECTOR).forEach(function (hostEl) { + removeHostListeners(hostEl); + const left = hostEl.querySelector('.' + BTN_LEFT_CLASS); + const right = hostEl.querySelector('.' + BTN_RIGHT_CLASS); + if (left) left.remove(); + if (right) right.remove(); + }); +}