Просмотр исходного кода

Merge pull request #293 from Timmoth/feature/global-search

Feature/global search
Tim Jones 3 дней назад
Родитель
Сommit
8276b64e1f

+ 142 - 0
Shared.Rcl/Components/GlobalSearch.razor

@@ -0,0 +1,142 @@
+@* Global resource search — header-level palette.
+
+   Keyboard:  ↑/↓ move the highlight, Enter navigates, Esc clears.
+   Blur:      Clicking anywhere outside the input closes the dropdown.
+*@
+
+@using Microsoft.AspNetCore.Components.Web
+@using RackPeek.Domain.Persistence
+@using RackPeek.Domain.Resources
+@using Shared.Rcl.Services
+
+@inject IResourceCollection Repo
+@inject NavigationManager Nav
+
+<div class="relative">
+    <input type="search"
+           data-testid="global-search-input"
+           placeholder="Search resources…"
+           autocomplete="off"
+           class="bg-zinc-800 text-zinc-200 placeholder-zinc-500
+                  border border-zinc-700 rounded
+                  px-3 py-1 text-sm w-72
+                  focus:outline-none focus:border-emerald-400"
+           @bind="_query"
+           @bind:event="oninput"
+           @bind:after="RunSearchAsync"
+           @onkeydown="OnKeyDown"
+           @onfocusout="OnFocusOut"/>
+
+    @if (!string.IsNullOrWhiteSpace(_query))
+    {
+        <div data-testid="global-search-results"
+             class="absolute left-0 right-0 mt-1 z-50
+                    bg-zinc-900 border border-zinc-700 rounded
+                    shadow-lg max-h-96 overflow-y-auto text-sm">
+
+            @if (_results.Count == 0)
+            {
+                <div data-testid="global-search-no-results"
+                     class="px-3 py-2 text-zinc-500 italic">
+                    No matches.
+                </div>
+            }
+            else
+            {
+                @for (var i = 0; i < _results.Count; i++)
+                {
+                    var r = _results[i];
+                    var highlighted = i == _highlightedIndex;
+                    var rowClass = highlighted
+                        ? "bg-zinc-800 border-l-2 border-emerald-400"
+                        : "hover:bg-zinc-800 border-l-2 border-transparent";
+
+                    <button type="button"
+                            data-testid="@($"global-search-result-{Sanitize(r.Kind)}-{Sanitize(r.Name)}")"
+                            @onclick="() => OnResultSelected(r)"
+                            class="@($"w-full text-left block px-3 py-2 text-zinc-200 border-b border-zinc-800 last:border-b-0 cursor-pointer {rowClass}")">
+
+                        <div class="font-medium text-emerald-400">@r.Name</div>
+                        <div data-testid="global-search-result-match"
+                             class="text-xs text-zinc-500">
+                            @r.Kind · via @r.MatchedField: @r.MatchedValue
+                        </div>
+                    </button>
+                }
+            }
+        </div>
+    }
+</div>
+
+@code {
+    // Brief delay on focus-out so a click on a result button can fire first.
+    // Browser event order is mousedown → blur → click, so the focus-out fires
+    // before the click handler on the button. The delay lets the click register
+    // before we close the dropdown.
+    private const int _blurCloseDelayMs = 150;
+
+    private string _query = string.Empty;
+    private IReadOnlyList<SearchResult> _results = [];
+    private int _highlightedIndex;
+
+    private async Task RunSearchAsync()
+    {
+        // Reload every search so newly added / edited / deleted resources show up
+        // without a hard page refresh. The repo layer is already in-memory, so
+        // this is a cheap dictionary lookup, not a disk read.
+        var resources = await Repo.GetAllOfTypeAsync<Resource>();
+        _results = GlobalSearchService.Search(resources, _query);
+        _highlightedIndex = 0;
+    }
+
+    private void OnKeyDown(KeyboardEventArgs e)
+    {
+        switch (e.Key)
+        {
+            case "ArrowDown":
+                if (_results.Count > 0)
+                    _highlightedIndex = Math.Min(_highlightedIndex + 1, _results.Count - 1);
+                break;
+
+            case "ArrowUp":
+                if (_results.Count > 0)
+                    _highlightedIndex = Math.Max(_highlightedIndex - 1, 0);
+                break;
+
+            case "Enter":
+                if (_highlightedIndex >= 0 && _highlightedIndex < _results.Count)
+                    OnResultSelected(_results[_highlightedIndex]);
+                break;
+
+            case "Escape":
+                Close();
+                break;
+        }
+    }
+
+    private async Task OnFocusOut()
+    {
+        await Task.Delay(_blurCloseDelayMs);
+        if (!string.IsNullOrEmpty(_query))
+        {
+            Close();
+            StateHasChanged();
+        }
+    }
+
+    private void OnResultSelected(SearchResult r)
+    {
+        Close();
+        Nav.NavigateTo(r.Url);
+    }
+
+    private void Close()
+    {
+        _query = string.Empty;
+        _results = [];
+        _highlightedIndex = 0;
+    }
+
+    private static string Sanitize(string value) =>
+        value.Replace(" ", "-").ToLowerInvariant();
+}

+ 25 - 20
Shared.Rcl/Layout/MainLayout.razor

@@ -1,30 +1,35 @@
 @using RackPeek.Domain
+@using Shared.Rcl.Components
 @inherits LayoutComponentBase
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono"
      data-testid="app-root">
 
     <header class="flex items-center justify-between p-4 border-b border-zinc-800 bg-zinc-900"
             data-testid="app-header">
-        <NavLink href=""
-                 data-testid="brand-link"
-                 class="hover:text-emerald-400"
-                 activeClass="text-emerald-400 font-semibold">
-
-            <div class="flex items-center gap-3"
-                 data-testid="brand-text">
-
-                <span class="text-xl font-bold text-emerald-400 tracking-wider">
-                    rackpeek
-                </span>
-
-                <span class="text-[10px]
-                     text-zinc-500
-                     tracking-wide">
-                    @RpkConstants.Version
-                </span>
-
-            </div>
-        </NavLink>
+        <div class="flex items-center gap-6">
+            <NavLink href=""
+                     data-testid="brand-link"
+                     class="hover:text-emerald-400"
+                     activeClass="text-emerald-400 font-semibold">
+
+                <div class="flex items-center gap-3"
+                     data-testid="brand-text">
+
+                    <span class="text-xl font-bold text-emerald-400 tracking-wider">
+                        rackpeek
+                    </span>
+
+                    <span class="text-[10px]
+                         text-zinc-500
+                         tracking-wide">
+                        @RpkConstants.Version
+                    </span>
+
+                </div>
+            </NavLink>
+
+            <GlobalSearch/>
+        </div>
 
         <div class="flex items-center gap-6">
             @if (RpkConstants.HasGitServices)

+ 119 - 0
Shared.Rcl/Services/GlobalSearchService.cs

@@ -0,0 +1,119 @@
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Shared.Rcl.Services;
+
+public record SearchResult(
+    string Name,
+    string Kind,
+    string Url,
+    string MatchedField,
+    string MatchedValue,
+    int Score
+);
+
+/// <summary>
+/// Ranks a set of resources against a free-text query and returns the top N matches.
+///
+/// Scoring per resource: each searchable field is scored independently with a
+/// weight (name &gt; ip &gt; tag &gt; label) and a shape modifier (equality &gt;
+/// prefix &gt; substring &gt; subsequence). The resource takes the best-scoring
+/// single field, so a strong name match always beats a weak label match.
+/// </summary>
+public static class GlobalSearchService {
+    private const int _defaultMax = 8;
+
+    private const int _weightName = 100;
+    private const int _weightIp = 50;
+    private const int _weightTag = 25;
+    private const int _weightLabel = 10;
+
+    public static IReadOnlyList<SearchResult> Search(
+        IEnumerable<Resource> resources,
+        string query,
+        int max = _defaultMax) {
+        if (string.IsNullOrWhiteSpace(query)) return [];
+
+        var q = query.Trim().ToLowerInvariant();
+        var results = new List<SearchResult>();
+
+        foreach (Resource r in resources) {
+            (string Field, string Value, int Score)? best = BestMatch(r, q);
+            if (best is null) continue;
+
+            results.Add(new SearchResult(
+                Name: r.Name,
+                Kind: r.Kind,
+                Url: Resource.GetResourceUrl(r.Kind, r.Name),
+                MatchedField: best.Value.Field,
+                MatchedValue: best.Value.Value,
+                Score: best.Value.Score));
+        }
+
+        return results
+            .OrderByDescending(s => s.Score)
+            .ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
+            .Take(max)
+            .ToList();
+    }
+
+    private static (string Field, string Value, int Score)? BestMatch(Resource r, string q) {
+        (string Field, string Value, int Score)? best = null;
+
+        void Consider(string field, string? value, int weight) {
+            if (string.IsNullOrEmpty(value)) return;
+            var score = ScoreField(value, q, weight);
+            if (score <= 0) return;
+            if (best is null || score > best.Value.Score) {
+                best = (field, value, score);
+            }
+        }
+
+        Consider("name", r.Name, _weightName);
+
+        var ip = GetIp(r);
+        Consider("ip", ip, _weightIp);
+
+        if (r.Tags is not null) {
+            foreach (var tag in r.Tags) {
+                Consider("tag", tag, _weightTag);
+            }
+        }
+
+        if (r.Labels is not null) {
+            foreach (KeyValuePair<string, string> kvp in r.Labels) {
+                // Match against the value (label keys are usually category names,
+                // values hold the meaningful data — IPs, hostnames, etc).
+                Consider("label", $"{kvp.Key}: {kvp.Value}", _weightLabel);
+            }
+        }
+
+        return best;
+    }
+
+    private static string? GetIp(Resource r) => r switch {
+        SystemResource s => s.Ip,
+        Service svc => svc.Network?.Ip,
+        _ => null
+    };
+
+    private static int ScoreField(string? value, string lowerQuery, int weight) {
+        if (string.IsNullOrEmpty(value)) return 0;
+
+        var v = value.ToLowerInvariant();
+        if (v == lowerQuery) return weight + 20;
+        if (v.StartsWith(lowerQuery, StringComparison.Ordinal)) return weight + 10;
+        if (v.Contains(lowerQuery, StringComparison.Ordinal)) return weight;
+        return IsSubsequence(lowerQuery, v) ? weight - 10 : 0;
+    }
+
+    private static bool IsSubsequence(string needle, string haystack) {
+        var i = 0;
+        foreach (var c in haystack) {
+            if (i < needle.Length && c == needle[i]) i++;
+            if (i == needle.Length) return true;
+        }
+        return i == needle.Length;
+    }
+}

+ 108 - 0
Tests.E2e/GlobalSearchTests.cs

@@ -0,0 +1,108 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class GlobalSearchTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output) {
+    private readonly PlaywrightFixture _fixture = fixture;
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Search_For_Resources_Of_Every_Kind_And_Navigate_To_One() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        // Shared random token in every seeded name so a single fragment hits
+        // every kind, while a kind-prefixed query isolates one.
+        var token = Guid.NewGuid().ToString("N")[..6];
+
+        var seed = new (string Kind, string Name)[] {
+            ("server",      $"srv-{token}"),
+            ("switch",      $"sw-{token}"),
+            ("firewall",    $"fw-{token}"),
+            ("router",      $"rt-{token}"),
+            ("accesspoint", $"ap-{token}"),
+            ("ups",         $"ups-{token}"),
+            ("desktop",     $"dt-{token}"),
+            ("laptop",      $"lt-{token}"),
+            ("system",      $"sys-{token}"),
+            ("service",     $"svc-{token}"),
+        };
+
+        try {
+            await page.GotoAsync(_fixture.BaseUrl);
+            await new MainLayoutPom(page).AssertLoadedAsync();
+
+            // ───── Seed: one resource of every kind ─────
+            await page.GotoAsync($"{_fixture.BaseUrl}/servers/list");
+            await new ServersListPom(page).AddServerAsync($"srv-{token}");
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/switches/list");
+            await new SwitchListPom(page).AddSwitchAsync($"sw-{token}");
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/firewalls/list");
+            await new FirewallsListPom(page).AddFirewallAsync($"fw-{token}");
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/routers/list");
+            await new RouterListPom(page).AddRouterAsync($"rt-{token}");
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/accesspoints/list");
+            await new AccessPointsListPom(page).AddAccessPointAsync($"ap-{token}");
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/ups/list");
+            await new UpsListPom(page).AddUpsAsync($"ups-{token}");
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/desktops/list");
+            await new DesktopsListPom(page).AddDesktopAsync($"dt-{token}");
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/laptops/list");
+            await new LaptopListPom(page).AddLaptopAsync($"lt-{token}");
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/systems/list");
+            await new SystemsListPom(page).AddSystemAsync($"sys-{token}");
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/services/list");
+            await new ServicesListPom(page).AddServiceAsync($"svc-{token}");
+
+            var search = new GlobalSearchPom(page);
+
+            // ───── Per-kind search: each unique name surfaces its own kind ─────
+            foreach ((string Kind, string Name) entry in seed) {
+                await search.SearchAsync(entry.Name);
+                await search.AssertResultExistsAsync(entry.Kind, entry.Name);
+            }
+
+            // ───── Cross-kind search: shared token surfaces results from
+            //       multiple kinds in the same dropdown. Top N is capped at 8;
+            //       within an equal-score tier ties break alphabetically by name,
+            //       so the kinds asserted here are those guaranteed to land
+            //       inside the cap given the seeded name prefixes.
+            await search.SearchAsync(token);
+            await search.AssertResultExistsAsync("service", $"svc-{token}");
+            await search.AssertResultExistsAsync("server", $"srv-{token}");
+            await search.AssertResultExistsAsync("switch", $"sw-{token}");
+
+            // ───── Click navigates to the resource page ─────
+            await search.SearchAsync($"svc-{token}");
+            await search.ClickResultAsync("service", $"svc-{token}");
+            await page.WaitForURLAsync($"**/resources/services/svc-{token}");
+        }
+        catch (Exception) {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+}

+ 44 - 0
Tests.E2e/PageObjectModels/GlobalSearchPom.cs

@@ -0,0 +1,44 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+/// <summary>
+/// POM for the header-level global search component.
+///
+/// Selectors anchor on stable test IDs:
+///   global-search-input             — the textbox
+///   global-search-results           — the dropdown container (visible when query is non-empty)
+///   global-search-no-results        — empty-state inside the dropdown
+///   global-search-result-{kind}-{n} — one row per match
+///   global-search-result-match      — secondary label inside a row, e.g. "via ip: 10.0.0.5"
+/// </summary>
+public class GlobalSearchPom(IPage page) {
+    public ILocator Input => page.GetByTestId("global-search-input");
+    public ILocator Results => page.GetByTestId("global-search-results");
+    public ILocator NoResults => page.GetByTestId("global-search-no-results");
+
+    public ILocator Result(string kind, string name) =>
+        page.GetByTestId($"global-search-result-{kind.ToLowerInvariant()}-{Sanitize(name)}");
+
+    public ILocator ResultMatchLabel(string kind, string name) =>
+        Result(kind, name).GetByTestId("global-search-result-match");
+
+    public async Task SearchAsync(string query) {
+        await Input.FillAsync(query);
+        await Assertions.Expect(Results).ToBeVisibleAsync();
+    }
+
+    public async Task ClearAsync() => await Input.FillAsync("");
+
+    public async Task ClickResultAsync(string kind, string name) =>
+        await Result(kind, name).ClickAsync();
+
+    public async Task AssertResultExistsAsync(string kind, string name) =>
+        await Assertions.Expect(Result(kind, name)).ToBeVisibleAsync();
+
+    public async Task AssertResultDoesNotExistAsync(string kind, string name) =>
+        await Assertions.Expect(Result(kind, name)).Not.ToBeVisibleAsync();
+
+    private static string Sanitize(string value) =>
+        value.Replace(" ", "-").ToLowerInvariant();
+}