mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
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:
@@ -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.";
|
||||
|
||||
@@ -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,9 +33,35 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
var pageState = DhcpPageState.FromStatus(_status);
|
||||
|
||||
@if (!pageState.DhcpAvailable)
|
||||
{
|
||||
<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">
|
||||
@@ -238,13 +266,21 @@ else
|
||||
<section class="page-section">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<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">
|
||||
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (!pageState.LeasesAvailable)
|
||||
{
|
||||
<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>
|
||||
@@ -333,8 +369,10 @@ else
|
||||
<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>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
@@ -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<ApplicationOptions> AppOptions
|
||||
<PageTitle>Hosts | @AppTitle</PageTitle>
|
||||
|
||||
<div class="hosts-page">
|
||||
<h1 class="page-title">Hosts Configuration</h1>
|
||||
|
||||
@if (_status == null)
|
||||
@@ -20,12 +30,25 @@
|
||||
}
|
||||
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" />
|
||||
</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)
|
||||
{
|
||||
<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>
|
||||
}
|
||||
|
||||
<HostsFileSection Path="@(_status.ManagedHostsFilePath ?? "")" Entries="_entries" IsEditable="true" IsFirst="true" />
|
||||
<p class="text-muted small mt-2 mb-0">Save changes via the toolbar above.</p>
|
||||
<section class="page-section">
|
||||
<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">
|
||||
<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)
|
||||
{
|
||||
@foreach (var ro in _readOnlyHosts)
|
||||
<p class="text-muted">No hosts entries found.</p>
|
||||
}
|
||||
else if (VisibleGroups.Count == 0)
|
||||
{
|
||||
<HostsFileSection Path="@ro.Path" Entries="@ro.Entries" IsEditable="false" IsFirst="false" @key="@ro.Path" />
|
||||
<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 {
|
||||
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
||||
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 IReadOnlyList<ReadOnlyHostsFile>? _readOnlyHosts;
|
||||
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,
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Returns MarkupString so Blazor renders raw HTML (path + source label).</summary>
|
||||
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<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()
|
||||
{
|
||||
try
|
||||
@@ -130,10 +396,23 @@ else
|
||||
try
|
||||
{
|
||||
_status = await StatusClient.GetStatusAsync();
|
||||
var entries = await HostsClient.GetHostsAsync();
|
||||
_entries = entries?.ToList() ?? new List<HostEntry>();
|
||||
_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<HostsPageRow>();
|
||||
}
|
||||
|
||||
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<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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HostsController> _logger;
|
||||
|
||||
public HostsController(IHostsFileService hostsService, IHostsCache hostsCache, ILogger<HostsController> logger)
|
||||
public HostsController(
|
||||
IHostsFileService hostsService,
|
||||
IHostsCache hostsCache,
|
||||
IDnsmasqConfigSetService configSetService,
|
||||
ILogger<HostsController> logger)
|
||||
{
|
||||
_hostsService = hostsService;
|
||||
_hostsCache = hostsCache;
|
||||
_configSetService = configSetService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -52,4 +59,26 @@ public class HostsController : ControllerBase
|
||||
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>
|
||||
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);
|
||||
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;
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public interface IHostsCache : IApplicationSingleton
|
||||
@@ -13,4 +13,14 @@ public interface IHostsCache : IApplicationSingleton
|
||||
Task<HostsSnapshot> GetSnapshotAsync(CancellationToken ct = default);
|
||||
void Invalidate();
|
||||
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);
|
||||
}
|
||||
|
||||
+12
@@ -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);
|
||||
|
||||
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<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 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<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;
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
_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;
|
||||
}
|
||||
}
|
||||
+7
@@ -41,4 +41,11 @@ public interface IEffectiveConfigEditSession : IApplicationScopedService
|
||||
IReadOnlyList<FieldIssue> GetValidationSummary();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
+10
-5
@@ -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
|
||||
return await _saveService.SaveAsync(_pending.ToList(), ct);
|
||||
}
|
||||
|
||||
public void AcceptAppliedChanges(bool stayInEditMode)
|
||||
{
|
||||
_pending.Clear();
|
||||
_fieldIssues.Clear();
|
||||
_crossOptionIssues.Clear();
|
||||
ActiveFieldKey = null;
|
||||
IsEditMode = stayInEditMode;
|
||||
NotifyChanged();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? AsStringList(object? value)
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+2
-1
@@ -38,7 +38,8 @@ public static class EffectiveConfigViews
|
||||
DnsmasqConfKeys.NoHosts,
|
||||
DnsmasqConfKeys.AddnHosts,
|
||||
DnsmasqConfKeys.Hostsdir,
|
||||
DnsmasqConfKeys.ReadEthers,
|
||||
]),
|
||||
new(EffectiveConfigSections.SectionResolver, "Resolver / DNS", [
|
||||
DnsmasqConfKeys.ExpandHosts,
|
||||
]),
|
||||
];
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user