mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
layout fixes and fuzzy search
This commit is contained in:
@@ -1 +1 @@
|
||||
632
|
||||
633
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<List<EnrollmentEvent>> GetSessionEventsAsync(string tenantId, string sessionId, int maxResults = 1000)
|
||||
=> _storage.GetSessionEventsAsync(tenantId, sessionId, maxResults);
|
||||
|
||||
public Task<List<QuickSearchResult>> QuickSearchSessionsAsync(string tenantId, string query, int limit = 10)
|
||||
public Task<List<QuickSearchResult>> QuickSearchSessionsAsync(string? tenantId, string query, int limit = 10)
|
||||
=> _storage.QuickSearchSessionsAsync(tenantId, query, limit);
|
||||
|
||||
public Task<List<SessionSummary>> SearchSessionsAsync(string? tenantId, SessionSearchFilter filter)
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -230,19 +230,21 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// ===== SEARCH METHODS =====
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight typeahead search: matches SessionId, SerialNumber, or DeviceName
|
||||
/// using a prefix scan on the SessionsIndex table. Returns at most <paramref name="limit"/> 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).
|
||||
/// </summary>
|
||||
public async Task<List<QuickSearchResult>> QuickSearchSessionsAsync(string tenantId, string query, int limit = 10)
|
||||
public async Task<List<QuickSearchResult>> QuickSearchSessionsAsync(string? tenantId, string query, int limit = 10)
|
||||
{
|
||||
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
||||
var results = new List<QuickSearchResult>();
|
||||
var exactResults = new List<QuickSearchResult>();
|
||||
var fuzzyResults = new List<QuickSearchResult>();
|
||||
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<TableEntity>(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<SessionStatus>(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<SessionStatus>(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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if any substring of <paramref name="haystack"/> of length <paramref name="needle"/>.Length
|
||||
/// is within edit distance <paramref name="maxDistance"/> of the needle (case-insensitive).
|
||||
/// Uses a sliding-window Levenshtein approach for efficiency.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
Task<List<EnrollmentEvent>> GetSessionEventsAsync(string tenantId, string sessionId, int maxResults = 1000);
|
||||
|
||||
// --- Search ---
|
||||
Task<List<QuickSearchResult>> QuickSearchSessionsAsync(string tenantId, string query, int limit = 10);
|
||||
Task<List<QuickSearchResult>> QuickSearchSessionsAsync(string? tenantId, string query, int limit = 10);
|
||||
Task<List<SessionSummary>> SearchSessionsAsync(string? tenantId, SessionSearchFilter filter);
|
||||
Task<List<SessionSummary>> SearchSessionsByEventAsync(
|
||||
string? tenantId, string eventType, string? source, string? severity,
|
||||
|
||||
@@ -214,7 +214,7 @@ export default function GlobalSearch() {
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: inline search field (visible md+) */}
|
||||
<div ref={containerRef} className="hidden md:flex relative flex-1 max-w-md mx-4">
|
||||
<div ref={containerRef} className="hidden md:flex relative flex-1 max-w-md mr-1">
|
||||
<div className="relative w-full">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<SearchIcon />
|
||||
|
||||
@@ -229,11 +229,11 @@ export default function Navbar() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Global Search */}
|
||||
<GlobalSearch />
|
||||
{/* Right side — Search + Dark Mode, Notifications, Settings, Overflow, User */}
|
||||
<div className="flex items-center justify-end flex-1 space-x-1">
|
||||
{/* Global Search */}
|
||||
<GlobalSearch />
|
||||
|
||||
{/* Right side — Tier 1: Dark Mode, Notifications, Settings, Overflow, User */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* Dark Mode Toggle — hidden on <sm, moved to overflow */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user