Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions Shared.Rcl/Components/GlobalSearch.razor
Original file line number Diff line number Diff line change
@@ -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();
}
45 changes: 25 additions & 20 deletions Shared.Rcl/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
119 changes: 119 additions & 0 deletions Shared.Rcl/Services/GlobalSearchService.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading