Hosts page: grouped file filter with counts and source-path projection

Add SourcePathFilter to HostsPageQueryState and apply it in HostsPageProjectionService.
Replace the native select with a grouped menu (managed/system/additional/hostsdir),
per-file non-comment counts, toolbar layout, and full-path labels with end-anchored
ellipsis plus title tooltips. Include HostsSourcePathDisplay helpers, menu models,
and tests for grouping and projection filtering.
This commit is contained in:
2026-04-07 16:00:25 +10:00
parent 2e713f0e3c
commit 02a840799b
8 changed files with 672 additions and 46 deletions
@@ -0,0 +1,55 @@
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Tests.Models.Hosts;
public sealed class HostsSourcePathDisplayTests
{
private static HostsPageRow Row(HostsRowSourceKind kind, string path, bool isComment = false) =>
new(
Id: "id",
SourceKind: kind,
SourcePath: path,
IsEditable: false,
IsActive: true,
InactiveReason: null,
Address: "127.0.0.1",
Names: [],
EffectiveNames: [],
IsComment: isComment,
LineNumber: 1);
[Fact]
public void BuildFileFilterMenuGroups_InjectsManagedPath_WhenNotInRows()
{
var managed = "/data/managed.hosts";
var groups = HostsSourcePathDisplay.BuildFileFilterMenuGroups([], managed);
Assert.Single(groups);
Assert.Equal(HostsRowSourceKind.Managed, groups[0].SourceKind);
Assert.Single(groups[0].Files);
Assert.Equal(managed, groups[0].Files[0].Path);
Assert.Equal(0, groups[0].Files[0].RecordCount);
}
[Fact]
public void BuildFileFilterMenuGroups_OrdersGroupsManagedSystemAddn_AndCountsNonComments()
{
var rows = new[]
{
Row(HostsRowSourceKind.SystemHosts, "/etc/hosts"),
Row(HostsRowSourceKind.Managed, "/m/a.hosts"),
Row(HostsRowSourceKind.Managed, "/m/a.hosts", isComment: true),
Row(HostsRowSourceKind.AddnHosts, "/extra/b.hosts"),
};
var groups = HostsSourcePathDisplay.BuildFileFilterMenuGroups(rows, managedHostsPathAlwaysShow: null);
Assert.Equal(3, groups.Count);
Assert.Equal(HostsRowSourceKind.Managed, groups[0].SourceKind);
Assert.Equal(HostsRowSourceKind.SystemHosts, groups[1].SourceKind);
Assert.Equal(HostsRowSourceKind.AddnHosts, groups[2].SourceKind);
Assert.Single(groups[0].Files);
Assert.Equal(1, groups[0].Files[0].RecordCount);
}
}
@@ -0,0 +1,80 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts;
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Tests.Services.Hosts;
public class HostsPageProjectionServiceTests
{
private readonly HostsPageProjectionService _sut = new();
[Fact]
public void BuildGroups_SourcePathFilter_KeepsOnlyThatFile()
{
var rows = new HostsPageRow[]
{
new(
"m1",
HostsRowSourceKind.Managed,
"/data/managed.hosts",
IsEditable: true,
IsActive: true,
InactiveReason: null,
Address: "10.0.0.1",
Names: new[] { "a" },
EffectiveNames: new[] { "a" },
IsComment: false,
LineNumber: 1),
new(
"x1",
HostsRowSourceKind.AddnHosts,
"/etc/extra.hosts",
IsEditable: false,
IsActive: true,
InactiveReason: null,
Address: "10.0.0.2",
Names: new[] { "b" },
EffectiveNames: new[] { "b" },
IsComment: false,
LineNumber: 1),
};
var query = new HostsPageQueryState(
Search: "",
Grouping: HostsGroupingMode.Source,
Sort: HostsSortMode.LineNumber,
Descending: false,
SourceKindFilter: null,
SourcePathFilter: "/etc/extra.hosts",
EditableFilter: null,
ActiveFilter: null);
var groups = _sut.BuildGroups(rows, query);
Assert.Single(groups);
Assert.Equal("/etc/extra.hosts", groups[0].Subtitle);
Assert.Single(groups[0].Rows);
}
[Fact]
public void BuildGroups_SourcePathFilter_EmptyMeansAllFiles()
{
var rows = new HostsPageRow[]
{
new("m1", HostsRowSourceKind.Managed, "/a", true, true, null, "1.1.1.1", new[] { "a" }, new[] { "a" }, false, 1),
new("x1", HostsRowSourceKind.AddnHosts, "/b", false, true, null, "2.2.2.2", new[] { "b" }, new[] { "b" }, false, 1),
};
var query = new HostsPageQueryState(
"",
HostsGroupingMode.Source,
HostsSortMode.LineNumber,
false,
null,
SourcePathFilter: null,
null,
null);
var groups = _sut.BuildGroups(rows, query);
Assert.Equal(2, groups.Count);
}
}
+176 -5
View File
@@ -69,19 +69,79 @@ else
<section class="page-section"> <section class="page-section">
<h2 class="page-section-title mb-3">Hosts entries</h2> <h2 class="page-section-title mb-3">Hosts entries</h2>
<div class="hosts-controls-bar d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2"> <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"> @onkeydown="OnHostsToolbarKeyDown">
<div class="hosts-toolbar-fields flex-grow-1">
<input type="text" <input type="text"
class="form-control form-control-sm hosts-toolbar-search" class="form-control form-control-sm hosts-toolbar-search"
placeholder="Search address, names, path…" placeholder="Search address, names, path…"
@bind="_search" @bind="_search"
@bind:event="oninput" /> @bind:event="oninput" />
<label class="visually-hidden" for="hosts-file-filter-trigger">Hosts file</label>
<div class="hosts-file-filter-wrap @(_fileFilterMenuOpen ? "hosts-file-filter-wrap--open" : null)">
@if (_fileFilterMenuOpen)
{
<div class="hosts-file-filter-backdrop" @onclick="CloseFileFilterMenu" aria-hidden="true"></div>
}
<button type="button"
id="hosts-file-filter-trigger"
class="hosts-file-filter-toggle hosts-toolbar-file-filter"
title="Show entries from all loaded hosts files, or only one file"
aria-label="Filter by hosts file"
aria-haspopup="listbox"
aria-expanded="@(_fileFilterMenuOpen ? "true" : "false")"
aria-controls="hosts-file-filter-menu"
@onclick="ToggleFileFilterMenu"
@onclick:stopPropagation="true">
<span class="hosts-file-filter-toggle-label text-truncate">@FileFilterSummaryText</span>
<i class="bi bi-chevron-down hosts-file-filter-chevron flex-shrink-0" aria-hidden="true"></i>
</button>
@if (_fileFilterMenuOpen)
{
<div id="hosts-file-filter-menu"
class="hosts-file-filter-panel shadow"
role="listbox"
aria-label="Hosts files"
@onclick:stopPropagation="true">
<button type="button"
class="hosts-file-filter-option hosts-file-filter-option--all @(string.IsNullOrWhiteSpace(_sourceFileFilter) ? "hosts-file-filter-option--active" : null)"
role="option"
aria-selected="@(string.IsNullOrWhiteSpace(_sourceFileFilter) ? "true" : "false")"
@onclick="@(() => SelectSourceFileFilter(""))">
<span class="hosts-file-filter-option-main">All files</span>
<span class="hosts-file-filter-count">@HostsTotalDataRowCount</span>
</button>
@foreach (var group in FileFilterMenuGroups)
{
<div class="hosts-file-filter-group hosts-file-filter-group--@FileFilterGroupKindClass(group.SourceKind)">
<div class="hosts-file-filter-group-header" role="presentation">
<span class="hosts-file-filter-group-title">@group.Title</span>
</div>
@foreach (var file in group.Files)
{
var path = file.Path;
var active = IsSourceFilePathSelected(path);
<button type="button"
class="hosts-file-filter-option @(active ? "hosts-file-filter-option--active" : null)"
role="option"
aria-selected="@(active ? "true" : "false")"
title="@path"
@onclick="@(() => SelectSourceFileFilter(path))">
<span class="hosts-file-filter-option-main hosts-file-filter-path" title="@path">@path</span>
<span class="hosts-file-filter-count">@file.RecordCount</span>
</button>
}
</div>
}
</div>
}
</div>
</div> </div>
</div> </div>
@if (VisibleGroups.Count == 0) @if (VisibleGroups.Count == 0)
{ {
<p class="text-muted">No hosts entries found.</p> <p class="text-muted">@(string.IsNullOrWhiteSpace(_sourceFileFilter) ? "No hosts entries found." : "No hosts entries for the selected file.")</p>
} }
else else
{ {
@@ -89,6 +149,10 @@ else
{ {
<p class="text-muted">No rows match the current search.</p> <p class="text-muted">No rows match the current search.</p>
} }
else if (TotalVisibleDataRows == 0 && !string.IsNullOrWhiteSpace(_sourceFileFilter))
{
<p class="text-muted">No rows match the current filters.</p>
}
<div class="app-table-scroll-shell" data-table-scroll-cue> <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" <div class="app-table-scroll-region" data-table-scroll-region role="region" aria-label="Hosts entries table" tabindex="0"
@onkeydown="OnHostsTableRegionKeyDown"> @onkeydown="OnHostsTableRegionKeyDown">
@@ -128,7 +192,7 @@ else
<span class="badge bg-warning text-dark">Inactive</span> <span class="badge bg-warning text-dark">Inactive</span>
} }
</div> </div>
<div class="hosts-group-header__path-row"> <div class="hosts-group-header__path-row @(IsManagedEditableGroup(group) ? "hosts-group-header__path-row--with-managed-actions" : null)">
<div class="hosts-group-header__path"> <div class="hosts-group-header__path">
@if (!string.IsNullOrWhiteSpace(group.Subtitle)) @if (!string.IsNullOrWhiteSpace(group.Subtitle))
{ {
@@ -406,6 +470,8 @@ else
private string? _error; private string? _error;
private string _search = ""; private string _search = "";
/// <summary>Empty = all hosts files; otherwise matches <see cref="HostsPageRow.SourcePath"/>.</summary>
private string _sourceFileFilter = "";
private readonly HashSet<string> _selectedManagedRowIds = new(StringComparer.Ordinal); private readonly HashSet<string> _selectedManagedRowIds = new(StringComparer.Ordinal);
@@ -431,6 +497,7 @@ else
private object? _visibleGroupsCacheRowsRef; private object? _visibleGroupsCacheRowsRef;
private string _visibleGroupsCacheSearch = "\0"; private string _visibleGroupsCacheSearch = "\0";
private string _visibleGroupsCacheManagedPath = "\0"; private string _visibleGroupsCacheManagedPath = "\0";
private string _visibleGroupsCacheSourcePathFilter = "\0";
private IReadOnlyList<HostsPageGroup> VisibleGroups private IReadOnlyList<HostsPageGroup> VisibleGroups
{ {
@@ -440,7 +507,8 @@ else
if (_visibleGroupsCache != null if (_visibleGroupsCache != null
&& ReferenceEquals(_visibleGroupsCacheRowsRef, _unifiedRows) && ReferenceEquals(_visibleGroupsCacheRowsRef, _unifiedRows)
&& _visibleGroupsCacheSearch == _search && _visibleGroupsCacheSearch == _search
&& _visibleGroupsCacheManagedPath == managedPath) && _visibleGroupsCacheManagedPath == managedPath
&& _visibleGroupsCacheSourcePathFilter == _sourceFileFilter)
{ {
return _visibleGroupsCache; return _visibleGroupsCache;
} }
@@ -450,6 +518,7 @@ else
_visibleGroupsCacheRowsRef = _unifiedRows; _visibleGroupsCacheRowsRef = _unifiedRows;
_visibleGroupsCacheSearch = _search; _visibleGroupsCacheSearch = _search;
_visibleGroupsCacheManagedPath = managedPath; _visibleGroupsCacheManagedPath = managedPath;
_visibleGroupsCacheSourcePathFilter = _sourceFileFilter;
return _visibleGroupsCache; return _visibleGroupsCache;
} }
} }
@@ -512,6 +581,10 @@ else
if (groups.Any(g => g.SourceKind == HostsRowSourceKind.Managed)) if (groups.Any(g => g.SourceKind == HostsRowSourceKind.Managed))
return groups; return groups;
if (!string.IsNullOrWhiteSpace(_sourceFileFilter)
&& !string.Equals(_sourceFileFilter.Trim(), path, StringComparison.OrdinalIgnoreCase))
return groups;
var placeholder = new HostsPageGroup( var placeholder = new HostsPageGroup(
Key: $"{HostsRowSourceKind.Managed}:{path}", Key: $"{HostsRowSourceKind.Managed}:{path}",
Title: "Managed hosts", Title: "Managed hosts",
@@ -535,10 +608,99 @@ else
Sort: HostsSortMode.LineNumber, Sort: HostsSortMode.LineNumber,
Descending: false, Descending: false,
SourceKindFilter: null, SourceKindFilter: null,
SourcePathFilter: string.IsNullOrWhiteSpace(_sourceFileFilter) ? null : _sourceFileFilter.Trim(),
EditableFilter: null, EditableFilter: null,
ActiveFilter: null); ActiveFilter: null);
} }
private bool _fileFilterMenuOpen;
private IReadOnlyList<HostsFileFilterMenuGroup> FileFilterMenuGroups =>
HostsSourcePathDisplay.BuildFileFilterMenuGroups(_unifiedRows, _status?.ManagedHostsFilePath);
private int HostsTotalDataRowCount => _unifiedRows.Count(r => !r.IsComment);
private string FileFilterSummaryText
{
get
{
if (string.IsNullOrWhiteSpace(_sourceFileFilter))
{
var n = HostsTotalDataRowCount;
return n == 0 ? "All files" : $"All files · {n} records";
}
var trimmed = _sourceFileFilter.Trim();
foreach (var g in FileFilterMenuGroups)
{
foreach (var f in g.Files)
{
if (string.Equals(f.Path, trimmed, StringComparison.OrdinalIgnoreCase))
{
var prefix = HostsSourcePathDisplay.GroupSummaryPrefix(g.SourceKind);
return $"{prefix} · {f.FileName} ({f.RecordCount})";
}
}
}
return HostsSourcePathDisplay.FormatOptionLabel(trimmed);
}
}
private void ToggleFileFilterMenu() => _fileFilterMenuOpen = !_fileFilterMenuOpen;
private void CloseFileFilterMenu() => _fileFilterMenuOpen = false;
private void SelectSourceFileFilter(string path)
{
_sourceFileFilter = path ?? "";
CloseFileFilterMenu();
OnSourceFileFilterChangedAfterBind();
}
private async Task OnHostsToolbarKeyDown(KeyboardEventArgs e)
{
if (!_fileFilterMenuOpen || e.Key != "Escape")
return;
CloseFileFilterMenu();
await InvokeAsync(StateHasChanged);
}
private bool IsSourceFilePathSelected(string path) =>
!string.IsNullOrWhiteSpace(_sourceFileFilter)
&& string.Equals(_sourceFileFilter.Trim(), path.Trim(), StringComparison.OrdinalIgnoreCase);
private static string FileFilterGroupKindClass(HostsRowSourceKind kind) => kind switch
{
HostsRowSourceKind.Managed => "managed",
HostsRowSourceKind.SystemHosts => "system",
HostsRowSourceKind.AddnHosts => "addn",
HostsRowSourceKind.Hostsdir => "hostsdir",
_ => "other"
};
private void OnSourceFileFilterChangedAfterBind()
{
InvalidateVisibleGroupsCache();
StateHasChanged();
}
private void NormalizeSourceFileFilterIfStale()
{
if (string.IsNullOrWhiteSpace(_sourceFileFilter))
return;
var f = _sourceFileFilter.Trim();
var inRows = _unifiedRows.Any(r => string.Equals(r.SourcePath, f, StringComparison.OrdinalIgnoreCase));
var managed = _status?.ManagedHostsFilePath?.Trim();
var isManagedPath = !string.IsNullOrEmpty(managed)
&& string.Equals(managed, f, StringComparison.OrdinalIgnoreCase);
if (!inRows && !isManagedPath)
{
_sourceFileFilter = "";
InvalidateVisibleGroupsCache();
}
}
private void InvalidateVisibleGroupsCache() private void InvalidateVisibleGroupsCache()
{ {
_visibleGroupsCache = null; _visibleGroupsCache = null;
@@ -777,6 +939,13 @@ else
if (_showRemoveHostsModal || _showInvalidHostEditModal) if (_showRemoveHostsModal || _showInvalidHostEditModal)
return; return;
if (_fileFilterMenuOpen && e.Key == "Escape")
{
CloseFileFilterMenu();
await InvokeAsync(StateHasChanged);
return;
}
if (IsInlineEditing) if (IsInlineEditing)
{ {
// Scroll region is focusable (tabindex=0); after clicking outside inputs, Escape must still exit edit. // Scroll region is focusable (tabindex=0); after clicking outside inputs, Escape must still exit edit.
@@ -1193,6 +1362,7 @@ else
_unifiedRows = Array.Empty<HostsPageRow>(); _unifiedRows = Array.Empty<HostsPageRow>();
} }
NormalizeSourceFileFilterIfStale();
InvalidateVisibleGroupsCache(); InvalidateVisibleGroupsCache();
_selectedManagedRowIds.Clear(); _selectedManagedRowIds.Clear();
_selectionAnchorRowId = null; _selectionAnchorRowId = null;
@@ -1235,6 +1405,7 @@ else
} }
_unifiedRows = managedRows.Concat(_restUnifiedRows).ToList(); _unifiedRows = managedRows.Concat(_restUnifiedRows).ToList();
NormalizeSourceFileFilterIfStale();
ClearStaleInlineEditState(); ClearStaleInlineEditState();
PruneManagedSelectionToExistingEditableRows(); PruneManagedSelectionToExistingEditableRows();
} }
+262 -41
View File
@@ -58,9 +58,214 @@
box-shadow: 0 10px 24px -18px rgba(15, 23, 42, 0.35); box-shadow: 0 10px 24px -18px rgba(15, 23, 42, 0.35);
} }
/* Search + file filter on one row; search grows, select keeps a stable width. */
.hosts-toolbar-fields {
display: flex;
flex-wrap: nowrap;
align-items: stretch;
gap: 0.5rem;
min-width: 0;
}
.hosts-toolbar-search { .hosts-toolbar-search {
min-width: 14rem; flex: 1 1 12rem;
max-width: 24rem; min-width: 0;
width: auto;
max-width: none;
}
/* File filter: wrapper keeps toolbar flex slot; menu layers above table when open. */
.hosts-file-filter-wrap {
position: relative;
flex: 0 0 auto;
min-width: 13.5rem;
max-width: 22rem;
width: auto;
}
.hosts-file-filter-wrap--open {
z-index: 1080;
}
.hosts-file-filter-backdrop {
position: fixed;
inset: 0;
z-index: 1070;
background: transparent;
}
.hosts-file-filter-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.35rem;
width: 100%;
padding: 0.25rem 0.55rem;
font-size: 0.875rem;
line-height: 1.5;
text-align: left;
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius-sm);
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.hosts-file-filter-toggle:hover {
border-color: #bfc7d4;
}
.hosts-file-filter-wrap--open .hosts-file-filter-toggle {
border-color: #86b7fe;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.2);
}
.hosts-file-filter-toggle-label {
min-width: 0;
}
.hosts-file-filter-chevron {
font-size: 0.75rem;
opacity: 0.65;
transition: transform 0.15s ease;
}
.hosts-file-filter-wrap--open .hosts-file-filter-chevron {
transform: rotate(180deg);
}
.hosts-file-filter-panel {
position: absolute;
top: calc(100% + 4px);
right: 0;
left: auto;
min-width: 100%;
width: max-content;
max-width: min(26rem, 92vw);
max-height: min(22rem, 55vh);
overflow-x: hidden;
overflow-y: auto;
z-index: 1085;
padding: 0.35rem;
background: var(--bs-body-bg, #fff);
border: 1px solid var(--bs-border-color, #e2e8f0);
border-radius: 10px;
}
.hosts-file-filter-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
width: 100%;
padding: 0.4rem 0.55rem;
margin: 0;
font-size: 0.8125rem;
line-height: 1.35;
text-align: left;
color: #1e293b;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
}
.hosts-file-filter-option:hover {
background: rgba(15, 23, 42, 0.06);
}
.hosts-file-filter-option--active {
background: rgba(13, 110, 253, 0.12);
font-weight: 600;
}
.hosts-file-filter-option--active:hover {
background: rgba(13, 110, 253, 0.18);
}
.hosts-file-filter-option--all {
margin-bottom: 0.25rem;
border: 1px solid #e7ebf0;
background: linear-gradient(180deg, #fbfcfe 0%, #f4f6f9 100%);
}
.hosts-file-filter-option-main {
flex: 1 1 0%;
min-width: 0;
}
/* Full path when width allows; when truncated, keep the end (usually the filename) visible. Full path in title for hover. */
.hosts-file-filter-path {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
direction: rtl;
text-align: left;
unicode-bidi: plaintext;
}
.hosts-file-filter-count {
flex-shrink: 0;
padding: 0.1rem 0.45rem;
font-size: 0.7rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1.3;
color: #475569;
background: rgba(15, 23, 42, 0.06);
border-radius: 999px;
}
.hosts-file-filter-option--active .hosts-file-filter-count {
color: #0d47a1;
background: rgba(13, 110, 253, 0.2);
}
.hosts-file-filter-group {
margin-top: 0.35rem;
padding: 0.3rem 0.35rem 0.45rem;
border-radius: 8px;
border: 1px solid rgba(15, 23, 42, 0.08);
}
.hosts-file-filter-group:first-of-type {
margin-top: 0;
}
.hosts-file-filter-group--managed {
background: linear-gradient(180deg, #e8f4fd 0%, #dceef9 100%);
border-color: #b8d4ee;
}
.hosts-file-filter-group--system {
background: linear-gradient(180deg, #f1f5f9 0%, #e8edf3 100%);
border-color: #cbd5e1;
}
.hosts-file-filter-group--addn {
background: linear-gradient(180deg, #fff8e6 0%, #fff0cc 100%);
border-color: #e9d48a;
}
.hosts-file-filter-group--hostsdir {
background: linear-gradient(180deg, #ecfdf3 0%, #dff6e8 100%);
border-color: #a7e3c0;
}
.hosts-file-filter-group--other {
background: #f8fafc;
}
.hosts-file-filter-group-header {
padding: 0.2rem 0.35rem 0.45rem;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #334155;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
margin-bottom: 0.15rem;
} }
.hosts-toolbar-select { .hosts-toolbar-select {
@@ -161,13 +366,31 @@
.hosts-group-header__path-row { .hosts-group-header__path-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: flex-end; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.5rem 0.75rem; gap: 0.5rem 0.75rem;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
} }
/* Fixed column for actions avoids path/subtitle reflow when buttons show or hide. */
.hosts-group-header__path-row--with-managed-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--hosts-managed-actions-col, 9.75rem);
align-items: center;
column-gap: 0.5rem;
}
.hosts-group-header__path-row--with-managed-actions .hosts-group-header__path {
flex: unset;
min-width: 0;
}
.hosts-group-header__path-row--with-managed-actions .hosts-group-header__managed-actions {
margin-left: 0;
justify-self: end;
}
.hosts-group-header__path { .hosts-group-header__path {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -180,65 +403,60 @@
.hosts-group-header__managed-actions { .hosts-group-header__managed-actions {
flex: 0 0 auto; flex: 0 0 auto;
align-self: flex-end; align-self: center;
margin-left: auto; margin-left: auto;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; justify-content: flex-end;
min-height: 2.35rem; gap: 0.28rem;
padding: 0.25rem; min-height: 1.65rem;
max-width: 100%;
padding: 0.18rem;
border: 1px solid rgba(148, 163, 184, 0.26); border: 1px solid rgba(148, 163, 184, 0.26);
border-radius: 999px; border-radius: 999px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 250, 252, 0.94) 100%); background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 250, 252, 0.94) 100%);
box-shadow: 0 12px 24px -20px rgba(15, 23, 42, 0.45); box-shadow: 0 8px 18px -16px rgba(15, 23, 42, 0.4);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
transform-origin: top right; transition: opacity 160ms ease, border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
transition: opacity 180ms ease, transform 180ms ease, max-width 200ms ease, padding 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
overflow: hidden; overflow: hidden;
} }
.hosts-group-header__managed-actions--visible { .hosts-group-header__managed-actions--visible {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1);
max-width: 16rem;
} }
.hosts-group-header__managed-actions--hidden { .hosts-group-header__managed-actions--hidden {
opacity: 0; opacity: 0;
transform: translateY(-4px) scale(0.96);
max-width: 0;
padding-left: 0;
padding-right: 0;
border-color: transparent; border-color: transparent;
box-shadow: none; box-shadow: none;
background: transparent;
pointer-events: none; pointer-events: none;
} }
.hosts-managed-action-btn { .hosts-managed-action-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.3rem;
min-height: 1.9rem; min-height: 1.28rem;
padding: 0.38rem 0.78rem; padding: 0.22rem 0.52rem;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 999px; border-radius: 999px;
font-size: 0.84rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.01em; letter-spacing: 0.01em;
line-height: 1; line-height: 1;
box-shadow: none; box-shadow: none;
transition: transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease, border-color 140ms ease, color 140ms ease; transition: transform 120ms ease, box-shadow 120ms ease, background-color 120ms ease, border-color 120ms ease, color 120ms ease;
animation: hosts-managed-action-enter 180ms ease;
} }
.hosts-managed-action-btn i { .hosts-managed-action-btn i {
font-size: 0.85rem; font-size: 0.78rem;
} }
.hosts-managed-action-btn:hover, .hosts-managed-action-btn:hover,
.hosts-managed-action-btn:focus-visible { .hosts-managed-action-btn:focus-visible {
transform: translateY(-1px); transform: translateY(-0.5px);
box-shadow: 0 10px 18px -14px rgba(15, 23, 42, 0.55); box-shadow: 0 6px 14px -12px rgba(15, 23, 42, 0.5);
} }
.hosts-managed-action-btn:active { .hosts-managed-action-btn:active {
@@ -285,18 +503,6 @@
border-color: rgba(239, 68, 68, 0.42); border-color: rgba(239, 68, 68, 0.42);
} }
@keyframes hosts-managed-action-enter {
from {
opacity: 0;
transform: translateY(-4px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.hosts-group-header__badge { .hosts-group-header__badge {
flex-shrink: 0; flex-shrink: 0;
} }
@@ -382,14 +588,23 @@
} }
@media (max-width: 640.98px) { @media (max-width: 640.98px) {
.hosts-group-header__path-row--with-managed-actions {
--hosts-managed-actions-col: 9rem;
}
.hosts-group-header__managed-actions { .hosts-group-header__managed-actions {
gap: 0.3rem; gap: 0.22rem;
min-height: 2.2rem; min-height: 1.55rem;
padding: 0.15rem;
} }
.hosts-managed-action-btn { .hosts-managed-action-btn {
padding: 0.34rem 0.68rem; padding: 0.2rem 0.45rem;
font-size: 0.8rem; font-size: 0.72rem;
}
.hosts-managed-action-btn i {
font-size: 0.74rem;
} }
} }
@@ -419,10 +634,16 @@
border-radius: 12px; border-radius: 12px;
} }
.hosts-toolbar-fields {
flex-wrap: wrap;
}
.hosts-toolbar-search, .hosts-toolbar-search,
.hosts-file-filter-wrap,
.hosts-toolbar-select { .hosts-toolbar-select {
min-width: 0; min-width: 0;
max-width: none; max-width: none;
flex: 1 1 100%;
width: 100%; width: 100%;
} }
@@ -25,6 +25,12 @@ public sealed class HostsPageProjectionService : IHostsPageProjectionService
if (query.SourceKindFilter is { } sk) if (query.SourceKindFilter is { } sk)
filtered = filtered.Where(r => r.SourceKind == sk); filtered = filtered.Where(r => r.SourceKind == sk);
if (!string.IsNullOrWhiteSpace(query.SourcePathFilter))
{
var path = query.SourcePathFilter.Trim();
filtered = filtered.Where(r => string.Equals(r.SourcePath, path, StringComparison.OrdinalIgnoreCase));
}
if (query.EditableFilter is { } ed) if (query.EditableFilter is { } ed)
filtered = filtered.Where(r => r.IsEditable == ed); filtered = filtered.Where(r => r.IsEditable == ed);
@@ -0,0 +1,10 @@
namespace DnsmasqWebUI.Models.Hosts;
/// <summary>One hosts file row in the filter menu (path, short name, non-comment line count).</summary>
public sealed record HostsFileFilterMenuItem(string Path, string FileName, int RecordCount);
/// <summary>A source-kind section in the hosts file filter menu.</summary>
public sealed record HostsFileFilterMenuGroup(
HostsRowSourceKind SourceKind,
string Title,
IReadOnlyList<HostsFileFilterMenuItem> Files);
@@ -23,5 +23,7 @@ public sealed record HostsPageQueryState(
HostsSortMode Sort, HostsSortMode Sort,
bool Descending, bool Descending,
HostsRowSourceKind? SourceKindFilter, HostsRowSourceKind? SourceKindFilter,
/// <summary>When set, only rows whose <see cref="HostsPageRow.SourcePath"/> matches (ordinal-ignore-case).</summary>
string? SourcePathFilter,
bool? EditableFilter, bool? EditableFilter,
bool? ActiveFilter); bool? ActiveFilter);
@@ -0,0 +1,81 @@
namespace DnsmasqWebUI.Models.Hosts;
/// <summary>Labels for hosts <see cref="HostsPageRow.SourcePath"/> in UI (e.g. file filter dropdown).</summary>
public static class HostsSourcePathDisplay
{
public static string FormatOptionLabel(string path)
{
if (string.IsNullOrWhiteSpace(path))
return path;
var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var name = Path.GetFileName(trimmed);
return string.IsNullOrEmpty(name) ? path : name;
}
/// <summary>
/// Builds grouped menu data: paths grouped by <see cref="HostsRowSourceKind"/> with per-file record counts
/// (non-comment lines). Ensures <paramref name="managedHostsPathAlwaysShow"/> appears even when there are no managed rows yet.
/// </summary>
public static IReadOnlyList<HostsFileFilterMenuGroup> BuildFileFilterMenuGroups(
IReadOnlyList<HostsPageRow> rows,
string? managedHostsPathAlwaysShow)
{
var byPath = rows
.Where(r => !string.IsNullOrWhiteSpace(r.SourcePath))
.GroupBy(r => r.SourcePath.Trim(), StringComparer.OrdinalIgnoreCase)
.Select(g => (
Path: g.Key,
Kind: g.First().SourceKind,
Count: g.Count(r => !r.IsComment)))
.ToList();
var managedTrim = managedHostsPathAlwaysShow?.Trim();
if (!string.IsNullOrEmpty(managedTrim)
&& !byPath.Any(e => string.Equals(e.Path, managedTrim, StringComparison.OrdinalIgnoreCase)))
byPath.Add((managedTrim, HostsRowSourceKind.Managed, 0));
return byPath
.GroupBy(e => e.Kind)
.OrderBy(g => SourceKindOrder(g.Key))
.Select(g =>
{
var files = g.OrderBy(e => e.Path, StringComparer.OrdinalIgnoreCase)
.Select(e => new HostsFileFilterMenuItem(e.Path, FormatOptionLabel(e.Path), e.Count))
.ToList();
return new HostsFileFilterMenuGroup(
g.Key,
GroupMenuTitle(g.Key),
files);
})
.ToList();
}
public static string GroupMenuTitle(HostsRowSourceKind kind) => kind switch
{
HostsRowSourceKind.Managed => "Managed hosts",
HostsRowSourceKind.SystemHosts => "System hosts",
HostsRowSourceKind.AddnHosts => "Additional hosts",
HostsRowSourceKind.Hostsdir => "Hosts directory",
_ => "Hosts files"
};
public static string GroupSummaryPrefix(HostsRowSourceKind kind) => kind switch
{
HostsRowSourceKind.Managed => "Managed",
HostsRowSourceKind.SystemHosts => "System",
HostsRowSourceKind.AddnHosts => "Additional",
HostsRowSourceKind.Hostsdir => "Hosts dir",
_ => "Hosts"
};
private static int SourceKindOrder(HostsRowSourceKind kind) => kind switch
{
HostsRowSourceKind.Managed => 0,
HostsRowSourceKind.SystemHosts => 1,
HostsRowSourceKind.AddnHosts => 2,
HostsRowSourceKind.Hostsdir => 3,
_ => 99
};
}