From 99eac1369fc596234b7fa64b65300f510a7f4bef Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 27 Mar 2026 21:50:46 +0100 Subject: [PATCH] layout fixes and fuzzy search --- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../TableStorage/TableSessionRepository.cs | 2 +- .../Sessions/QuickSearchSessionsFunction.cs | 4 +- .../Services/TableStorageService.AgentApi.cs | 124 ++++++++++++++---- .../DataAccess/ISessionRepository.cs | 2 +- .../components/GlobalSearch.tsx | 2 +- .../components/Navbar.tsx | 8 +- .../tsconfig.tsbuildinfo | 2 +- 8 files changed, 112 insertions(+), 34 deletions(-) diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index c9a777fb..636b54bd 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -632 +633 diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs index 9a823ab0..041687e3 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs @@ -89,7 +89,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task> GetSessionEventsAsync(string tenantId, string sessionId, int maxResults = 1000) => _storage.GetSessionEventsAsync(tenantId, sessionId, maxResults); - public Task> QuickSearchSessionsAsync(string tenantId, string query, int limit = 10) + public Task> QuickSearchSessionsAsync(string? tenantId, string query, int limit = 10) => _storage.QuickSearchSessionsAsync(tenantId, query, limit); public Task> SearchSessionsAsync(string? tenantId, SessionSearchFilter filter) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QuickSearchSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QuickSearchSessionsFunction.cs index 8dba8906..fcfbaed6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QuickSearchSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QuickSearchSessionsFunction.cs @@ -44,7 +44,6 @@ public class QuickSearchSessionsFunction return tooMany; } - var tenantId = requestCtx.TenantId; var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); var q = query["q"]?.Trim(); @@ -53,6 +52,9 @@ public class QuickSearchSessionsFunction return await req.BadRequestAsync("Query parameter 'q' must be at least 2 characters."); } + // Global Admins search across all tenants; regular users are scoped to their own tenant + string? tenantId = requestCtx.IsGlobalAdmin ? null : requestCtx.TenantId; + var results = await _sessionRepo.QuickSearchSessionsAsync(tenantId, q, limit: 10); return await req.OkAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs index 9fccbcdf..53bc705b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs @@ -230,19 +230,21 @@ namespace AutopilotMonitor.Functions.Services // ===== SEARCH METHODS ===== /// - /// Lightweight typeahead search: matches SessionId, SerialNumber, or DeviceName - /// using a prefix scan on the SessionsIndex table. Returns at most results. + /// Lightweight typeahead search: matches SessionId, SerialNumber, or DeviceName. + /// Supports exact contains match (priority) and fuzzy match (edit distance <= 2). + /// When tenantId is null, searches across all tenants (Global Admin). /// - public async Task> QuickSearchSessionsAsync(string tenantId, string query, int limit = 10) + public async Task> QuickSearchSessionsAsync(string? tenantId, string query, int limit = 10) { var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex); - var results = new List(); + var exactResults = new List(); + var fuzzyResults = new List(); var q = query.Trim(); - var qLower = q.ToLowerInvariant(); - // Scan all sessions for this tenant (newest first via index). - // Table Storage doesn't support substring/contains, so we do client-side matching. - var filter = $"PartitionKey eq '{tenantId}'"; + // Tenant-scoped or cross-tenant scan + string? filter = !string.IsNullOrEmpty(tenantId) + ? $"PartitionKey eq '{tenantId}'" + : null; await foreach (var entity in indexTableClient.QueryAsync(filter: filter)) { @@ -250,8 +252,8 @@ namespace AutopilotMonitor.Functions.Services var serial = entity.GetString("SerialNumber") ?? string.Empty; var deviceName = entity.GetString("DeviceName") ?? string.Empty; + // Phase 1: exact substring match (highest priority) string? matchedField = null; - if (sessionId.Contains(q, StringComparison.OrdinalIgnoreCase)) matchedField = "sessionId"; else if (serial.Contains(q, StringComparison.OrdinalIgnoreCase)) @@ -259,27 +261,101 @@ namespace AutopilotMonitor.Functions.Services else if (deviceName.Contains(q, StringComparison.OrdinalIgnoreCase)) matchedField = "deviceName"; - if (matchedField == null) + if (matchedField != null) + { + exactResults.Add(BuildQuickSearchResult(entity, sessionId, serial, deviceName, matchedField)); + if (exactResults.Count >= limit) break; + continue; + } + + // Phase 2: fuzzy match — only if we don't have enough exact results yet + if (exactResults.Count + fuzzyResults.Count >= limit) continue; - var statusString = entity.GetString("Status") ?? "InProgress"; - if (!Enum.TryParse(statusString, ignoreCase: true, out var status)) - status = SessionStatus.Unknown; + if (FuzzyContains(serial, q, maxDistance: 2)) + matchedField = "serialNumber"; + else if (FuzzyContains(deviceName, q, maxDistance: 2)) + matchedField = "deviceName"; - results.Add(new QuickSearchResult + if (matchedField != null) { - SessionId = sessionId, - SerialNumber = serial, - DeviceName = deviceName, - Status = status, - StartedAt = SafeGetDateTime(entity, "StartedAt") ?? DateTime.UtcNow, - MatchedField = matchedField, - }); - - if (results.Count >= limit) break; + fuzzyResults.Add(BuildQuickSearchResult(entity, sessionId, serial, deviceName, matchedField)); + } } - return results; + // Exact matches first, then fuzzy, capped to limit + exactResults.AddRange(fuzzyResults); + return exactResults.Count > limit ? exactResults.GetRange(0, limit) : exactResults; + } + + private QuickSearchResult BuildQuickSearchResult( + TableEntity entity, string sessionId, string serial, string deviceName, string matchedField) + { + var statusString = entity.GetString("Status") ?? "InProgress"; + if (!Enum.TryParse(statusString, ignoreCase: true, out var status)) + status = SessionStatus.Unknown; + + return new QuickSearchResult + { + SessionId = sessionId, + SerialNumber = serial, + DeviceName = deviceName, + Status = status, + StartedAt = SafeGetDateTime(entity, "StartedAt") ?? DateTime.UtcNow, + MatchedField = matchedField, + }; + } + + /// + /// Checks if any substring of of length .Length + /// is within edit distance of the needle (case-insensitive). + /// Uses a sliding-window Levenshtein approach for efficiency. + /// + private static bool FuzzyContains(string haystack, string needle, int maxDistance) + { + if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(needle)) + return false; + + var h = haystack.ToLowerInvariant(); + var n = needle.ToLowerInvariant(); + var nLen = n.Length; + + if (nLen > h.Length + maxDistance) + return false; + + // Classic Levenshtein with early-termination: compute full distance between needle + // and every substring of haystack, but bail out once we find a match. + // We use a single-row DP optimisation. + var prev = new int[nLen + 1]; + var curr = new int[nLen + 1]; + + for (int j = 0; j <= nLen; j++) + prev[j] = j; + + for (int i = 1; i <= h.Length; i++) + { + curr[0] = 0; // Allow matching to start at any position in haystack + var minInRow = int.MaxValue; + + for (int j = 1; j <= nLen; j++) + { + var cost = h[i - 1] == n[j - 1] ? 0 : 1; + curr[j] = Math.Min( + Math.Min(curr[j - 1] + 1, prev[j] + 1), + prev[j - 1] + cost); + minInRow = Math.Min(minInRow, curr[j]); + } + + // If the best possible score in this row already exceeds threshold, this row contributes nothing + // but we can't prune early because curr[0]=0 resets at each position. + + if (curr[nLen] <= maxDistance) + return true; + + (prev, curr) = (curr, prev); + } + + return false; } /// diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs index 5309e8e2..09e43360 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs @@ -42,7 +42,7 @@ namespace AutopilotMonitor.Shared.DataAccess Task> GetSessionEventsAsync(string tenantId, string sessionId, int maxResults = 1000); // --- Search --- - Task> QuickSearchSessionsAsync(string tenantId, string query, int limit = 10); + Task> QuickSearchSessionsAsync(string? tenantId, string query, int limit = 10); Task> SearchSessionsAsync(string? tenantId, SessionSearchFilter filter); Task> SearchSessionsByEventAsync( string? tenantId, string eventType, string? source, string? severity, diff --git a/src/Web/autopilot-monitor-web/components/GlobalSearch.tsx b/src/Web/autopilot-monitor-web/components/GlobalSearch.tsx index 9a049542..fd801d3c 100644 --- a/src/Web/autopilot-monitor-web/components/GlobalSearch.tsx +++ b/src/Web/autopilot-monitor-web/components/GlobalSearch.tsx @@ -214,7 +214,7 @@ export default function GlobalSearch() { return ( <> {/* Desktop: inline search field (visible md+) */} -
+
diff --git a/src/Web/autopilot-monitor-web/components/Navbar.tsx b/src/Web/autopilot-monitor-web/components/Navbar.tsx index 3cfcbee2..2a98f39c 100644 --- a/src/Web/autopilot-monitor-web/components/Navbar.tsx +++ b/src/Web/autopilot-monitor-web/components/Navbar.tsx @@ -229,11 +229,11 @@ export default function Navbar() {
- {/* Global Search */} - + {/* Right side — Search + Dark Mode, Notifications, Settings, Overflow, User */} +
+ {/* Global Search */} + - {/* Right side — Tier 1: Dark Mode, Notifications, Settings, Overflow, User */} -
{/* Dark Mode Toggle — hidden on