|
@@ -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 > 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;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|