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.
This commit is contained in:
2026-03-26 00:24:21 +10:00
parent 333566554c
commit c7e7844ce7
24 changed files with 1541 additions and 79 deletions
@@ -84,6 +84,13 @@
var result = await Session.ApplyAsync(); var result = await Session.ApplyAsync();
_saveResult = result; _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) if (result.Saved && result.Restarted)
{ {
var message = result.UserMessage ?? "Config saved and dnsmasq restarted."; var message = result.UserMessage ?? "Config saved and dnsmasq restarted.";
@@ -118,6 +125,12 @@
var result = await SaveService.RestoreAsync(backups); var result = await SaveService.RestoreAsync(backups);
_restoreResult = result; _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) if (result.Restored && result.Restarted)
{ {
var message = result.UserMessage ?? "Backup restored and dnsmasq restarted."; var message = result.UserMessage ?? "Backup restored and dnsmasq restarted.";
+83 -45
View File
@@ -2,7 +2,9 @@
@page "/leases" @page "/leases"
@rendermode InteractiveServer @rendermode InteractiveServer
@namespace DnsmasqWebUI.Components.Pages @namespace DnsmasqWebUI.Components.Pages
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.PageStates
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions @using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata @using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
@using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions @using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions
@@ -31,12 +33,38 @@
} }
else else
{ {
<section class="page-section"> var pageState = DhcpPageState.FromStatus(_status);
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Dhcp" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
</section>
@* ---- Static hosts (dhcp-host= in managed file) ---- *@ @if (!pageState.DhcpAvailable)
<section class="page-section"> {
<div class="alert alert-warning">
<h4 class="alert-heading">DHCP not available</h4>
<p class="mb-0">@pageState.DhcpUnavailableReason</p>
</div>
<section class="page-section">
<h2 class="page-section-title">Advanced configuration</h2>
<p class="text-muted">DHCP-specific options are not available, but you can view the effective config below.</p>
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Dhcp" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
</section>
}
else
{
<section class="page-section">
<h2 class="page-section-title">Effective configuration</h2>
<p class="text-muted small">Use the editor below for full dnsmasq flexibility. The workflow UI appears underneath.</p>
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Dhcp" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
</section>
@if (pageState.ReadEthersEnabled)
{
<div class="alert alert-info">
<strong>read-ethers</strong> is enabled. Dnsmasq reads <code>/etc/ethers</code> and
treats its entries like <code>dhcp-host</code> mappings for DHCP clients.
</div>
}
@* ---- Static hosts (dhcp-host= in managed file) ---- *@
<section class="page-section">
<h2 class="page-section-title">Static hosts</h2> <h2 class="page-section-title">Static hosts</h2>
@if (!_status.ManagedFilePathExists) @if (!_status.ManagedFilePathExists)
{ {
@@ -234,42 +262,50 @@ else
} }
</section> </section>
@* ---- Leases (dhcp-leasefile, read-only) ---- *@ @* ---- Leases (dhcp-leasefile, read-only) ---- *@
<section class="page-section"> <section class="page-section">
<div class="d-flex align-items-center justify-content-between mb-2"> <div class="d-flex align-items-center justify-content-between mb-2">
<h2 class="page-section-title mb-0">Leases</h2> <h2 class="page-section-title mb-0">Leases</h2>
@if (_status.LeasesPathConfigured) @if (pageState.LeasesAvailable && _status.LeasesPathConfigured)
{ {
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Leases refresh interval settings" @onclick="OpenLeasesSettings" title="Configure leases refresh interval"> <button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Leases refresh interval settings" @onclick="OpenLeasesSettings" title="Configure leases refresh interval">
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i> <i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
</button> </button>
} }
</div>
@if (_status.LeasesPathConfigured && !string.IsNullOrEmpty(_status.LeasesPath))
{
<p class="text-muted small mb-2">Path: @_status.LeasesPath (read-only).</p>
}
@if (!_status.LeasesPathConfigured)
{
<p class="text-muted">Leases not configured (no dhcp-leasefile in config).</p>
}
else if (!_status.LeasesPathExists)
{
<p class="text-warning">Leases file not found: @_status.LeasesPath. DHCP may be disabled or path may be wrong.</p>
}
else if (_leases == null)
{
<p class="text-warning">Could not read leases file.</p>
}
else if (_leases.Count == 0)
{
<p class="text-muted">No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.</p>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
</div> </div>
} @if (!pageState.LeasesAvailable)
else {
{ <div class="alert alert-info">
<p class="mb-0">@pageState.LeasesUnavailableReason</p>
</div>
}
else
{
@if (_status.LeasesPathConfigured && !string.IsNullOrEmpty(_status.LeasesPath))
{
<p class="text-muted small mb-2">Path: @_status.LeasesPath (read-only).</p>
}
@if (!_status.LeasesPathConfigured)
{
<p class="text-muted">Leases not configured (no dhcp-leasefile in config).</p>
}
else if (!_status.LeasesPathExists)
{
<p class="text-warning">Leases file not found: @_status.LeasesPath. DHCP may be disabled or path may be wrong.</p>
}
else if (_leases == null)
{
<p class="text-warning">Could not read leases file.</p>
}
else if (_leases.Count == 0)
{
<p class="text-muted">No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.</p>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
</div>
}
else
{
<div class="row g-2 align-items-center mb-2"> <div class="row g-2 align-items-center mb-2">
<div class="col-auto"> <div class="col-auto">
<label for="lease-filter-keyword" class="form-label mb-0 small text-muted">Search leases</label> <label for="lease-filter-keyword" class="form-label mb-0 small text-muted">Search leases</label>
@@ -329,12 +365,14 @@ else
} }
</div> </div>
</div> </div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2"> <div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button> <button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
</div> </div>
} }
</section> }
</section>
}
} }
@code { @code {
+354 -21
View File
@@ -1,14 +1,24 @@
@page "/hosts" @page "/hosts"
@rendermode InteractiveServer @rendermode InteractiveServer
@namespace DnsmasqWebUI.Components.Pages @namespace DnsmasqWebUI.Components.Pages
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.PageStates
@using DnsmasqWebUI.Models.Hosts
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions @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 IStatusClient StatusClient
@inject IHostsClient HostsClient @inject IHostsClient HostsClient
@inject IEffectiveConfigEditSession Session @inject IEffectiveConfigEditSession Session
@inject IHostsPageProjectionService HostsProjection
@inject IJSRuntime JSRuntime
@inject IOptions<ApplicationOptions> AppOptions @inject IOptions<ApplicationOptions> AppOptions
<PageTitle>Hosts | @AppTitle</PageTitle> <PageTitle>Hosts | @AppTitle</PageTitle>
<div class="hosts-page">
<h1 class="page-title">Hosts Configuration</h1> <h1 class="page-title">Hosts Configuration</h1>
@if (_status == null) @if (_status == null)
@@ -20,12 +30,25 @@
} }
else else
{ {
<section class="page-section mb-4"> var pageState = HostsPageState.FromStatus(_status);
<section class="page-section">
<h2 class="page-section-title">Effective configuration</h2>
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Hosts" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" /> <EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Hosts" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
</section> </section>
@if (string.IsNullOrEmpty(_status.ManagedHostsFilePath))
@if (!pageState.SystemHostsActive && !string.IsNullOrEmpty(pageState.SystemHostsInactiveReason))
{ {
<p class="text-muted">Hosts editing is unavailable: managed hosts path is not configured. Set <strong>Dnsmasq:MainConfigPath</strong> (and optionally <strong>Dnsmasq:ManagedHostsFileName</strong>) so the app can create and edit the managed hosts file.</p> <div class="alert alert-info">
<strong>System hosts</strong> is inactive: @pageState.SystemHostsInactiveReason
</div>
}
@if (!pageState.ManagedHostsAvailable)
{
<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> <p class="text-muted small">Dnsmasq loads these addn-hosts files: @RenderAddnHostsList()</p>
@@ -42,30 +65,254 @@ else
<div class="alert alert-danger">@_error</div> <div class="alert alert-danger">@_error</div>
} }
<HostsFileSection Path="@(_status.ManagedHostsFilePath ?? "")" Entries="_entries" IsEditable="true" IsFirst="true" /> <section class="page-section">
<p class="text-muted small mt-2 mb-0">Save changes via the toolbar above.</p> <h2 class="page-section-title mb-3">Hosts entries</h2>
@if (_readOnlyHosts?.Count > 0) <div class="hosts-controls-bar d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
{ <div class="d-flex flex-wrap gap-2 align-items-center flex-grow-1">
@foreach (var ro in _readOnlyHosts) <input type="text"
class="form-control form-control-sm hosts-toolbar-search"
placeholder="Search address, names, path…"
@bind="_search"
@bind:event="oninput" />
</div>
</div>
@if (_unifiedRows == null || _unifiedRows.Count == 0)
{ {
<HostsFileSection Path="@ro.Path" Entries="@ro.Entries" IsEditable="false" IsFirst="false" @key="@ro.Path" /> <p class="text-muted">No hosts entries found.</p>
} }
} else if (VisibleGroups.Count == 0)
{
<p class="text-muted">No rows match the current search.</p>
}
else
{
<div class="app-table-scroll-shell" data-table-scroll-cue>
<div class="app-table-scroll-region" data-table-scroll-region role="region" aria-label="Hosts entries table" tabindex="0">
<table class="table table-bordered table-hover app-data-table hosts-table mb-0">
<caption class="visually-hidden">Hosts entries grouped for scanning and editing</caption>
<thead>
<tr>
<th scope="col">Address</th>
<th scope="col">Names</th>
<th scope="col">Effective names</th>
<th scope="col">Status</th>
</tr>
</thead>
@foreach (var group in VisibleGroups)
{
<tbody @key="@group.Key">
<tr class="hosts-group-header">
<th colspan="4" scope="colgroup" class="hosts-group-header-cell">
<div class="hosts-group-header__content">
<div class="hosts-group-header__title">
<span>@group.Title</span>
@if (group.GroupContainsEditableRows)
{
<span class="badge bg-primary">Editable</span>
}
@if (!group.IsActive)
{
<span class="badge bg-warning text-dark">Inactive</span>
}
@if (!string.IsNullOrWhiteSpace(group.Subtitle))
{
<span class="hosts-group-header__subtitle text-muted small">@group.Subtitle</span>
}
</div>
</div>
</th>
</tr>
@foreach (var row in group.Rows)
{
@if (IsEditingRow(row))
{
<tr class="hosts-row hosts-row--editing table-info" @key="@("edit:" + row.Id)">
<td>
<input class="form-control form-control-sm" @bind="_editAddress" />
</td>
<td>
<input class="form-control form-control-sm" placeholder="Comma-separated names" @bind="_editNames" />
</td>
<td class="text-muted small">@string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, pageState.ExpansionDomain))</td>
<td>
<div class="hosts-status-cell">
<span class="hosts-status-pill hosts-status-pill--editable">Editable</span>
@if (row.IsComment)
{
<span class="hosts-status-pill hosts-status-pill--muted">Commented</span>
}
<div class="hosts-status-actions">
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveEditAsync">Save</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CancelEdit">Cancel</button>
</div>
</div>
</td>
</tr>
}
else
{
<tr class="@RowClass(row)" @key="@row.Id">
<td><code>@row.Address</code></td>
<td>@string.Join(", ", row.Names)</td>
<td>
@if (row.EffectiveNames.Count != row.Names.Count)
{
<span class="text-primary">@string.Join(", ", row.EffectiveNames)</span>
}
else
{
@string.Join(", ", row.EffectiveNames)
}
</td>
<td>
<div class="hosts-status-cell">
@if (!row.IsActive)
{
<span class="hosts-status-pill hosts-status-pill--inactive" title="@row.InactiveReason">Inactive</span>
}
@if (row.IsComment)
{
<span class="hosts-status-pill hosts-status-pill--muted">Commented</span>
}
@if (row.IsEditable)
{
<span class="hosts-status-pill hosts-status-pill--editable">Editable</span>
}
else
{
<span class="hosts-status-pill hosts-status-pill--readonly">Read-only</span>
}
@if (row.IsEditable && string.IsNullOrEmpty(_editingRowId))
{
<button type="button" class="btn btn-sm btn-outline-primary hosts-inline-action" @onclick="() => BeginEdit(row)">Edit</button>
}
</div>
</td>
</tr>
}
}
</tbody>
}
</table>
</div>
</div>
}
@if (pageState.ExpandHostsEnabled && !string.IsNullOrEmpty(pageState.ExpansionDomain))
{
<div class="alert alert-info mt-3 mb-0">
<strong>expand-hosts</strong> is enabled with domain <code>@pageState.ExpansionDomain</code>.
Simple hostnames (without dots) will be expanded to include the domain suffix.
The <strong>Effective names</strong> column shows how dnsmasq will resolve each entry.
</div>
}
</section>
} }
} }
</div>
@code { @code {
private string AppTitle => AppOptions!.Value!.EffectiveTitle; private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private DnsmasqServiceStatus? _status; private DnsmasqServiceStatus? _status;
private List<HostEntry> _entries = new(); private IReadOnlyList<HostsPageRow> _unifiedRows = Array.Empty<HostsPageRow>();
private List<HostEntry> _managedEntries = new();
private List<HostEntry> _originalManagedEntries = new(); private List<HostEntry> _originalManagedEntries = new();
private IReadOnlyList<ReadOnlyHostsFile>? _readOnlyHosts;
private string? _error; private string? _error;
private static List<HostEntry> CloneEntries(IReadOnlyList<HostEntry> entries) private string _search = "";
private string? _editingRowId;
private string _editAddress = "";
private string _editNames = "";
private IJSObjectReference? _hostsTableScrollCueModule;
private IReadOnlyList<HostsPageGroup> 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<string> { "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<string> ParseNamesList(string raw) =>
raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(s => !string.IsNullOrEmpty(s))
.ToList();
private static List<HostEntry> CloneEntries(IReadOnlyList<HostEntry> entries) =>
entries.Select(e => new HostEntry
{ {
LineNumber = e.LineNumber, LineNumber = e.LineNumber,
Id = e.Id, Id = e.Id,
@@ -75,13 +322,12 @@ else
IsComment = e.IsComment, IsComment = e.IsComment,
IsPassthrough = e.IsPassthrough IsPassthrough = e.IsPassthrough
}).ToList(); }).ToList();
}
private void SyncManagedHostsDraft() private void SyncManagedHostsDraft()
{ {
if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath)) if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath))
return; return;
var draft = CloneEntries(_entries); var draft = CloneEntries(_managedEntries);
var original = CloneEntries(_originalManagedEntries); var original = CloneEntries(_originalManagedEntries);
var pending = new PendingManagedHostsChange(original, draft, _status.ManagedHostsFilePath); var pending = new PendingManagedHostsChange(original, draft, _status.ManagedHostsFilePath);
if (pending.HasChanges) if (pending.HasChanges)
@@ -90,7 +336,6 @@ else
Session.RevertManagedHostsChange(); Session.RevertManagedHostsChange();
} }
/// <summary>Returns MarkupString so Blazor renders raw HTML (path + source label).</summary>
private MarkupString RenderAddnHostsList() private MarkupString RenderAddnHostsList()
{ {
if (_status?.AddnHostsPaths == null || _status.AddnHostsPaths.Count == 0) if (_status?.AddnHostsPaths == null || _status.AddnHostsPaths.Count == 0)
@@ -114,6 +359,27 @@ else
await Load(); await Load();
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_hostsTableScrollCueModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
"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() private async Task RefreshStatusAsync()
{ {
try try
@@ -130,10 +396,23 @@ else
try try
{ {
_status = await StatusClient.GetStatusAsync(); _status = await StatusClient.GetStatusAsync();
var entries = await HostsClient.GetHostsAsync(); _managedEntries = (await HostsClient.GetHostsAsync()).ToList();
_entries = entries?.ToList() ?? new List<HostEntry>(); _originalManagedEntries = CloneEntries(_managedEntries);
_originalManagedEntries = CloneEntries(_entries);
_readOnlyHosts = await HostsClient.GetReadOnlyHostsAsync(); 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<HostsPageRow>();
}
SyncManagedHostsDraft(); SyncManagedHostsDraft();
} }
catch (Exception ex) 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<HostsPageRow>();
foreach (var e in _managedEntries.Where(x => !x.IsPassthrough))
{
var names = e.Names ?? (IReadOnlyList<string>)Array.Empty<string>();
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;
}
} }
@@ -28,6 +28,10 @@
vertical-align: middle; vertical-align: middle;
} }
.hosts-page {
display: block;
}
@media (max-width: 640.98px) { @media (max-width: 640.98px) {
::deep .table { ::deep .table {
font-size: 0.875rem; font-size: 0.875rem;
@@ -38,3 +42,190 @@
padding: 0.5rem; 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;
}
}
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
using DnsmasqWebUI.Models.Hosts; using DnsmasqWebUI.Models.Hosts;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -11,12 +12,18 @@ public class HostsController : ControllerBase
{ {
private readonly IHostsFileService _hostsService; private readonly IHostsFileService _hostsService;
private readonly IHostsCache _hostsCache; private readonly IHostsCache _hostsCache;
private readonly IDnsmasqConfigSetService _configSetService;
private readonly ILogger<HostsController> _logger; private readonly ILogger<HostsController> _logger;
public HostsController(IHostsFileService hostsService, IHostsCache hostsCache, ILogger<HostsController> logger) public HostsController(
IHostsFileService hostsService,
IHostsCache hostsCache,
IDnsmasqConfigSetService configSetService,
ILogger<HostsController> logger)
{ {
_hostsService = hostsService; _hostsService = hostsService;
_hostsCache = hostsCache; _hostsCache = hostsCache;
_configSetService = configSetService;
_logger = logger; _logger = logger;
} }
@@ -52,4 +59,26 @@ public class HostsController : ControllerBase
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
} }
} }
/// <summary>Returns unified rows for the Hosts page, preserving source awareness.</summary>
[HttpGet("unified")]
public async Task<ActionResult<IReadOnlyList<HostsPageRow>>> 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 });
}
}
} }
@@ -12,4 +12,12 @@ public interface IHostsClient
/// <summary>Gets read-only hosts files (system + addn-hosts) from GET api/hosts/readonly.</summary> /// <summary>Gets read-only hosts files (system + addn-hosts) from GET api/hosts/readonly.</summary>
Task<IReadOnlyList<ReadOnlyHostsFile>> GetReadOnlyHostsAsync(CancellationToken ct = default); Task<IReadOnlyList<ReadOnlyHostsFile>> GetReadOnlyHostsAsync(CancellationToken ct = default);
/// <summary>Gets unified hosts rows (managed + read-only) from GET api/hosts/unified.</summary>
Task<IReadOnlyList<HostsPageRow>> GetUnifiedRowsAsync(
bool expandHosts,
string? domain,
bool noHosts,
string? managedHostsPath,
CancellationToken ct = default);
} }
@@ -22,4 +22,20 @@ public sealed class HostsClient : IHostsClient
var list = await _http.GetFromJsonAsync<List<ReadOnlyHostsFile>>("api/hosts/readonly", ApiJsonOptions.ClientOptions, ct); var list = await _http.GetFromJsonAsync<List<ReadOnlyHostsFile>>("api/hosts/readonly", ApiJsonOptions.ClientOptions, ct);
return list ?? new List<ReadOnlyHostsFile>(); return list ?? new List<ReadOnlyHostsFile>();
} }
public async Task<IReadOnlyList<HostsPageRow>> 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<List<HostsPageRow>>($"api/hosts/unified{queryParams}", ApiJsonOptions.ClientOptions, ct);
return list ?? new List<HostsPageRow>();
}
} }
@@ -5,7 +5,7 @@ using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions; namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions;
/// <summary> /// <summary>
/// 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 <see cref="NotifyWeWroteManagedHosts"/> after the app writes the managed hosts file so the cache updates in place and ignores the next watcher event. /// Call <see cref="NotifyWeWroteManagedHosts"/> after the app writes the managed hosts file so the cache updates in place and ignores the next watcher event.
/// </summary> /// </summary>
public interface IHostsCache : IApplicationSingleton public interface IHostsCache : IApplicationSingleton
@@ -13,4 +13,14 @@ public interface IHostsCache : IApplicationSingleton
Task<HostsSnapshot> GetSnapshotAsync(CancellationToken ct = default); Task<HostsSnapshot> GetSnapshotAsync(CancellationToken ct = default);
void Invalidate(); void Invalidate();
void NotifyWeWroteManagedHosts(IReadOnlyList<HostEntry> entries); void NotifyWeWroteManagedHosts(IReadOnlyList<HostEntry> entries);
/// <summary>
/// Builds unified page rows from the snapshot, preserving source awareness.
/// </summary>
Task<IReadOnlyList<HostsPageRow>> GetUnifiedRowsAsync(
bool expandHosts,
string? domain,
bool noHosts,
string? managedHostsPath,
CancellationToken ct = default);
} }
@@ -0,0 +1,12 @@
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions;
/// <summary>
/// Projects flat <see cref="HostsPageRow"/> into grouped, filtered, sorted Hosts page groups.
/// </summary>
public interface IHostsPageProjectionService : IApplicationSingleton
{
IReadOnlyList<HostsPageGroup> BuildGroups(IReadOnlyList<HostsPageRow> rows, HostsPageQueryState query);
}
@@ -80,7 +80,8 @@ public sealed class HostsCache : IHostsCache, IDisposable
TryEnsureWatchers(managedPath); TryEnsureWatchers(managedPath);
var managedEntries = ReadManagedEntries(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; _lastManagedHostsPath = !string.IsNullOrEmpty(managedPath) ? Path.GetFullPath(managedPath) : null;
_snapshot = new HostsSnapshot(managedEntries, readOnlyFiles); _snapshot = new HostsSnapshot(managedEntries, readOnlyFiles);
@@ -185,13 +186,14 @@ public sealed class HostsCache : IHostsCache, IDisposable
return entries; return entries;
} }
private List<ReadOnlyHostsFile> ReadReadOnlyFiles(string? managedPath, IReadOnlyList<string> addnPaths, bool noHosts, CancellationToken ct) private List<ReadOnlyHostsFile> ReadReadOnlyFiles(string? managedPath, IReadOnlyList<string> addnPaths, bool noHosts, string? hostsdirPath, CancellationToken ct)
{ {
var result = new List<ReadOnlyHostsFile>(); var result = new List<ReadOnlyHostsFile>();
var managedPathFull = !string.IsNullOrEmpty(managedPath) ? Path.GetFullPath(managedPath) : null; var managedPathFull = !string.IsNullOrEmpty(managedPath) ? Path.GetFullPath(managedPath) : null;
var systemPath = _options.SystemHostsPath?.Trim(); var systemPath = _options.SystemHostsPath?.Trim();
var systemPathFull = !string.IsNullOrEmpty(systemPath) ? Path.GetFullPath(systemPath) : null; 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)) if (!noHosts && !string.IsNullOrEmpty(systemPathFull) && File.Exists(systemPathFull))
{ {
try try
@@ -213,6 +215,7 @@ public sealed class HostsCache : IHostsCache, IDisposable
} }
} }
// Addn-hosts files
foreach (var p in addnPaths) foreach (var p in addnPaths)
{ {
ct.ThrowIfCancellationRequested(); 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<HostEntry>();
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; return result;
} }
public async Task<IReadOnlyList<HostsPageRow>> GetUnifiedRowsAsync(
bool expandHosts,
string? domain,
bool noHosts,
string? managedHostsPath,
CancellationToken ct = default)
{
var snapshot = await GetSnapshotAsync(ct);
var rows = new List<HostsPageRow>();
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<string>)Array.Empty<string>();
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<string>)Array.Empty<string>();
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;
}
/// <summary>
/// True if <paramref name="fileFullPath"/> is the directory itself or a path inside it.
/// Uses a directory separator after the parent prefix so a hostsdir of <c>/etc/dnsmasq/hosts</c>
/// does not match <c>/etc/dnsmasq/hosts-extra/file</c> (plain <see cref="string.StartsWith(string)"/> would).
/// </summary>
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() public void Dispose()
{ {
_watcherManaged?.Dispose(); _watcherManaged?.Dispose();
@@ -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<HostsPageGroup> BuildGroups(IReadOnlyList<HostsPageRow> rows, HostsPageQueryState query)
{
if (rows.Count == 0)
return Array.Empty<HostsPageGroup>();
IEnumerable<HostsPageRow> 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<HostsPageGroup>();
return query.Grouping == HostsGroupingMode.Activity
? BuildActivityGroups(list, query)
: BuildSourceGroups(list, query);
}
private static IReadOnlyList<HostsPageGroup> BuildSourceGroups(List<HostsPageRow> 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<HostsPageGroup>();
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<HostsPageGroup> BuildActivityGroups(List<HostsPageRow> rows, HostsPageQueryState query)
{
var orderedGroups = rows
.GroupBy(r => r.IsActive)
.OrderByDescending(g => g.Key);
var result = new List<HostsPageGroup>();
foreach (var g in orderedGroups)
{
var sortedRows = SortRows(g, query.Sort, query.Descending).ToList();
if (sortedRows.Count == 0)
continue;
var active = g.Key;
var title = active ? "Active" : "Inactive";
var subtitle = active
? "Entries from sources dnsmasq loads for hosts."
: "Entries from sources that are ignored or unavailable (e.g. system hosts under no-hosts).";
result.Add(new HostsPageGroup(
Key: active ? "activity:active" : "activity:inactive",
Title: title,
Subtitle: subtitle,
SourceKind: null,
GroupContainsEditableRows: sortedRows.Any(r => r.IsEditable),
IsActive: active,
InactiveReason: null,
VisibleRowCount: sortedRows.Count,
Rows: sortedRows));
}
return result;
}
private static IEnumerable<HostsPageRow> SortRows(IEnumerable<HostsPageRow> rows, HostsSortMode sort, bool descending)
{
IEnumerable<HostsPageRow> ordered = sort switch
{
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;
}
}
@@ -41,4 +41,11 @@ public interface IEffectiveConfigEditSession : IApplicationScopedService
IReadOnlyList<FieldIssue> GetValidationSummary(); IReadOnlyList<FieldIssue> GetValidationSummary();
Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default); Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default);
/// <summary>
/// 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.
/// </summary>
/// <param name="stayInEditMode">If true, keep edit mode active; if false, exit edit mode.</param>
void AcceptAppliedChanges(bool stayInEditMode);
} }
@@ -143,12 +143,17 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
if (_pending.Count == 0) if (_pending.Count == 0)
return EffectiveConfigSaveResult.NoChanges(); return EffectiveConfigSaveResult.NoChanges();
var result = await _saveService.SaveAsync(_pending.ToList(), ct); return await _saveService.SaveAsync(_pending.ToList(), ct);
if (result.Saved && result.Restarted) }
ExitEditModeDiscard();
else public void AcceptAppliedChanges(bool stayInEditMode)
NotifyChanged(); {
return result; _pending.Clear();
_fieldIssues.Clear();
_crossOptionIssues.Clear();
ActiveFieldKey = null;
IsEditMode = stayInEditMode;
NotifyChanged();
} }
private static IReadOnlyList<string>? AsStringList(object? value) private static IReadOnlyList<string>? AsStringList(object? value)
@@ -29,7 +29,6 @@ public static class EffectiveConfigSections
DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.NoHosts,
DnsmasqConfKeys.AddnHosts, DnsmasqConfKeys.AddnHosts,
DnsmasqConfKeys.Hostsdir, DnsmasqConfKeys.Hostsdir,
DnsmasqConfKeys.ReadEthers,
]), ]),
new SectionDef(SectionResolver, "Resolver / DNS", [ new SectionDef(SectionResolver, "Resolver / DNS", [
DnsmasqConfKeys.Server, DnsmasqConfKeys.Server,
@@ -101,6 +100,7 @@ public static class EffectiveConfigSections
DnsmasqConfKeys.LeasefileRo, DnsmasqConfKeys.LeasefileRo,
DnsmasqConfKeys.Leasequery, DnsmasqConfKeys.Leasequery,
DnsmasqConfKeys.DhcpGenerateNames, DnsmasqConfKeys.DhcpGenerateNames,
DnsmasqConfKeys.ReadEthers,
DnsmasqConfKeys.DhcpBroadcast, DnsmasqConfKeys.DhcpBroadcast,
DnsmasqConfKeys.DhcpSequentialIp, DnsmasqConfKeys.DhcpSequentialIp,
DnsmasqConfKeys.DhcpIgnoreClid, DnsmasqConfKeys.DhcpIgnoreClid,
@@ -38,7 +38,8 @@ public static class EffectiveConfigViews
DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.NoHosts,
DnsmasqConfKeys.AddnHosts, DnsmasqConfKeys.AddnHosts,
DnsmasqConfKeys.Hostsdir, DnsmasqConfKeys.Hostsdir,
DnsmasqConfKeys.ReadEthers, ]),
new(EffectiveConfigSections.SectionResolver, "Resolver / DNS", [
DnsmasqConfKeys.ExpandHosts, DnsmasqConfKeys.ExpandHosts,
]), ]),
]; ];
@@ -71,7 +71,7 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
private void RegisterFlagDisplays() private void RegisterFlagDisplays()
{ {
RegisterFlags(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.ReadEthers); RegisterFlags(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.NoHosts);
RegisterFlags( RegisterFlags(
EffectiveConfigSections.SectionResolver, EffectiveConfigSections.SectionResolver,
DnsmasqConfKeys.ExpandHosts, DnsmasqConfKeys.ExpandHosts,
@@ -92,6 +92,7 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
RegisterFlags( RegisterFlags(
EffectiveConfigSections.SectionDhcp, EffectiveConfigSections.SectionDhcp,
DnsmasqConfKeys.DhcpAuthoritative, DnsmasqConfKeys.DhcpAuthoritative,
DnsmasqConfKeys.ReadEthers,
DnsmasqConfKeys.DhcpRapidCommit, DnsmasqConfKeys.DhcpRapidCommit,
DnsmasqConfKeys.LeasefileRo, DnsmasqConfKeys.LeasefileRo,
DnsmasqConfKeys.DhcpSequentialIp, DnsmasqConfKeys.DhcpSequentialIp,
@@ -0,0 +1,53 @@
using DnsmasqWebUI.Models.Dnsmasq;
namespace DnsmasqWebUI.Models.Dnsmasq.PageStates;
/// <summary>
/// Page state for the DHCP specialized page. Derived from DnsmasqServiceStatus to keep Razor markup clean.
/// </summary>
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);
}
}
@@ -0,0 +1,51 @@
using DnsmasqWebUI.Models.Dnsmasq;
namespace DnsmasqWebUI.Models.Dnsmasq.PageStates;
/// <summary>
/// Page state for the Hosts specialized page. Derived from DnsmasqServiceStatus to keep Razor markup clean.
/// </summary>
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);
}
}
@@ -0,0 +1,22 @@
namespace DnsmasqWebUI.Models.Hosts;
/// <summary>Preview of names after <c>expand-hosts</c> (does not mutate stored names).</summary>
public static class HostsEffectiveNames
{
public static IReadOnlyList<string> Expand(
IReadOnlyList<string> 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();
}
}
@@ -0,0 +1,15 @@
namespace DnsmasqWebUI.Models.Hosts;
/// <summary>
/// A logical group of <see cref="HostsPageRow"/> for the Hosts page (by source file or by active/inactive).
/// </summary>
public sealed record HostsPageGroup(
string Key,
string Title,
string? Subtitle,
HostsRowSourceKind? SourceKind,
bool GroupContainsEditableRows,
bool IsActive,
string? InactiveReason,
int VisibleRowCount,
IReadOnlyList<HostsPageRow> Rows);
@@ -0,0 +1,32 @@
namespace DnsmasqWebUI.Models.Hosts;
/// <summary>
/// Source kind for a hosts row. Used to distinguish editable managed rows from read-only sources.
/// </summary>
public enum HostsRowSourceKind
{
/// <summary>Managed hosts file (editable by the app).</summary>
Managed,
/// <summary>System hosts file (e.g. /etc/hosts).</summary>
SystemHosts,
/// <summary>Additional hosts file (addn-hosts=).</summary>
AddnHosts,
/// <summary>Hosts directory (hostsdir=).</summary>
Hostsdir
}
/// <summary>
/// Unified row model for the Hosts page. Preserves source awareness while allowing a single rich table.
/// </summary>
public sealed record HostsPageRow(
string Id,
HostsRowSourceKind SourceKind,
string SourcePath,
bool IsEditable,
bool IsActive,
string? InactiveReason,
string Address,
IReadOnlyList<string> Names,
IReadOnlyList<string> EffectiveNames,
bool IsComment,
int LineNumber);
@@ -0,0 +1,27 @@
namespace DnsmasqWebUI.Models.Hosts;
/// <summary>How rows are grouped on the Hosts page.</summary>
public enum HostsGroupingMode
{
Source,
Activity
}
/// <summary>Sort key for rows within each group.</summary>
public enum HostsSortMode
{
Address,
Name,
EffectiveName,
LineNumber
}
/// <summary>Filter and sort state for <see cref="IHostsPageProjectionService"/>.</summary>
public sealed record HostsPageQueryState(
string Search,
HostsGroupingMode Grouping,
HostsSortMode Sort,
bool Descending,
HostsRowSourceKind? SourceKindFilter,
bool? EditableFilter,
bool? ActiveFilter);
+133
View File
@@ -258,6 +258,139 @@ h1:focus {
align-items: center; 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) */ /* Settings gear icon size (Bootstrap Icons) */
.bi-gear-sm { .bi-gear-sm {
font-size: 1.125rem; font-size: 1.125rem;
@@ -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();
});
}