GlobalSearchService.cs 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. using RackPeek.Domain.Resources;
  2. using RackPeek.Domain.Resources.Services;
  3. using RackPeek.Domain.Resources.SystemResources;
  4. namespace Shared.Rcl.Services;
  5. public record SearchResult(
  6. string Name,
  7. string Kind,
  8. string Url,
  9. string MatchedField,
  10. string MatchedValue,
  11. int Score
  12. );
  13. /// <summary>
  14. /// Ranks a set of resources against a free-text query and returns the top N matches.
  15. ///
  16. /// Scoring per resource: each searchable field is scored independently with a
  17. /// weight (name &gt; ip &gt; tag &gt; label) and a shape modifier (equality &gt;
  18. /// prefix &gt; substring &gt; subsequence). The resource takes the best-scoring
  19. /// single field, so a strong name match always beats a weak label match.
  20. /// </summary>
  21. public static class GlobalSearchService {
  22. private const int _defaultMax = 8;
  23. private const int _weightName = 100;
  24. private const int _weightIp = 50;
  25. private const int _weightTag = 25;
  26. private const int _weightLabel = 10;
  27. public static IReadOnlyList<SearchResult> Search(
  28. IEnumerable<Resource> resources,
  29. string query,
  30. int max = _defaultMax) {
  31. if (string.IsNullOrWhiteSpace(query)) return [];
  32. var q = query.Trim().ToLowerInvariant();
  33. var results = new List<SearchResult>();
  34. foreach (Resource r in resources) {
  35. (string Field, string Value, int Score)? best = BestMatch(r, q);
  36. if (best is null) continue;
  37. results.Add(new SearchResult(
  38. Name: r.Name,
  39. Kind: r.Kind,
  40. Url: Resource.GetResourceUrl(r.Kind, r.Name),
  41. MatchedField: best.Value.Field,
  42. MatchedValue: best.Value.Value,
  43. Score: best.Value.Score));
  44. }
  45. return results
  46. .OrderByDescending(s => s.Score)
  47. .ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
  48. .Take(max)
  49. .ToList();
  50. }
  51. private static (string Field, string Value, int Score)? BestMatch(Resource r, string q) {
  52. (string Field, string Value, int Score)? best = null;
  53. void Consider(string field, string? value, int weight) {
  54. if (string.IsNullOrEmpty(value)) return;
  55. var score = ScoreField(value, q, weight);
  56. if (score <= 0) return;
  57. if (best is null || score > best.Value.Score) {
  58. best = (field, value, score);
  59. }
  60. }
  61. Consider("name", r.Name, _weightName);
  62. var ip = GetIp(r);
  63. Consider("ip", ip, _weightIp);
  64. if (r.Tags is not null) {
  65. foreach (var tag in r.Tags) {
  66. Consider("tag", tag, _weightTag);
  67. }
  68. }
  69. if (r.Labels is not null) {
  70. foreach (KeyValuePair<string, string> kvp in r.Labels) {
  71. // Match against the value (label keys are usually category names,
  72. // values hold the meaningful data — IPs, hostnames, etc).
  73. Consider("label", $"{kvp.Key}: {kvp.Value}", _weightLabel);
  74. }
  75. }
  76. return best;
  77. }
  78. private static string? GetIp(Resource r) => r switch {
  79. SystemResource s => s.Ip,
  80. Service svc => svc.Network?.Ip,
  81. _ => null
  82. };
  83. private static int ScoreField(string? value, string lowerQuery, int weight) {
  84. if (string.IsNullOrEmpty(value)) return 0;
  85. var v = value.ToLowerInvariant();
  86. if (v == lowerQuery) return weight + 20;
  87. if (v.StartsWith(lowerQuery, StringComparison.Ordinal)) return weight + 10;
  88. if (v.Contains(lowerQuery, StringComparison.Ordinal)) return weight;
  89. return IsSubsequence(lowerQuery, v) ? weight - 10 : 0;
  90. }
  91. private static bool IsSubsequence(string needle, string haystack) {
  92. var i = 0;
  93. foreach (var c in haystack) {
  94. if (i < needle.Length && c == needle[i]) i++;
  95. if (i == needle.Length) return true;
  96. }
  97. return i == needle.Length;
  98. }
  99. }