| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119 |
- 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 > ip > tag > label) and a shape modifier (equality >
- /// prefix > substring > 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;
- }
- }
|