mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -69,19 +69,79 @@ else
|
||||
<section class="page-section">
|
||||
<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="d-flex flex-wrap gap-2 align-items-center flex-grow-1">
|
||||
<div class="hosts-controls-bar d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2"
|
||||
@onkeydown="OnHostsToolbarKeyDown">
|
||||
<div class="hosts-toolbar-fields flex-grow-1">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm hosts-toolbar-search"
|
||||
placeholder="Search address, names, path…"
|
||||
@bind="_search"
|
||||
@bind:event="oninput" />
|
||||
<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>
|
||||
|
||||
@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
|
||||
{
|
||||
@@ -89,6 +149,10 @@ else
|
||||
{
|
||||
<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-region" data-table-scroll-region role="region" aria-label="Hosts entries table" tabindex="0"
|
||||
@onkeydown="OnHostsTableRegionKeyDown">
|
||||
@@ -128,7 +192,7 @@ else
|
||||
<span class="badge bg-warning text-dark">Inactive</span>
|
||||
}
|
||||
</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">
|
||||
@if (!string.IsNullOrWhiteSpace(group.Subtitle))
|
||||
{
|
||||
@@ -406,6 +470,8 @@ else
|
||||
private string? _error;
|
||||
|
||||
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);
|
||||
|
||||
@@ -431,6 +497,7 @@ else
|
||||
private object? _visibleGroupsCacheRowsRef;
|
||||
private string _visibleGroupsCacheSearch = "\0";
|
||||
private string _visibleGroupsCacheManagedPath = "\0";
|
||||
private string _visibleGroupsCacheSourcePathFilter = "\0";
|
||||
|
||||
private IReadOnlyList<HostsPageGroup> VisibleGroups
|
||||
{
|
||||
@@ -440,7 +507,8 @@ else
|
||||
if (_visibleGroupsCache != null
|
||||
&& ReferenceEquals(_visibleGroupsCacheRowsRef, _unifiedRows)
|
||||
&& _visibleGroupsCacheSearch == _search
|
||||
&& _visibleGroupsCacheManagedPath == managedPath)
|
||||
&& _visibleGroupsCacheManagedPath == managedPath
|
||||
&& _visibleGroupsCacheSourcePathFilter == _sourceFileFilter)
|
||||
{
|
||||
return _visibleGroupsCache;
|
||||
}
|
||||
@@ -450,6 +518,7 @@ else
|
||||
_visibleGroupsCacheRowsRef = _unifiedRows;
|
||||
_visibleGroupsCacheSearch = _search;
|
||||
_visibleGroupsCacheManagedPath = managedPath;
|
||||
_visibleGroupsCacheSourcePathFilter = _sourceFileFilter;
|
||||
return _visibleGroupsCache;
|
||||
}
|
||||
}
|
||||
@@ -512,6 +581,10 @@ else
|
||||
if (groups.Any(g => g.SourceKind == HostsRowSourceKind.Managed))
|
||||
return groups;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_sourceFileFilter)
|
||||
&& !string.Equals(_sourceFileFilter.Trim(), path, StringComparison.OrdinalIgnoreCase))
|
||||
return groups;
|
||||
|
||||
var placeholder = new HostsPageGroup(
|
||||
Key: $"{HostsRowSourceKind.Managed}:{path}",
|
||||
Title: "Managed hosts",
|
||||
@@ -535,10 +608,99 @@ else
|
||||
Sort: HostsSortMode.LineNumber,
|
||||
Descending: false,
|
||||
SourceKindFilter: null,
|
||||
SourcePathFilter: string.IsNullOrWhiteSpace(_sourceFileFilter) ? null : _sourceFileFilter.Trim(),
|
||||
EditableFilter: 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()
|
||||
{
|
||||
_visibleGroupsCache = null;
|
||||
@@ -777,6 +939,13 @@ else
|
||||
if (_showRemoveHostsModal || _showInvalidHostEditModal)
|
||||
return;
|
||||
|
||||
if (_fileFilterMenuOpen && e.Key == "Escape")
|
||||
{
|
||||
CloseFileFilterMenu();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsInlineEditing)
|
||||
{
|
||||
// Scroll region is focusable (tabindex=0); after clicking outside inputs, Escape must still exit edit.
|
||||
@@ -1193,6 +1362,7 @@ else
|
||||
_unifiedRows = Array.Empty<HostsPageRow>();
|
||||
}
|
||||
|
||||
NormalizeSourceFileFilterIfStale();
|
||||
InvalidateVisibleGroupsCache();
|
||||
_selectedManagedRowIds.Clear();
|
||||
_selectionAnchorRowId = null;
|
||||
@@ -1235,6 +1405,7 @@ else
|
||||
}
|
||||
|
||||
_unifiedRows = managedRows.Concat(_restUnifiedRows).ToList();
|
||||
NormalizeSourceFileFilterIfStale();
|
||||
ClearStaleInlineEditState();
|
||||
PruneManagedSelectionToExistingEditableRows();
|
||||
}
|
||||
|
||||
@@ -58,9 +58,214 @@
|
||||
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 {
|
||||
min-width: 14rem;
|
||||
max-width: 24rem;
|
||||
flex: 1 1 12rem;
|
||||
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 {
|
||||
@@ -161,13 +366,31 @@
|
||||
.hosts-group-header__path-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 0.75rem;
|
||||
width: 100%;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -180,65 +403,60 @@
|
||||
|
||||
.hosts-group-header__managed-actions {
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-end;
|
||||
align-self: center;
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2.35rem;
|
||||
padding: 0.25rem;
|
||||
justify-content: flex-end;
|
||||
gap: 0.28rem;
|
||||
min-height: 1.65rem;
|
||||
max-width: 100%;
|
||||
padding: 0.18rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.26);
|
||||
border-radius: 999px;
|
||||
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);
|
||||
transform-origin: top right;
|
||||
transition: opacity 180ms ease, transform 180ms ease, max-width 200ms ease, padding 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||||
transition: opacity 160ms ease, border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hosts-group-header__managed-actions--visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.hosts-group-header__managed-actions--hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.96);
|
||||
max-width: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hosts-managed-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 1.9rem;
|
||||
padding: 0.38rem 0.78rem;
|
||||
gap: 0.3rem;
|
||||
min-height: 1.28rem;
|
||||
padding: 0.22rem 0.52rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: 0.84rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1;
|
||||
box-shadow: none;
|
||||
transition: transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease, border-color 140ms ease, color 140ms ease;
|
||||
animation: hosts-managed-action-enter 180ms ease;
|
||||
transition: transform 120ms ease, box-shadow 120ms ease, background-color 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.hosts-managed-action-btn i {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.hosts-managed-action-btn:hover,
|
||||
.hosts-managed-action-btn:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 18px -14px rgba(15, 23, 42, 0.55);
|
||||
transform: translateY(-0.5px);
|
||||
box-shadow: 0 6px 14px -12px rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
|
||||
.hosts-managed-action-btn:active {
|
||||
@@ -285,18 +503,6 @@
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -382,14 +588,23 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.hosts-group-header__path-row--with-managed-actions {
|
||||
--hosts-managed-actions-col: 9rem;
|
||||
}
|
||||
|
||||
.hosts-group-header__managed-actions {
|
||||
gap: 0.3rem;
|
||||
min-height: 2.2rem;
|
||||
gap: 0.22rem;
|
||||
min-height: 1.55rem;
|
||||
padding: 0.15rem;
|
||||
}
|
||||
|
||||
.hosts-managed-action-btn {
|
||||
padding: 0.34rem 0.68rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.hosts-managed-action-btn i {
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,10 +634,16 @@
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.hosts-toolbar-fields {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hosts-toolbar-search,
|
||||
.hosts-file-filter-wrap,
|
||||
.hosts-toolbar-select {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@ public sealed class HostsPageProjectionService : IHostsPageProjectionService
|
||||
if (query.SourceKindFilter is { } 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)
|
||||
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,
|
||||
bool Descending,
|
||||
HostsRowSourceKind? SourceKindFilter,
|
||||
/// <summary>When set, only rows whose <see cref="HostsPageRow.SourcePath"/> matches (ordinal-ignore-case).</summary>
|
||||
string? SourcePathFilter,
|
||||
bool? EditableFilter,
|
||||
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
|
||||
};
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user