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();
|
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.";
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+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);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-6
@@ -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)
|
||||||
|
|||||||
+1
-1
@@ -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,
|
||||||
|
|||||||
+2
-1
@@ -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,
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|||||||
+2
-1
@@ -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);
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user