From 9870876402caa632da8177b4ca9382f8d4c41f4b Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Tue, 10 Jun 2025 18:31:15 -0700 Subject: [PATCH 01/11] WIP on adding filter handling. --- .../Components/Dashboard.razor.cs | 7 + ControlR.Web.Server/Api/DevicesController.cs | 67 ++----- .../Extensions/DeviceQueryExtensions.cs | 166 ++++++++++++++++++ ControlR.Web.Server/Program.cs | 4 +- .../Dtos/ServerApi/DeviceColumnFilter.cs | 23 +++ .../Dtos/ServerApi/DeviceColumnSort.cs | 8 + .../Dtos/ServerApi/DeviceSearchRequestDto.cs | 19 +- 7 files changed, 229 insertions(+), 65 deletions(-) create mode 100644 ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs create mode 100644 Libraries/ControlR.Libraries.Shared/Dtos/ServerApi/DeviceColumnFilter.cs create mode 100644 Libraries/ControlR.Libraries.Shared/Dtos/ServerApi/DeviceColumnSort.cs diff --git a/ControlR.Web.Client/Components/Dashboard.razor.cs b/ControlR.Web.Client/Components/Dashboard.razor.cs index 4bdabef7..48106a0b 100644 --- a/ControlR.Web.Client/Components/Dashboard.razor.cs +++ b/ControlR.Web.Client/Components/Dashboard.razor.cs @@ -135,6 +135,13 @@ private async Task> LoadServerData(GridState new DeviceColumnFilter + { + PropertyName = fd.Column?.PropertyName, + Operator = fd.Operator, + Value = fd.Value?.ToString() })] }; diff --git a/ControlR.Web.Server/Api/DevicesController.cs b/ControlR.Web.Server/Api/DevicesController.cs index 9835badf..59cb689d 100644 --- a/ControlR.Web.Server/Api/DevicesController.cs +++ b/ControlR.Web.Server/Api/DevicesController.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using Microsoft.AspNetCore.Mvc; +using MudBlazor; namespace ControlR.Web.Server.Api; @@ -113,6 +114,7 @@ public async Task> SearchDevices( [FromServices] IAuthorizationService authorizationService, [FromServices] ILogger logger) { + var isRelationalDatabase = appDb.Database.IsRelational(); // Start with all devices var anyDevices = await appDb.Devices.AnyAsync(); var query = appDb.Devices @@ -122,59 +124,22 @@ public async Task> SearchDevices( .AsQueryable(); // Apply filtering - if (!string.IsNullOrWhiteSpace(requestDto.SearchText)) - { - var searchText = requestDto.SearchText; - - if (appDb.Database.IsInMemory()) - { - query = query.Where(d => - d.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase) || - d.Alias.Contains(searchText, StringComparison.OrdinalIgnoreCase) || - d.OsDescription.Contains(searchText, StringComparison.OrdinalIgnoreCase) || - d.ConnectionId.Contains(searchText, StringComparison.OrdinalIgnoreCase) || - string.Join("", d.CurrentUsers).Contains(searchText, StringComparison.OrdinalIgnoreCase)); - } - else - { - // Use EF.Functions.Like for the database-searchable fields - query = query.Where(d => - EF.Functions.ILike(d.Name ?? "", $"%{searchText}%") || - EF.Functions.ILike(d.Alias ?? "", $"%{searchText}%") || - EF.Functions.ILike(d.OsDescription ?? "", $"%{searchText}%") || - EF.Functions.ILike(d.ConnectionId ?? "", $"%{searchText}%") || - EF.Functions.ILike(string.Join("", d.CurrentUsers) ?? "", $"%{searchText}%")); - } - } + query = query + .FilterBySearchText(requestDto.SearchText, isRelationalDatabase) + .FilterByOnlineOffline(requestDto.HideOfflineDevices) + .FilterByColumnFilters(requestDto.FilterDefinitions, isRelationalDatabase, logger); - if (requestDto.HideOfflineDevices) - { - query = query.Where(d => d.IsOnline); - } + query = await query.FilterByTagIds(requestDto.TagIds, appDb); - // Handle tag filtering - if (requestDto.TagIds != null && requestDto.TagIds.Count > 0) + if (query is null) { - // Find devices through the many-to-many relationship - var deviceIds = await appDb.Devices - .Where(d => d.Tags!.Any(t => requestDto.TagIds.Contains(t.Id))) - .Select(d => d.Id) - .ToListAsync(); - - if (deviceIds.Count != 0) + // No matching devices found + return new DeviceSearchResponseDto { - query = query.Where(d => deviceIds.Contains(d.Id)); - } - else - { - // No matching devices found - return new DeviceSearchResponseDto - { - Items = [], - TotalItems = 0, - AnyDevicesForUser = anyDevices - }; - } + Items = [], + TotalItems = 0, + AnyDevicesForUser = anyDevices + }; } // Apply sorting @@ -187,11 +152,11 @@ public async Task> SearchDevices( [nameof(DeviceDto.UsedStoragePercent)] = d => d.UsedStoragePercent }; - if (requestDto.SortDefinitions != null && requestDto.SortDefinitions.Count > 0) + if (requestDto.SortDefinitions is { Count: > 0} sortDefs) { IOrderedQueryable? orderedQuery = null; - foreach (var sortDef in requestDto.SortDefinitions.OrderBy(s => s.SortOrder)) + foreach (var sortDef in sortDefs.OrderBy(s => s.SortOrder)) { if (string.IsNullOrWhiteSpace(sortDef.PropertyName) || !sortExpressions.TryGetValue(sortDef.PropertyName, out var expr)) diff --git a/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs b/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs new file mode 100644 index 00000000..6043e860 --- /dev/null +++ b/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs @@ -0,0 +1,166 @@ +using MudBlazor; + +namespace ControlR.Web.Server.Extensions; + +public static class DeviceQueryExtensions +{ + public static IQueryable FilterBySearchText( + this IQueryable query, + string? searchText, + bool isRelationalDatabase) + { + if (string.IsNullOrWhiteSpace(searchText)) + { + return query; + } + + if (isRelationalDatabase) + { + return query.Where(d => + EF.Functions.ILike(d.Name ?? "", $"%{searchText}%") || + EF.Functions.ILike(d.Alias ?? "", $"%{searchText}%") || + EF.Functions.ILike(d.OsDescription ?? "", $"%{searchText}%") || + EF.Functions.ILike(d.ConnectionId ?? "", $"%{searchText}%") || + EF.Functions.ILike(string.Join("", d.CurrentUsers) ?? "", $"%{searchText}%")); + } + + return query.Where(d => + d.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + d.Alias.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + d.OsDescription.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + d.ConnectionId.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + string.Join("", d.CurrentUsers).Contains(searchText, StringComparison.OrdinalIgnoreCase)); + } + + public static IQueryable FilterByOnlineOffline( + this IQueryable query, + bool hideOfflineDevices) + { + if (hideOfflineDevices) + { + return query.Where(d => d.IsOnline); + } + return query; + } + + public static async Task?> FilterByTagIds( + this IQueryable query, + List? tagIds, + AppDb appDb) + { + if (tagIds is not { Count: > 0 } tags) + { + return query; + } + + // Find devices through the many-to-many relationship + var deviceIds = await appDb.Devices + .Where(d => d.Tags!.Any(t => tagIds.Contains(t.Id))) + .Select(d => d.Id) + .ToListAsync(); + + if (deviceIds.Count > 0) + { + return query.Where(d => deviceIds.Contains(d.Id)); + } + + return null; + } + + public static IQueryable FilterByColumnFilters( + this IQueryable query, + List? filterDefinitions, + bool isRelationalDatabase, + ILogger logger) + { + if (filterDefinitions is not { Count: > 0 }) + { + return query; + } + + foreach (var filter in filterDefinitions) + { + if (!filter.Validate()) + { + logger.LogError("Invalid column filter definition: {@Filter}", filter); + continue; + } + + switch (filter.PropertyName) + { + case nameof(Device.Name): + query = query.FilterByStringColumn(filter.Operator, filter.Value, isRelationalDatabase); + break; + case nameof(Device.IsOnline): + break; + case nameof(Device.CpuUtilization): + break; + case nameof(Device.UsedMemoryPercent): + break; + case nameof(Device.UsedStoragePercent): + break; + default: + logger.LogError("Unhandled filter property: {PropertyName}", filter.PropertyName); + break; + } + } + return query; + } + + private static IQueryable FilterByStringColumn( + this IQueryable query, + string filterOperator, + string filterValue, + bool isRelationalDatabase) + { + if (isRelationalDatabase) + { + return filterOperator switch + { + FilterOperator.String.Contains => + query.Where(d => EF.Functions.ILike(d.Name, $"%{filterValue}%")), + FilterOperator.String.Empty => + query.Where(d => string.IsNullOrWhiteSpace(d.Name)), + FilterOperator.String.EndsWith => + query.Where(d => EF.Functions.ILike(d.Name, $"%{filterValue}")), + FilterOperator.String.Equal => + query.Where(d => EF.Functions.ILike(d.Name, filterValue)), + FilterOperator.String.NotContains => + query.Where(d => !EF.Functions.ILike(d.Name, $"%{filterValue}%")), + FilterOperator.String.NotEmpty => + query.Where(d => !string.IsNullOrWhiteSpace(d.Name)), + FilterOperator.String.NotEqual => + query.Where(d => !EF.Functions.ILike(d.Name, filterValue)), + FilterOperator.String.StartsWith => + query.Where(d => EF.Functions.ILike(d.Name, $"{filterValue}%")), + _ => + throw new ArgumentOutOfRangeException( + nameof(filterOperator), $"Unsupported filter operator: {filterOperator}"), + }; + } + else + { + return filterOperator switch + { + FilterOperator.String.Contains => + query.Where(d => d.Name.Contains(filterValue, StringComparison.OrdinalIgnoreCase)), + FilterOperator.String.Empty => + query.Where(d => string.IsNullOrWhiteSpace(d.Name)), + FilterOperator.String.EndsWith => + query.Where(d => d.Name.EndsWith(filterValue, StringComparison.OrdinalIgnoreCase)), + FilterOperator.String.Equal => + query.Where(d => d.Name.Equals(filterValue, StringComparison.OrdinalIgnoreCase)), + FilterOperator.String.NotContains => + query.Where(d => !d.Name.Contains(filterValue, StringComparison.OrdinalIgnoreCase)), + FilterOperator.String.NotEmpty => + query.Where(d => !string.IsNullOrWhiteSpace(d.Name)), + FilterOperator.String.NotEqual => + query.Where(d => !d.Name.Equals(filterValue, StringComparison.OrdinalIgnoreCase)), + FilterOperator.String.StartsWith => + query.Where(d => d.Name.StartsWith(filterValue, StringComparison.OrdinalIgnoreCase)), + _ => throw new ArgumentOutOfRangeException( + nameof(filterOperator), $"Unsupported filter operator: {filterOperator}"), + }; + } + } +} diff --git a/ControlR.Web.Server/Program.cs b/ControlR.Web.Server/Program.cs index a8a09f5e..380e9461 100644 --- a/ControlR.Web.Server/Program.cs +++ b/ControlR.Web.Server/Program.cs @@ -1,10 +1,10 @@ using ControlR.Libraries.WebSocketRelay.Common.Extensions; +using ControlR.Web.Client.Components.Layout; using ControlR.Web.Server.Components; using ControlR.Web.Server.Components.Account; using ControlR.Web.Server.Middleware; using ControlR.Web.Server.Startup; using ControlR.Web.ServiceDefaults; -using _Imports = ControlR.Web.Client._Imports; var builder = WebApplication.CreateBuilder(args); @@ -64,7 +64,7 @@ { app.MapRazorComponents() .AddInteractiveWebAssemblyRenderMode() - .AddAdditionalAssemblies(typeof(_Imports).Assembly); + .AddAdditionalAssemblies(typeof(MainLayout).Assembly); }); app.MapAdditionalIdentityEndpoints(); diff --git a/Libraries/ControlR.Libraries.Shared/Dtos/ServerApi/DeviceColumnFilter.cs b/Libraries/ControlR.Libraries.Shared/Dtos/ServerApi/DeviceColumnFilter.cs new file mode 100644 index 00000000..8c9b4566 --- /dev/null +++ b/Libraries/ControlR.Libraries.Shared/Dtos/ServerApi/DeviceColumnFilter.cs @@ -0,0 +1,23 @@ + +using System.Diagnostics.CodeAnalysis; + +namespace ControlR.Libraries.Shared.Dtos.ServerApi; + +public class DeviceColumnFilter +{ + public string? PropertyName { get; set; } + public string? Operator { get; set; } + public string? Value { get; set; } + + [MemberNotNullWhen(true, nameof(PropertyName), nameof(Operator), nameof(Value))] + public bool Validate() + { + if (string.IsNullOrWhiteSpace(PropertyName) || + string.IsNullOrWhiteSpace(Operator) || + string.IsNullOrWhiteSpace(Value)) + { + return false; + } + return true; + } +} \ No newline at end of file diff --git a/Libraries/ControlR.Libraries.Shared/Dtos/ServerApi/DeviceColumnSort.cs b/Libraries/ControlR.Libraries.Shared/Dtos/ServerApi/DeviceColumnSort.cs new file mode 100644 index 00000000..0d8601e8 --- /dev/null +++ b/Libraries/ControlR.Libraries.Shared/Dtos/ServerApi/DeviceColumnSort.cs @@ -0,0 +1,8 @@ +namespace ControlR.Libraries.Shared.Dtos.ServerApi; + +public class DeviceColumnSort +{ + public string? PropertyName { get; set; } + public bool Descending { get; set; } + public int SortOrder { get; set; } +} \ No newline at end of file diff --git a/Libraries/ControlR.Libraries.Shared/Dtos/ServerApi/DeviceSearchRequestDto.cs b/Libraries/ControlR.Libraries.Shared/Dtos/ServerApi/DeviceSearchRequestDto.cs index c33469a7..96be8ac9 100644 --- a/Libraries/ControlR.Libraries.Shared/Dtos/ServerApi/DeviceSearchRequestDto.cs +++ b/Libraries/ControlR.Libraries.Shared/Dtos/ServerApi/DeviceSearchRequestDto.cs @@ -2,17 +2,12 @@ namespace ControlR.Libraries.Shared.Dtos.ServerApi; public class DeviceSearchRequestDto { - public string? SearchText { get; set; } - public bool HideOfflineDevices { get; set; } - public List? TagIds { get; set; } - public int Page { get; set; } - public int PageSize { get; set; } - public List? SortDefinitions { get; set; } + public List? FilterDefinitions { get; set; } + public bool HideOfflineDevices { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + public string? SearchText { get; set; } + public List? SortDefinitions { get; set; } + public List? TagIds { get; set; } } -public class DeviceColumnSort -{ - public string? PropertyName { get; set; } - public bool Descending { get; set; } - public int SortOrder { get; set; } -} \ No newline at end of file From f7224c544834a66d5dabb27f09f7b4e05b54fc40 Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Tue, 10 Jun 2025 18:49:58 -0700 Subject: [PATCH 02/11] Add reusable filter method for string properties. --- .../Extensions/DeviceQueryExtensions.cs | 72 ++++++++++++++----- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs b/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs index 6043e860..6461f1bb 100644 --- a/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs +++ b/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs @@ -1,4 +1,5 @@ using MudBlazor; +using System.Linq.Expressions; namespace ControlR.Web.Server.Extensions; @@ -85,11 +86,19 @@ public static IQueryable FilterByColumnFilters( logger.LogError("Invalid column filter definition: {@Filter}", filter); continue; } - switch (filter.PropertyName) { case nameof(Device.Name): - query = query.FilterByStringColumn(filter.Operator, filter.Value, isRelationalDatabase); + query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.Name, isRelationalDatabase); + break; + case nameof(Device.Alias): + query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.Alias, isRelationalDatabase); + break; + case nameof(Device.OsDescription): + query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.OsDescription, isRelationalDatabase); + break; + case nameof(Device.ConnectionId): + query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.ConnectionId, isRelationalDatabase); break; case nameof(Device.IsOnline): break; @@ -111,6 +120,7 @@ private static IQueryable FilterByStringColumn( this IQueryable query, string filterOperator, string filterValue, + Expression> propertySelector, bool isRelationalDatabase) { if (isRelationalDatabase) @@ -118,21 +128,21 @@ private static IQueryable FilterByStringColumn( return filterOperator switch { FilterOperator.String.Contains => - query.Where(d => EF.Functions.ILike(d.Name, $"%{filterValue}%")), + query.Where(BuildStringExpression(propertySelector, p => EF.Functions.ILike(p!, $"%{filterValue}%"))), FilterOperator.String.Empty => - query.Where(d => string.IsNullOrWhiteSpace(d.Name)), + query.Where(BuildStringExpression(propertySelector, p => string.IsNullOrWhiteSpace(p))), FilterOperator.String.EndsWith => - query.Where(d => EF.Functions.ILike(d.Name, $"%{filterValue}")), + query.Where(BuildStringExpression(propertySelector, p => EF.Functions.ILike(p!, $"%{filterValue}"))), FilterOperator.String.Equal => - query.Where(d => EF.Functions.ILike(d.Name, filterValue)), + query.Where(BuildStringExpression(propertySelector, p => EF.Functions.ILike(p!, filterValue))), FilterOperator.String.NotContains => - query.Where(d => !EF.Functions.ILike(d.Name, $"%{filterValue}%")), + query.Where(BuildStringExpression(propertySelector, p => !EF.Functions.ILike(p!, $"%{filterValue}%"))), FilterOperator.String.NotEmpty => - query.Where(d => !string.IsNullOrWhiteSpace(d.Name)), + query.Where(BuildStringExpression(propertySelector, p => !string.IsNullOrWhiteSpace(p))), FilterOperator.String.NotEqual => - query.Where(d => !EF.Functions.ILike(d.Name, filterValue)), + query.Where(BuildStringExpression(propertySelector, p => !EF.Functions.ILike(p!, filterValue))), FilterOperator.String.StartsWith => - query.Where(d => EF.Functions.ILike(d.Name, $"{filterValue}%")), + query.Where(BuildStringExpression(propertySelector, p => EF.Functions.ILike(p!, $"{filterValue}%"))), _ => throw new ArgumentOutOfRangeException( nameof(filterOperator), $"Unsupported filter operator: {filterOperator}"), @@ -143,24 +153,50 @@ private static IQueryable FilterByStringColumn( return filterOperator switch { FilterOperator.String.Contains => - query.Where(d => d.Name.Contains(filterValue, StringComparison.OrdinalIgnoreCase)), + query.Where(BuildStringExpression(propertySelector, p => p!.Contains(filterValue, StringComparison.OrdinalIgnoreCase))), FilterOperator.String.Empty => - query.Where(d => string.IsNullOrWhiteSpace(d.Name)), + query.Where(BuildStringExpression(propertySelector, p => string.IsNullOrWhiteSpace(p))), FilterOperator.String.EndsWith => - query.Where(d => d.Name.EndsWith(filterValue, StringComparison.OrdinalIgnoreCase)), + query.Where(BuildStringExpression(propertySelector, p => p!.EndsWith(filterValue, StringComparison.OrdinalIgnoreCase))), FilterOperator.String.Equal => - query.Where(d => d.Name.Equals(filterValue, StringComparison.OrdinalIgnoreCase)), + query.Where(BuildStringExpression(propertySelector, p => p!.Equals(filterValue, StringComparison.OrdinalIgnoreCase))), FilterOperator.String.NotContains => - query.Where(d => !d.Name.Contains(filterValue, StringComparison.OrdinalIgnoreCase)), + query.Where(BuildStringExpression(propertySelector, p => !p!.Contains(filterValue, StringComparison.OrdinalIgnoreCase))), FilterOperator.String.NotEmpty => - query.Where(d => !string.IsNullOrWhiteSpace(d.Name)), + query.Where(BuildStringExpression(propertySelector, p => !string.IsNullOrWhiteSpace(p))), FilterOperator.String.NotEqual => - query.Where(d => !d.Name.Equals(filterValue, StringComparison.OrdinalIgnoreCase)), + query.Where(BuildStringExpression(propertySelector, p => !p!.Equals(filterValue, StringComparison.OrdinalIgnoreCase))), FilterOperator.String.StartsWith => - query.Where(d => d.Name.StartsWith(filterValue, StringComparison.OrdinalIgnoreCase)), + query.Where(BuildStringExpression(propertySelector, p => p!.StartsWith(filterValue, StringComparison.OrdinalIgnoreCase))), _ => throw new ArgumentOutOfRangeException( nameof(filterOperator), $"Unsupported filter operator: {filterOperator}"), }; } } + + private static Expression> BuildStringExpression( + Expression> propertySelector, + Expression> condition) + { + var parameter = propertySelector.Parameters[0]; + var propertyExpression = propertySelector.Body; + var conditionBody = condition.Body; + + // Replace the parameter in the condition with the property expression + var visitor = new ParameterReplacerVisitor(condition.Parameters[0], propertyExpression); + var replacedCondition = visitor.Visit(conditionBody); + + return Expression.Lambda>(replacedCondition!, parameter); + } + + private class ParameterReplacerVisitor(ParameterExpression oldParameter, Expression newExpression) : ExpressionVisitor + { + private readonly ParameterExpression _oldParameter = oldParameter; + private readonly Expression _newExpression = newExpression; + + protected override Expression VisitParameter(ParameterExpression node) + { + return node == _oldParameter ? _newExpression : base.VisitParameter(node); + } + } } From 7ef1825ac2c6d27bb339cdb6dbba0dec3724899b Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Tue, 10 Jun 2025 19:07:25 -0700 Subject: [PATCH 03/11] Add a boolean query filter. --- .../Extensions/DeviceQueryExtensions.cs | 175 +++++++++++------- 1 file changed, 107 insertions(+), 68 deletions(-) diff --git a/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs b/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs index 6461f1bb..f98e827c 100644 --- a/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs +++ b/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs @@ -1,10 +1,70 @@ -using MudBlazor; -using System.Linq.Expressions; +using System.Linq.Expressions; +using MudBlazor; namespace ControlR.Web.Server.Extensions; public static class DeviceQueryExtensions { + + public static IQueryable FilterByColumnFilters( + this IQueryable query, + List? filterDefinitions, + bool isRelationalDatabase, + ILogger logger) + { + if (filterDefinitions is not { Count: > 0 }) + { + return query; + } + + foreach (var filter in filterDefinitions) + { + if (!filter.Validate()) + { + logger.LogError("Invalid column filter definition: {@Filter}", filter); + continue; + } + switch (filter.PropertyName) + { + case nameof(Device.Name): + query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.Name, isRelationalDatabase); + break; + case nameof(Device.Alias): + query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.Alias, isRelationalDatabase); + break; + case nameof(Device.OsDescription): + query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.OsDescription, isRelationalDatabase); + break; + case nameof(Device.ConnectionId): + query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.ConnectionId, isRelationalDatabase); + break; + case nameof(Device.IsOnline): + query = query.FilterByBooleanColumn(filter.Operator, filter.Value, d => d.IsOnline); + break; + case nameof(Device.CpuUtilization): + break; + case nameof(Device.UsedMemoryPercent): + break; + case nameof(Device.UsedStoragePercent): + break; + default: + logger.LogError("Unhandled filter property: {PropertyName}", filter.PropertyName); + break; + } + } + return query; + } + + public static IQueryable FilterByOnlineOffline( + this IQueryable query, + bool hideOfflineDevices) + { + if (hideOfflineDevices) + { + return query.Where(d => d.IsOnline); + } + return query; + } public static IQueryable FilterBySearchText( this IQueryable query, string? searchText, @@ -33,17 +93,6 @@ public static IQueryable FilterBySearchText( string.Join("", d.CurrentUsers).Contains(searchText, StringComparison.OrdinalIgnoreCase)); } - public static IQueryable FilterByOnlineOffline( - this IQueryable query, - bool hideOfflineDevices) - { - if (hideOfflineDevices) - { - return query.Where(d => d.IsOnline); - } - return query; - } - public static async Task?> FilterByTagIds( this IQueryable query, List? tagIds, @@ -68,52 +117,57 @@ public static IQueryable FilterByOnlineOffline( return null; } - public static IQueryable FilterByColumnFilters( + private static Expression> BuildBooleanExpression( + Expression> propertySelector, + bool expectedValue) + { + var parameter = propertySelector.Parameters[0]; + var propertyExpression = propertySelector.Body; + + // Create the comparison expression + var comparisonExpression = expectedValue + ? propertyExpression + : Expression.Not(propertyExpression); + + return Expression.Lambda>(comparisonExpression, parameter); + } + + private static Expression> BuildStringExpression( + Expression> propertySelector, + Expression> condition) + { + var parameter = propertySelector.Parameters[0]; + var propertyExpression = propertySelector.Body; + var conditionBody = condition.Body; + + // Replace the parameter in the condition with the property expression + var visitor = new ParameterReplacerVisitor(condition.Parameters[0], propertyExpression); + var replacedCondition = visitor.Visit(conditionBody); + + return Expression.Lambda>(replacedCondition!, parameter); + } + + private static IQueryable FilterByBooleanColumn( this IQueryable query, - List? filterDefinitions, - bool isRelationalDatabase, - ILogger logger) + string filterOperator, + string filterValue, + Expression> propertySelector) { - if (filterDefinitions is not { Count: > 0 }) + // Parse the filter value to boolean + if (!bool.TryParse(filterValue, out var boolValue)) { + // If not a valid boolean, return query unchanged return query; } - foreach (var filter in filterDefinitions) + return filterOperator switch { - if (!filter.Validate()) - { - logger.LogError("Invalid column filter definition: {@Filter}", filter); - continue; - } - switch (filter.PropertyName) - { - case nameof(Device.Name): - query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.Name, isRelationalDatabase); - break; - case nameof(Device.Alias): - query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.Alias, isRelationalDatabase); - break; - case nameof(Device.OsDescription): - query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.OsDescription, isRelationalDatabase); - break; - case nameof(Device.ConnectionId): - query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.ConnectionId, isRelationalDatabase); - break; - case nameof(Device.IsOnline): - break; - case nameof(Device.CpuUtilization): - break; - case nameof(Device.UsedMemoryPercent): - break; - case nameof(Device.UsedStoragePercent): - break; - default: - logger.LogError("Unhandled filter property: {PropertyName}", filter.PropertyName); - break; - } - } - return query; + // Handle MudBlazor boolean filter operators + FilterOperator.Boolean.Is => + query.Where(BuildBooleanExpression(propertySelector, boolValue)), + _ => throw new ArgumentOutOfRangeException( + nameof(filterOperator), $"Unsupported filter operator: {filterOperator}"), + }; } private static IQueryable FilterByStringColumn( @@ -174,25 +228,10 @@ private static IQueryable FilterByStringColumn( } } - private static Expression> BuildStringExpression( - Expression> propertySelector, - Expression> condition) - { - var parameter = propertySelector.Parameters[0]; - var propertyExpression = propertySelector.Body; - var conditionBody = condition.Body; - - // Replace the parameter in the condition with the property expression - var visitor = new ParameterReplacerVisitor(condition.Parameters[0], propertyExpression); - var replacedCondition = visitor.Visit(conditionBody); - - return Expression.Lambda>(replacedCondition!, parameter); - } - private class ParameterReplacerVisitor(ParameterExpression oldParameter, Expression newExpression) : ExpressionVisitor { - private readonly ParameterExpression _oldParameter = oldParameter; private readonly Expression _newExpression = newExpression; + private readonly ParameterExpression _oldParameter = oldParameter; protected override Expression VisitParameter(ParameterExpression node) { From 522e79d8fe8fa605d60d6c344dfe1cff98090291 Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Tue, 10 Jun 2025 19:17:51 -0700 Subject: [PATCH 04/11] Add filtering by double properties. --- .../Extensions/DeviceQueryExtensions.cs | 74 ++++++++++++++++--- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs b/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs index f98e827c..b6437df4 100644 --- a/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs +++ b/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs @@ -27,25 +27,28 @@ public static IQueryable FilterByColumnFilters( switch (filter.PropertyName) { case nameof(Device.Name): - query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.Name, isRelationalDatabase); + query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.Name, isRelationalDatabase, logger); break; case nameof(Device.Alias): - query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.Alias, isRelationalDatabase); + query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.Alias, isRelationalDatabase, logger); break; case nameof(Device.OsDescription): - query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.OsDescription, isRelationalDatabase); + query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.OsDescription, isRelationalDatabase, logger); break; case nameof(Device.ConnectionId): - query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.ConnectionId, isRelationalDatabase); + query = query.FilterByStringColumn(filter.Operator, filter.Value, d => d.ConnectionId, isRelationalDatabase, logger); break; case nameof(Device.IsOnline): - query = query.FilterByBooleanColumn(filter.Operator, filter.Value, d => d.IsOnline); + query = query.FilterByBooleanColumn(filter.Operator, filter.Value, d => d.IsOnline, logger); break; case nameof(Device.CpuUtilization): + query = query.FilterByDoubleColumn(filter.Operator, filter.Value, d => d.CpuUtilization, logger); break; case nameof(Device.UsedMemoryPercent): + query = query.FilterByDoubleColumn(filter.Operator, filter.Value, d => d.UsedMemoryPercent, logger); break; case nameof(Device.UsedStoragePercent): + query = query.FilterByDoubleColumn(filter.Operator, filter.Value, d => d.UsedStoragePercent, logger); break; default: logger.LogError("Unhandled filter property: {PropertyName}", filter.PropertyName); @@ -147,11 +150,27 @@ private static Expression> BuildStringExpression( return Expression.Lambda>(replacedCondition!, parameter); } + private static Expression> BuildDoubleExpression( + Expression> propertySelector, + Expression> condition) + { + var parameter = propertySelector.Parameters[0]; + var propertyExpression = propertySelector.Body; + var conditionBody = condition.Body; + + // Replace the parameter in the condition with the property expression + var visitor = new ParameterReplacerVisitor(condition.Parameters[0], propertyExpression); + var replacedCondition = visitor.Visit(conditionBody); + + return Expression.Lambda>(replacedCondition!, parameter); + } + private static IQueryable FilterByBooleanColumn( this IQueryable query, string filterOperator, string filterValue, - Expression> propertySelector) + Expression> propertySelector, + ILogger logger) { // Parse the filter value to boolean if (!bool.TryParse(filterValue, out var boolValue)) @@ -163,8 +182,7 @@ private static IQueryable FilterByBooleanColumn( return filterOperator switch { // Handle MudBlazor boolean filter operators - FilterOperator.Boolean.Is => - query.Where(BuildBooleanExpression(propertySelector, boolValue)), + FilterOperator.Boolean.Is => query.Where(BuildBooleanExpression(propertySelector, boolValue)), _ => throw new ArgumentOutOfRangeException( nameof(filterOperator), $"Unsupported filter operator: {filterOperator}"), }; @@ -175,7 +193,8 @@ private static IQueryable FilterByStringColumn( string filterOperator, string filterValue, Expression> propertySelector, - bool isRelationalDatabase) + bool isRelationalDatabase, + ILogger logger) { if (isRelationalDatabase) { @@ -227,6 +246,43 @@ private static IQueryable FilterByStringColumn( }; } } + private static IQueryable FilterByDoubleColumn( + this IQueryable query, + string filterOperator, + string filterValue, + Expression> propertySelector, + ILogger logger) + { + // Parse the filter value to double + if (!double.TryParse(filterValue, out var doubleValue)) + { + // If not a valid double, return query unchanged + return query; + } + + return filterOperator switch + { + // Handle MudBlazor numeric filter operators + FilterOperator.Number.Equal => + query.Where(BuildDoubleExpression(propertySelector, d => d == doubleValue)), + FilterOperator.Number.NotEqual => + query.Where(BuildDoubleExpression(propertySelector, d => d != doubleValue)), + FilterOperator.Number.GreaterThan => + query.Where(BuildDoubleExpression(propertySelector, d => d > doubleValue)), + FilterOperator.Number.GreaterThanOrEqual => + query.Where(BuildDoubleExpression(propertySelector, d => d >= doubleValue)), + FilterOperator.Number.LessThan => + query.Where(BuildDoubleExpression(propertySelector, d => d < doubleValue)), + FilterOperator.Number.LessThanOrEqual => + query.Where(BuildDoubleExpression(propertySelector, d => d <= doubleValue)), + FilterOperator.Number.Empty => + query.Where(BuildDoubleExpression(propertySelector, d => d == 0)), + FilterOperator.Number.NotEmpty => + query.Where(BuildDoubleExpression(propertySelector, d => d != 0)), + _ => throw new ArgumentOutOfRangeException( + nameof(filterOperator), $"Unsupported filter operator: {filterOperator}"), + }; + } private class ParameterReplacerVisitor(ParameterExpression oldParameter, Expression newExpression) : ExpressionVisitor { From eedc912c0548a47095cfeab66950c93fb1971be8 Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Tue, 10 Jun 2025 19:20:32 -0700 Subject: [PATCH 05/11] Log for invalid filter operators. --- .../Extensions/DeviceQueryExtensions.cs | 146 +++++++++--------- 1 file changed, 72 insertions(+), 74 deletions(-) diff --git a/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs b/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs index b6437df4..21d84e49 100644 --- a/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs +++ b/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs @@ -177,15 +177,15 @@ private static IQueryable FilterByBooleanColumn( { // If not a valid boolean, return query unchanged return query; - } - - return filterOperator switch + } switch (filterOperator) { // Handle MudBlazor boolean filter operators - FilterOperator.Boolean.Is => query.Where(BuildBooleanExpression(propertySelector, boolValue)), - _ => throw new ArgumentOutOfRangeException( - nameof(filterOperator), $"Unsupported filter operator: {filterOperator}"), - }; + case FilterOperator.Boolean.Is: + return query.Where(BuildBooleanExpression(propertySelector, boolValue)); + default: + logger.LogError("Unsupported boolean filter operator: {FilterOperator}", filterOperator); + return query; + } } private static IQueryable FilterByStringColumn( @@ -195,55 +195,54 @@ private static IQueryable FilterByStringColumn( Expression> propertySelector, bool isRelationalDatabase, ILogger logger) - { - if (isRelationalDatabase) + { if (isRelationalDatabase) { - return filterOperator switch + switch (filterOperator) { - FilterOperator.String.Contains => - query.Where(BuildStringExpression(propertySelector, p => EF.Functions.ILike(p!, $"%{filterValue}%"))), - FilterOperator.String.Empty => - query.Where(BuildStringExpression(propertySelector, p => string.IsNullOrWhiteSpace(p))), - FilterOperator.String.EndsWith => - query.Where(BuildStringExpression(propertySelector, p => EF.Functions.ILike(p!, $"%{filterValue}"))), - FilterOperator.String.Equal => - query.Where(BuildStringExpression(propertySelector, p => EF.Functions.ILike(p!, filterValue))), - FilterOperator.String.NotContains => - query.Where(BuildStringExpression(propertySelector, p => !EF.Functions.ILike(p!, $"%{filterValue}%"))), - FilterOperator.String.NotEmpty => - query.Where(BuildStringExpression(propertySelector, p => !string.IsNullOrWhiteSpace(p))), - FilterOperator.String.NotEqual => - query.Where(BuildStringExpression(propertySelector, p => !EF.Functions.ILike(p!, filterValue))), - FilterOperator.String.StartsWith => - query.Where(BuildStringExpression(propertySelector, p => EF.Functions.ILike(p!, $"{filterValue}%"))), - _ => - throw new ArgumentOutOfRangeException( - nameof(filterOperator), $"Unsupported filter operator: {filterOperator}"), - }; - } - else + case FilterOperator.String.Contains: + return query.Where(BuildStringExpression(propertySelector, p => EF.Functions.ILike(p!, $"%{filterValue}%"))); + case FilterOperator.String.Empty: + return query.Where(BuildStringExpression(propertySelector, p => string.IsNullOrWhiteSpace(p))); + case FilterOperator.String.EndsWith: + return query.Where(BuildStringExpression(propertySelector, p => EF.Functions.ILike(p!, $"%{filterValue}"))); + case FilterOperator.String.Equal: + return query.Where(BuildStringExpression(propertySelector, p => EF.Functions.ILike(p!, filterValue))); + case FilterOperator.String.NotContains: + return query.Where(BuildStringExpression(propertySelector, p => !EF.Functions.ILike(p!, $"%{filterValue}%"))); + case FilterOperator.String.NotEmpty: + return query.Where(BuildStringExpression(propertySelector, p => !string.IsNullOrWhiteSpace(p))); + case FilterOperator.String.NotEqual: + return query.Where(BuildStringExpression(propertySelector, p => !EF.Functions.ILike(p!, filterValue))); + case FilterOperator.String.StartsWith: + return query.Where(BuildStringExpression(propertySelector, p => EF.Functions.ILike(p!, $"{filterValue}%"))); + default: + logger.LogError("Unsupported string filter operator for relational database: {FilterOperator}", filterOperator); + return query; + } + } else { - return filterOperator switch + switch (filterOperator) { - FilterOperator.String.Contains => - query.Where(BuildStringExpression(propertySelector, p => p!.Contains(filterValue, StringComparison.OrdinalIgnoreCase))), - FilterOperator.String.Empty => - query.Where(BuildStringExpression(propertySelector, p => string.IsNullOrWhiteSpace(p))), - FilterOperator.String.EndsWith => - query.Where(BuildStringExpression(propertySelector, p => p!.EndsWith(filterValue, StringComparison.OrdinalIgnoreCase))), - FilterOperator.String.Equal => - query.Where(BuildStringExpression(propertySelector, p => p!.Equals(filterValue, StringComparison.OrdinalIgnoreCase))), - FilterOperator.String.NotContains => - query.Where(BuildStringExpression(propertySelector, p => !p!.Contains(filterValue, StringComparison.OrdinalIgnoreCase))), - FilterOperator.String.NotEmpty => - query.Where(BuildStringExpression(propertySelector, p => !string.IsNullOrWhiteSpace(p))), - FilterOperator.String.NotEqual => - query.Where(BuildStringExpression(propertySelector, p => !p!.Equals(filterValue, StringComparison.OrdinalIgnoreCase))), - FilterOperator.String.StartsWith => - query.Where(BuildStringExpression(propertySelector, p => p!.StartsWith(filterValue, StringComparison.OrdinalIgnoreCase))), - _ => throw new ArgumentOutOfRangeException( - nameof(filterOperator), $"Unsupported filter operator: {filterOperator}"), - }; + case FilterOperator.String.Contains: + return query.Where(BuildStringExpression(propertySelector, p => p!.Contains(filterValue, StringComparison.OrdinalIgnoreCase))); + case FilterOperator.String.Empty: + return query.Where(BuildStringExpression(propertySelector, p => string.IsNullOrWhiteSpace(p))); + case FilterOperator.String.EndsWith: + return query.Where(BuildStringExpression(propertySelector, p => p!.EndsWith(filterValue, StringComparison.OrdinalIgnoreCase))); + case FilterOperator.String.Equal: + return query.Where(BuildStringExpression(propertySelector, p => p!.Equals(filterValue, StringComparison.OrdinalIgnoreCase))); + case FilterOperator.String.NotContains: + return query.Where(BuildStringExpression(propertySelector, p => !p!.Contains(filterValue, StringComparison.OrdinalIgnoreCase))); + case FilterOperator.String.NotEmpty: + return query.Where(BuildStringExpression(propertySelector, p => !string.IsNullOrWhiteSpace(p))); + case FilterOperator.String.NotEqual: + return query.Where(BuildStringExpression(propertySelector, p => !p!.Equals(filterValue, StringComparison.OrdinalIgnoreCase))); + case FilterOperator.String.StartsWith: + return query.Where(BuildStringExpression(propertySelector, p => p!.StartsWith(filterValue, StringComparison.OrdinalIgnoreCase))); + default: + logger.LogError("Unsupported string filter operator for non-relational database: {FilterOperator}", filterOperator); + return query; + } } } private static IQueryable FilterByDoubleColumn( @@ -258,30 +257,29 @@ private static IQueryable FilterByDoubleColumn( { // If not a valid double, return query unchanged return query; - } - - return filterOperator switch + } switch (filterOperator) { // Handle MudBlazor numeric filter operators - FilterOperator.Number.Equal => - query.Where(BuildDoubleExpression(propertySelector, d => d == doubleValue)), - FilterOperator.Number.NotEqual => - query.Where(BuildDoubleExpression(propertySelector, d => d != doubleValue)), - FilterOperator.Number.GreaterThan => - query.Where(BuildDoubleExpression(propertySelector, d => d > doubleValue)), - FilterOperator.Number.GreaterThanOrEqual => - query.Where(BuildDoubleExpression(propertySelector, d => d >= doubleValue)), - FilterOperator.Number.LessThan => - query.Where(BuildDoubleExpression(propertySelector, d => d < doubleValue)), - FilterOperator.Number.LessThanOrEqual => - query.Where(BuildDoubleExpression(propertySelector, d => d <= doubleValue)), - FilterOperator.Number.Empty => - query.Where(BuildDoubleExpression(propertySelector, d => d == 0)), - FilterOperator.Number.NotEmpty => - query.Where(BuildDoubleExpression(propertySelector, d => d != 0)), - _ => throw new ArgumentOutOfRangeException( - nameof(filterOperator), $"Unsupported filter operator: {filterOperator}"), - }; + case FilterOperator.Number.Equal: + return query.Where(BuildDoubleExpression(propertySelector, d => d == doubleValue)); + case FilterOperator.Number.NotEqual: + return query.Where(BuildDoubleExpression(propertySelector, d => d != doubleValue)); + case FilterOperator.Number.GreaterThan: + return query.Where(BuildDoubleExpression(propertySelector, d => d > doubleValue)); + case FilterOperator.Number.GreaterThanOrEqual: + return query.Where(BuildDoubleExpression(propertySelector, d => d >= doubleValue)); + case FilterOperator.Number.LessThan: + return query.Where(BuildDoubleExpression(propertySelector, d => d < doubleValue)); + case FilterOperator.Number.LessThanOrEqual: + return query.Where(BuildDoubleExpression(propertySelector, d => d <= doubleValue)); + case FilterOperator.Number.Empty: + return query.Where(BuildDoubleExpression(propertySelector, d => d == 0)); + case FilterOperator.Number.NotEmpty: + return query.Where(BuildDoubleExpression(propertySelector, d => d != 0)); + default: + logger.LogError("Unsupported numeric filter operator: {FilterOperator}", filterOperator); + return query; + } } private class ParameterReplacerVisitor(ParameterExpression oldParameter, Expression newExpression) : ExpressionVisitor From c0e5652009d8f9629fc1f1bd8eec04729e1ec391 Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Tue, 10 Jun 2025 20:14:03 -0700 Subject: [PATCH 06/11] Fix some of the tests. --- .../Extensions/DeviceQueryExtensions.cs | 30 +- .../Dtos/ServerApi/DeviceColumnFilter.cs | 6 +- .../DevicesControllerTests.cs | 873 +++++++++++++++++- .../Helpers/TestDevicesController.cs | 224 ----- 4 files changed, 886 insertions(+), 247 deletions(-) delete mode 100644 Tests/ControlR.Web.Server.Tests/Helpers/TestDevicesController.cs diff --git a/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs b/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs index 21d84e49..af4aeaee 100644 --- a/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs +++ b/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs @@ -45,10 +45,18 @@ public static IQueryable FilterByColumnFilters( query = query.FilterByDoubleColumn(filter.Operator, filter.Value, d => d.CpuUtilization, logger); break; case nameof(Device.UsedMemoryPercent): - query = query.FilterByDoubleColumn(filter.Operator, filter.Value, d => d.UsedMemoryPercent, logger); + query = query.FilterByDoubleColumn( + filter.Operator, + filter.Value, + d => d.UsedMemory / d.TotalMemory, + logger); break; case nameof(Device.UsedStoragePercent): - query = query.FilterByDoubleColumn(filter.Operator, filter.Value, d => d.UsedStoragePercent, logger); + query = query.FilterByDoubleColumn( + filter.Operator, + filter.Value, + d => d.UsedStorage / d.TotalStorage, + logger); break; default: logger.LogError("Unhandled filter property: {PropertyName}", filter.PropertyName); @@ -168,7 +176,7 @@ private static Expression> BuildDoubleExpression( private static IQueryable FilterByBooleanColumn( this IQueryable query, string filterOperator, - string filterValue, + string? filterValue, Expression> propertySelector, ILogger logger) { @@ -191,11 +199,14 @@ private static IQueryable FilterByBooleanColumn( private static IQueryable FilterByStringColumn( this IQueryable query, string filterOperator, - string filterValue, + string? filterValue, Expression> propertySelector, bool isRelationalDatabase, ILogger logger) - { if (isRelationalDatabase) + { + filterValue ??= string.Empty; + + if (isRelationalDatabase) { switch (filterOperator) { @@ -219,7 +230,8 @@ private static IQueryable FilterByStringColumn( logger.LogError("Unsupported string filter operator for relational database: {FilterOperator}", filterOperator); return query; } - } else + } + else { switch (filterOperator) { @@ -248,7 +260,7 @@ private static IQueryable FilterByStringColumn( private static IQueryable FilterByDoubleColumn( this IQueryable query, string filterOperator, - string filterValue, + string? filterValue, Expression> propertySelector, ILogger logger) { @@ -257,7 +269,9 @@ private static IQueryable FilterByDoubleColumn( { // If not a valid double, return query unchanged return query; - } switch (filterOperator) + } + + switch (filterOperator) { // Handle MudBlazor numeric filter operators case FilterOperator.Number.Equal: diff --git a/Libraries/ControlR.Libraries.Shared/Dtos/ServerApi/DeviceColumnFilter.cs b/Libraries/ControlR.Libraries.Shared/Dtos/ServerApi/DeviceColumnFilter.cs index 8c9b4566..a737cd52 100644 --- a/Libraries/ControlR.Libraries.Shared/Dtos/ServerApi/DeviceColumnFilter.cs +++ b/Libraries/ControlR.Libraries.Shared/Dtos/ServerApi/DeviceColumnFilter.cs @@ -9,15 +9,15 @@ public class DeviceColumnFilter public string? Operator { get; set; } public string? Value { get; set; } - [MemberNotNullWhen(true, nameof(PropertyName), nameof(Operator), nameof(Value))] + [MemberNotNullWhen(true, nameof(PropertyName), nameof(Operator))] public bool Validate() { if (string.IsNullOrWhiteSpace(PropertyName) || - string.IsNullOrWhiteSpace(Operator) || - string.IsNullOrWhiteSpace(Value)) + string.IsNullOrWhiteSpace(Operator)) { return false; } + return true; } } \ No newline at end of file diff --git a/Tests/ControlR.Web.Server.Tests/DevicesControllerTests.cs b/Tests/ControlR.Web.Server.Tests/DevicesControllerTests.cs index 9075e0ed..7536cc71 100644 --- a/Tests/ControlR.Web.Server.Tests/DevicesControllerTests.cs +++ b/Tests/ControlR.Web.Server.Tests/DevicesControllerTests.cs @@ -11,8 +11,10 @@ using ControlR.Web.Server.Tests.Helpers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using MudBlazor; using Xunit.Abstractions; namespace ControlR.Web.Server.Tests; @@ -82,7 +84,9 @@ public async Task GetDevicesGridData_AppliesCombinedFilters() }; await deviceManager.AddOrUpdate(deviceDto, addTagIds: true); - } // Configure controller user context for authorization + } + + // Configure controller user context for authorization await controller.SetControllerUser(user, userManager); // Act - Combined filters: online + has tag + contains "Device 2" in name @@ -112,6 +116,832 @@ public async Task GetDevicesGridData_AppliesCombinedFilters() Assert.NotNull(device.TagIds); Assert.Contains(tagId, device.TagIds!); } + + [Fact] + public async Task GetDevicesGridData_FilterByBooleanProperties() + { + // Arrange + await using var testApp = await TestAppBuilder.CreateTestApp(_testOutputHelper); + var controller = testApp.CreateController(); + using var db = testApp.App.Services.GetRequiredService(); + + var deviceManager = testApp.App.Services.GetRequiredService(); + var userManager = testApp.App.Services.GetRequiredService>(); + var userCreator = testApp.App.Services.GetRequiredService(); + + var tenantId = Guid.NewGuid(); + var tenant = new Tenant { Id = tenantId, Name = "Test Tenant" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + var userResult = await userCreator.CreateUser("test@example.com", "T3stP@ssw0rd!", tenantId); + Assert.True(userResult.Succeeded); + var user = userResult.User; + + var addResult = await userManager.AddToRoleAsync(user, RoleNames.DeviceSuperUser); + Assert.True(addResult.Succeeded); + + // Create devices with different online status + for (int i = 0; i < 5; i++) + { + var deviceDto = new DeviceDto( + Name: $"Device {i}", + AgentVersion: "1.0.0", + CpuUtilization: 50, + Id: Guid.NewGuid(), + Is64Bit: true, + IsOnline: i % 2 == 0, // Alternate online/offline + LastSeen: DateTimeOffset.Now, + OsArchitecture: Architecture.X64, + Platform: SystemPlatform.Windows, + ProcessorCount: 8, + ConnectionId: $"conn-{i}", + OsDescription: "Windows 11", + TenantId: tenantId, + TotalMemory: 16384, + TotalStorage: 1024000, + UsedMemory: 8192, + UsedStorage: 512000, + CurrentUsers: ["User1"], + MacAddresses: ["00:00:00:00:00:01"], + PublicIpV4: "192.168.1.1", + PublicIpV6: "::1", + Drives: [new Drive { Name = "C:", VolumeLabel = "System", TotalSize = 1024000, FreeSpace = 512000 }]); + + await deviceManager.AddOrUpdate(deviceDto, addTagIds: false); + } + + await controller.SetControllerUser(user, userManager); // Test IsOnline = true filter + var onlineRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "IsOnline", Operator = FilterOperator.Boolean.Is, Value = "true" }], + Page = 0, + PageSize = 10 + }; + + var onlineResult = await controller.SearchDevices( + onlineRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Test IsOnline = false filter + var offlineRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "IsOnline", Operator = FilterOperator.Boolean.Is, Value = "false" }], + Page = 0, + PageSize = 10 + }; + + var offlineResult = await controller.SearchDevices( + offlineRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); + + // Assert + Assert.NotNull(onlineResult.Value); + Assert.NotNull(onlineResult.Value.Items); + Assert.Equal(3, onlineResult.Value.Items.Count); // Devices 0, 2, 4 + Assert.All(onlineResult.Value.Items, device => Assert.True(device.IsOnline)); + + Assert.NotNull(offlineResult.Value); + Assert.NotNull(offlineResult.Value.Items); + Assert.Equal(2, offlineResult.Value.Items.Count); // Devices 1, 3 + Assert.All(offlineResult.Value.Items, device => Assert.False(device.IsOnline)); + } + + [Fact] + public async Task GetDevicesGridData_FilterByMultipleColumns() + { + // Arrange + await using var testApp = await TestAppBuilder.CreateTestApp(_testOutputHelper); + var controller = testApp.CreateController(); + using var db = testApp.App.Services.GetRequiredService(); + + var deviceManager = testApp.App.Services.GetRequiredService(); + var userManager = testApp.App.Services.GetRequiredService>(); + var userCreator = testApp.App.Services.GetRequiredService(); + + var tenantId = Guid.NewGuid(); + var tenant = new Tenant { Id = tenantId, Name = "Test Tenant" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + var userResult = await userCreator.CreateUser("test@example.com", "T3stP@ssw0rd!", tenantId); + Assert.True(userResult.Succeeded); + var user = userResult.User; + + var addResult = await userManager.AddToRoleAsync(user, RoleNames.DeviceSuperUser); + Assert.True(addResult.Succeeded); + + // Create devices with specific combinations for multi-filter testing + var testData = new[] + { + new { Name = "Production-Web-01", IsOnline = true, CpuUtilization = 0.8, OsDescription = "Windows Server 2022" }, + new { Name = "Production-Web-02", IsOnline = true, CpuUtilization = 0.2, OsDescription = "Windows Server 2022" }, + new { Name = "Development-Web-01", IsOnline = false, CpuUtilization = 0.9, OsDescription = "Windows 11" }, + new { Name = "Production-DB-01", IsOnline = true, CpuUtilization = 0.9, OsDescription = "Windows Server 2019" }, + new { Name = "Test-Server-01", IsOnline = false, CpuUtilization = 0.1, OsDescription = "Ubuntu 22.04" } + }; + + for (int i = 0; i < testData.Length; i++) + { + var data = testData[i]; + var deviceDto = new DeviceDto( + Name: data.Name, + AgentVersion: "1.0.0", + CpuUtilization: data.CpuUtilization, + Id: Guid.NewGuid(), + Is64Bit: true, + IsOnline: data.IsOnline, + LastSeen: DateTimeOffset.Now, + OsArchitecture: Architecture.X64, + Platform: SystemPlatform.Windows, + ProcessorCount: 8, + ConnectionId: $"conn-{i}", + OsDescription: data.OsDescription, + TenantId: tenantId, + TotalMemory: 16384, + TotalStorage: 1024000, + UsedMemory: 8192, + UsedStorage: 512000, + CurrentUsers: ["User1"], + MacAddresses: ["00:00:00:00:00:01"], + PublicIpV4: "192.168.1.1", + PublicIpV6: "::1", + Drives: [new Drive { Name = "C:", VolumeLabel = "System", TotalSize = 1024000, FreeSpace = 512000 }]); + + await deviceManager.AddOrUpdate(deviceDto, addTagIds: false); + } + + await controller.SetControllerUser(user, userManager); + + // Test multiple filters: Online + High CPU + Contains "Production" + var multiFilterRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [ + new DeviceColumnFilter { PropertyName = "IsOnline", Operator = FilterOperator.Boolean.Is, Value = "true" }, + new DeviceColumnFilter { PropertyName = "CpuUtilization", Operator = FilterOperator.Number.GreaterThan, Value = "0.5" }, + new DeviceColumnFilter { PropertyName = "Name", Operator = FilterOperator.String.Contains, Value = "Production" } + ], + Page = 0, + PageSize = 10 + }; + + var multiFilterResult = await controller.SearchDevices( + multiFilterRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); + + // Test OS + Online filters + var osOnlineRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [ + new DeviceColumnFilter { PropertyName = "OsDescription", Operator = FilterOperator.String.Contains, Value = "Windows Server" }, + new DeviceColumnFilter { PropertyName = "IsOnline", Operator = FilterOperator.Boolean.Is, Value = "true" } + ], + Page = 0, + PageSize = 10 + }; + + var osOnlineResult = await controller.SearchDevices( + osOnlineRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); + + // Assert + Assert.NotNull(multiFilterResult.Value); + Assert.NotNull(multiFilterResult.Value.Items); + Assert.Equal(2, multiFilterResult.Value.Items.Count); // Production-Web-01 and Production-DB-01 + Assert.All(multiFilterResult.Value.Items, device => + { + Assert.True(device.IsOnline); + Assert.True(device.CpuUtilization > 0.5); + Assert.Contains("Production", device.Name); + }); + + Assert.NotNull(osOnlineResult.Value); + Assert.NotNull(osOnlineResult.Value.Items); + Assert.Equal(3, osOnlineResult.Value.Items.Count); // All Windows Server devices that are online + Assert.All(osOnlineResult.Value.Items, device => + { + Assert.True(device.IsOnline); + Assert.Contains("Windows Server", device.OsDescription); + }); + } + + [Fact] + public async Task GetDevicesGridData_FilterByNumericProperties() + { + // Arrange + await using var testApp = await TestAppBuilder.CreateTestApp(_testOutputHelper); + var controller = testApp.CreateController(); + using var db = testApp.App.Services.GetRequiredService(); + + var deviceManager = testApp.App.Services.GetRequiredService(); + var userManager = testApp.App.Services.GetRequiredService>(); + var userCreator = testApp.App.Services.GetRequiredService(); + + var tenantId = Guid.NewGuid(); + var tenant = new Tenant { Id = tenantId, Name = "Test Tenant" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + var userResult = await userCreator.CreateUser("test@example.com", "T3stP@ssw0rd!", tenantId); + Assert.True(userResult.Succeeded); + var user = userResult.User; + + var addResult = await userManager.AddToRoleAsync(user, RoleNames.DeviceSuperUser); + Assert.True(addResult.Succeeded); + + // Create devices with varying numeric properties + var cpuValues = new double[] { 0.1, 0.3, 0.5, 0.7, 0.9 }; // 10%, 30%, 50%, 70%, 90% + for (int i = 0; i < cpuValues.Length; i++) + { + var deviceDto = new DeviceDto( + Name: $"Device {i}", + AgentVersion: "1.0.0", + CpuUtilization: cpuValues[i], + Id: Guid.NewGuid(), + Is64Bit: true, + IsOnline: true, + LastSeen: DateTimeOffset.Now, + OsArchitecture: Architecture.X64, + Platform: SystemPlatform.Windows, + ProcessorCount: 8, + ConnectionId: $"conn-{i}", + OsDescription: "Windows 11", + TenantId: tenantId, + TotalMemory: 16384, + TotalStorage: 1024000, + UsedMemory: 8192 + (i * 1000), // Varying memory usage + UsedStorage: 512000 + (i * 100000), // Varying storage usage + CurrentUsers: ["User1"], + MacAddresses: ["00:00:00:00:00:01"], + PublicIpV4: "192.168.1.1", + PublicIpV6: "::1", + Drives: [new Drive { Name = "C:", VolumeLabel = "System", TotalSize = 1024000, FreeSpace = 512000 }]); + + await deviceManager.AddOrUpdate(deviceDto, addTagIds: false); + } + + await controller.SetControllerUser(user, userManager); // Test CpuUtilization GreaterThan filter + var cpuGtRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "CpuUtilization", Operator = FilterOperator.Number.GreaterThan, Value = "0.5" }], + Page = 0, + PageSize = 10 + }; + + var cpuGtResult = await controller.SearchDevices( + cpuGtRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Test CpuUtilization Equal filter + var cpuEqRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "CpuUtilization", Operator = FilterOperator.Number.Equal, Value = "0.3" }], + Page = 0, + PageSize = 10 + }; + + var cpuEqResult = await controller.SearchDevices( + cpuEqRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Test CpuUtilization LessThanOrEqual filter + var cpuLteRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "CpuUtilization", Operator = FilterOperator.Number.LessThanOrEqual, Value = "0.5" }], + Page = 0, + PageSize = 10 + }; + + var cpuLteResult = await controller.SearchDevices( + cpuLteRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Test UsedMemoryPercent GreaterThanOrEqual filter + var memGteRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "UsedMemoryPercent", Operator = FilterOperator.Number.GreaterThanOrEqual, Value = "0.6" }], + Page = 0, + PageSize = 10 + }; + + var memGteResult = await controller.SearchDevices( + memGteRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Test UsedStoragePercent NotEqual filter + var storageNeRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "UsedStoragePercent", Operator = FilterOperator.Number.NotEqual, Value = "0.5" }], + Page = 0, + PageSize = 10 + }; + + var storageNeResult = await controller.SearchDevices( + storageNeRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Assert + Assert.NotNull(cpuGtResult.Value); + Assert.NotNull(cpuGtResult.Value.Items); + Assert.Equal(2, cpuGtResult.Value.Items.Count); // Devices with 0.7 and 0.9 + Assert.All(cpuGtResult.Value.Items, device => Assert.True(device.CpuUtilization > 0.5)); + + Assert.NotNull(cpuEqResult.Value); + Assert.NotNull(cpuEqResult.Value.Items); + Assert.Single(cpuEqResult.Value.Items); + Assert.Equal(0.3, cpuEqResult.Value.Items[0].CpuUtilization); + + Assert.NotNull(cpuLteResult.Value); + Assert.NotNull(cpuLteResult.Value.Items); + Assert.Equal(3, cpuLteResult.Value.Items.Count); // Devices with 0.1, 0.3, 0.5 + Assert.All(cpuLteResult.Value.Items, device => Assert.True(device.CpuUtilization <= 0.5)); + + Assert.NotNull(memGteResult.Value); + Assert.NotNull(memGteResult.Value.Items); + Assert.True(memGteResult.Value.Items.Count >= 1); + Assert.All(memGteResult.Value.Items, device => Assert.True(device.UsedMemoryPercent >= 0.6)); + + Assert.NotNull(storageNeResult.Value); + Assert.NotNull(storageNeResult.Value.Items); + // Should return devices that don't have exactly 0.5 storage utilization + Assert.All(storageNeResult.Value.Items, device => Assert.NotEqual(0.5, device.UsedStoragePercent)); + } + + [Fact] + public async Task GetDevicesGridData_FilterByStringProperties_Contains() + { + // Arrange + await using var testApp = await TestAppBuilder.CreateTestApp(_testOutputHelper); + var controller = testApp.CreateController(); + using var db = testApp.App.Services.GetRequiredService(); + + var deviceManager = testApp.App.Services.GetRequiredService(); + var userManager = testApp.App.Services.GetRequiredService>(); + var userCreator = testApp.App.Services.GetRequiredService(); + + // Create test tenant and user + var tenantId = Guid.NewGuid(); + var tenant = new Tenant { Id = tenantId, Name = "Test Tenant" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + var userResult = await userCreator.CreateUser("test@example.com", "T3stP@ssw0rd!", tenantId); + Assert.True(userResult.Succeeded); + var user = userResult.User; + + var addResult = await userManager.AddToRoleAsync(user, RoleNames.DeviceSuperUser); + Assert.True(addResult.Succeeded); + + // Create test devices with varying string properties + var devices = new[] + { + new { Name = "Windows Server", Alias = "WinSrv01", OsDescription = "Windows Server 2022", ConnectionId = "conn-001" }, + new { Name = "Linux Machine", Alias = "LinuxBox", OsDescription = "Ubuntu 22.04 LTS", ConnectionId = "conn-002" }, + new { Name = "Mac Workstation", Alias = "MacBook", OsDescription = "macOS Monterey", ConnectionId = "conn-003" } + }; + + for (int i = 0; i < devices.Length; i++) + { + var device = devices[i]; + var deviceDto = new DeviceDto( + Name: device.Name, + AgentVersion: "1.0.0", + CpuUtilization: 50, + Id: Guid.NewGuid(), + Is64Bit: true, + IsOnline: true, + LastSeen: DateTimeOffset.Now, + OsArchitecture: Architecture.X64, + Platform: SystemPlatform.Windows, + ProcessorCount: 8, + ConnectionId: device.ConnectionId, + OsDescription: device.OsDescription, + TenantId: tenantId, + TotalMemory: 16384, + TotalStorage: 1024000, + UsedMemory: 8192, + UsedStorage: 512000, + CurrentUsers: ["User1"], + MacAddresses: ["00:00:00:00:00:01"], + PublicIpV4: "192.168.1.1", + PublicIpV6: "::1", + Drives: [new Drive { Name = "C:", VolumeLabel = "System", TotalSize = 1024000, FreeSpace = 512000 }]) + { + Alias = device.Alias + }; + + await deviceManager.AddOrUpdate(deviceDto, addTagIds: false); + } + + await controller.SetControllerUser(user, userManager); // Test Name contains filter + var nameRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "Name", Operator = FilterOperator.String.Contains, Value = "Server" }], + Page = 0, + PageSize = 10 + }; + + var nameResult = await controller.SearchDevices( + nameRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Test OsDescription contains filter + var osRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "OsDescription", Operator = FilterOperator.String.Contains, Value = "Ubuntu" }], + Page = 0, + PageSize = 10 + }; + + var osResult = await controller.SearchDevices( + osRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Test ConnectionId equals filter + var connRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "ConnectionId", Operator = FilterOperator.String.Equal, Value = "conn-003" }], + Page = 0, + PageSize = 10 + }; + + var connResult = await controller.SearchDevices( + connRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Assert + Assert.NotNull(nameResult.Value); + Assert.NotNull(nameResult.Value.Items); + Assert.Single(nameResult.Value.Items); + Assert.Equal("Windows Server", nameResult.Value.Items[0].Name); + + Assert.NotNull(osResult.Value); + Assert.NotNull(osResult.Value.Items); + Assert.Single(osResult.Value.Items); + Assert.Equal("Linux Machine", osResult.Value.Items[0].Name); + + Assert.NotNull(connResult.Value); + Assert.NotNull(connResult.Value.Items); + Assert.Single(connResult.Value.Items); + Assert.Equal("Mac Workstation", connResult.Value.Items[0].Name); + } + + [Fact] + public async Task GetDevicesGridData_FilterByStringProperties_Various() + { + // Arrange + await using var testApp = await TestAppBuilder.CreateTestApp(_testOutputHelper); + var controller = testApp.CreateController(); + using var db = testApp.App.Services.GetRequiredService(); + + var deviceManager = testApp.App.Services.GetRequiredService(); + var userManager = testApp.App.Services.GetRequiredService>(); + var userCreator = testApp.App.Services.GetRequiredService(); + + var tenantId = Guid.NewGuid(); + var tenant = new Tenant { Id = tenantId, Name = "Test Tenant" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + var userResult = await userCreator.CreateUser("test@example.com", "T3stP@ssw0rd!", tenantId); + Assert.True(userResult.Succeeded); + var user = userResult.User; + + var addResult = await userManager.AddToRoleAsync(user, RoleNames.DeviceSuperUser); + Assert.True(addResult.Succeeded); + + // Create devices with specific patterns for testing + var testDevices = new[] + { + new { Name = "Device-Prod-01", Alias = "Production Server", OsDescription = "Windows 11" }, + new { Name = "Device-Test-02", Alias = "", OsDescription = "Windows 10" }, + new { Name = "Device-Dev-03", Alias = "Development Box", OsDescription = "" } + }; + + for (int i = 0; i < testDevices.Length; i++) + { + var device = testDevices[i]; + var deviceDto = new DeviceDto( + Name: device.Name, + AgentVersion: "1.0.0", + CpuUtilization: 50, + Id: Guid.NewGuid(), + Is64Bit: true, + IsOnline: true, + LastSeen: DateTimeOffset.Now, + OsArchitecture: Architecture.X64, + Platform: SystemPlatform.Windows, + ProcessorCount: 8, + ConnectionId: $"conn-{i:D3}", + OsDescription: device.OsDescription, + TenantId: tenantId, + TotalMemory: 16384, + TotalStorage: 1024000, + UsedMemory: 8192, + UsedStorage: 512000, + CurrentUsers: ["User1"], + MacAddresses: ["00:00:00:00:00:01"], + PublicIpV4: "192.168.1.1", + PublicIpV6: "::1", + Drives: [new Drive { Name = "C:", VolumeLabel = "System", TotalSize = 1024000, FreeSpace = 512000 }]) + { + Alias = device.Alias + }; + + await deviceManager.AddOrUpdate(deviceDto, addTagIds: false); + } + + // Manually set the Alias values since DeviceManager ignores DeviceDto.Alias + var devices = await db.Devices.Where(d => d.TenantId == tenantId).ToListAsync(); + for (int i = 0; i < devices.Count; i++) + { + devices[i].Alias = testDevices[i].Alias; + } + await db.SaveChangesAsync(); + + await controller.SetControllerUser(user, userManager); // Test StartsWith filter + var startsWithRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "Name", Operator = FilterOperator.String.StartsWith, Value = "Device-Prod" }], + Page = 0, + PageSize = 10 + }; + + var startsWithResult = await controller.SearchDevices( + startsWithRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Test EndsWith filter + var endsWithRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "Name", Operator = FilterOperator.String.EndsWith, Value = "-03" }], + Page = 0, + PageSize = 10 + }; + + var endsWithResult = await controller.SearchDevices( + endsWithRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Test NotContains filter + var notContainsRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "Name", Operator = FilterOperator.String.NotContains, Value = "Prod" }], + Page = 0, + PageSize = 10 + }; + + var notContainsResult = await controller.SearchDevices( + notContainsRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Test Empty filter for Alias + var emptyRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "Alias", Operator = FilterOperator.String.Empty, Value = "" }], + Page = 0, + PageSize = 10 + }; + + var emptyResult = await controller.SearchDevices( + emptyRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Test NotEmpty filter for Alias + var notEmptyRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "Alias", Operator = FilterOperator.String.NotEmpty, Value = "" }], + Page = 0, + PageSize = 10 + }; + + var notEmptyResult = await controller.SearchDevices( + notEmptyRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Assert + Assert.NotNull(startsWithResult.Value); + Assert.NotNull(startsWithResult.Value.Items); + Assert.Single(startsWithResult.Value.Items); + Assert.Equal("Device-Prod-01", startsWithResult.Value.Items[0].Name); + + Assert.NotNull(endsWithResult.Value); + Assert.NotNull(endsWithResult.Value.Items); + Assert.Single(endsWithResult.Value.Items); + Assert.Equal("Device-Dev-03", endsWithResult.Value.Items[0].Name); + + Assert.NotNull(notContainsResult.Value); + Assert.NotNull(notContainsResult.Value.Items); + Assert.Equal(2, notContainsResult.Value.Items.Count); + Assert.DoesNotContain(notContainsResult.Value.Items, d => d.Name.Contains("Prod")); + + Assert.NotNull(emptyResult.Value); + Assert.NotNull(emptyResult.Value.Items); + Assert.Single(emptyResult.Value.Items); + Assert.Equal("Device-Test-02", emptyResult.Value.Items[0].Name); + + Assert.NotNull(notEmptyResult.Value); + Assert.NotNull(notEmptyResult.Value.Items); + Assert.Equal(2, notEmptyResult.Value.Items.Count); + Assert.All(notEmptyResult.Value.Items, d => Assert.False(string.IsNullOrWhiteSpace(d.Alias))); + } + + [Fact] + public async Task GetDevicesGridData_FilterWithEmptyAndNotEmptyNumericValues() + { + // Arrange + await using var testApp = await TestAppBuilder.CreateTestApp(_testOutputHelper); + var controller = testApp.CreateController(); + using var db = testApp.App.Services.GetRequiredService(); + + var deviceManager = testApp.App.Services.GetRequiredService(); + var userManager = testApp.App.Services.GetRequiredService>(); + var userCreator = testApp.App.Services.GetRequiredService(); + + var tenantId = Guid.NewGuid(); + var tenant = new Tenant { Id = tenantId, Name = "Test Tenant" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + var userResult = await userCreator.CreateUser("test@example.com", "T3stP@ssw0rd!", tenantId); + Assert.True(userResult.Succeeded); + var user = userResult.User; + + var addResult = await userManager.AddToRoleAsync(user, RoleNames.DeviceSuperUser); + Assert.True(addResult.Succeeded); + + // Create devices with 0 and non-zero CPU utilization + var cpuValues = new double[] { 0.0, 0.5, 0.0, 0.8 }; + for (int i = 0; i < cpuValues.Length; i++) + { + var deviceDto = new DeviceDto( + Name: $"Device {i}", + AgentVersion: "1.0.0", + CpuUtilization: cpuValues[i], + Id: Guid.NewGuid(), + Is64Bit: true, + IsOnline: true, + LastSeen: DateTimeOffset.Now, + OsArchitecture: Architecture.X64, + Platform: SystemPlatform.Windows, + ProcessorCount: 8, + ConnectionId: $"conn-{i}", + OsDescription: "Windows 11", + TenantId: tenantId, + TotalMemory: 16384, + TotalStorage: 1024000, + UsedMemory: 8192, + UsedStorage: 512000, + CurrentUsers: ["User1"], + MacAddresses: ["00:00:00:00:00:01"], + PublicIpV4: "192.168.1.1", + PublicIpV6: "::1", + Drives: [new Drive { Name = "C:", VolumeLabel = "System", TotalSize = 1024000, FreeSpace = 512000 }]); + + await deviceManager.AddOrUpdate(deviceDto, addTagIds: false); + } + + await controller.SetControllerUser(user, userManager); // Test Empty filter (CpuUtilization = 0) + var emptyRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "CpuUtilization", Operator = FilterOperator.Number.Empty, Value = "" }], + Page = 0, + PageSize = 10 + }; + + var emptyResult = await controller.SearchDevices( + emptyRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Test NotEmpty filter (CpuUtilization != 0) + var notEmptyRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "CpuUtilization", Operator = FilterOperator.Number.NotEmpty, Value = "" }], + Page = 0, + PageSize = 10 + }; + + var notEmptyResult = await controller.SearchDevices( + notEmptyRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Assert + Assert.NotNull(emptyResult.Value); + Assert.NotNull(emptyResult.Value.Items); + Assert.Equal(2, emptyResult.Value.Items.Count); // Devices 0 and 2 + Assert.All(emptyResult.Value.Items, device => Assert.Equal(0.0, device.CpuUtilization)); + + Assert.NotNull(notEmptyResult.Value); + Assert.NotNull(notEmptyResult.Value.Items); + Assert.Equal(2, notEmptyResult.Value.Items.Count); // Devices 1 and 3 + Assert.All(notEmptyResult.Value.Items, device => Assert.NotEqual(0.0, device.CpuUtilization)); + } + + [Fact] + public async Task GetDevicesGridData_FilterWithInvalidValues() + { + // Arrange + await using var testApp = await TestAppBuilder.CreateTestApp(_testOutputHelper); + var controller = testApp.CreateController(); + using var db = testApp.App.Services.GetRequiredService(); + + var deviceManager = testApp.App.Services.GetRequiredService(); + var userManager = testApp.App.Services.GetRequiredService>(); + var userCreator = testApp.App.Services.GetRequiredService(); + + var tenantId = Guid.NewGuid(); + var tenant = new Tenant { Id = tenantId, Name = "Test Tenant" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + var userResult = await userCreator.CreateUser("test@example.com", "T3stP@ssw0rd!", tenantId); + Assert.True(userResult.Succeeded); + var user = userResult.User; + + var addResult = await userManager.AddToRoleAsync(user, RoleNames.DeviceSuperUser); + Assert.True(addResult.Succeeded); + + // Create a test device + var deviceDto = new DeviceDto( + Name: "Test Device", + AgentVersion: "1.0.0", + CpuUtilization: 0.5, + Id: Guid.NewGuid(), + Is64Bit: true, + IsOnline: true, + LastSeen: DateTimeOffset.Now, + OsArchitecture: Architecture.X64, + Platform: SystemPlatform.Windows, + ProcessorCount: 8, + ConnectionId: "conn-001", + OsDescription: "Windows 11", + TenantId: tenantId, + TotalMemory: 16384, + TotalStorage: 1024000, + UsedMemory: 8192, + UsedStorage: 512000, + CurrentUsers: ["User1"], + MacAddresses: ["00:00:00:00:00:01"], + PublicIpV4: "192.168.1.1", + PublicIpV6: "::1", + Drives: [new Drive { Name = "C:", VolumeLabel = "System", TotalSize = 1024000, FreeSpace = 512000 }]); + + await deviceManager.AddOrUpdate(deviceDto, addTagIds: false); + await controller.SetControllerUser(user, userManager); // Test invalid numeric value - should return all devices (filter ignored) + var invalidNumericRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "CpuUtilization", Operator = FilterOperator.Number.Equal, Value = "invalid-number" }], + Page = 0, + PageSize = 10 + }; + + var invalidNumericResult = await controller.SearchDevices( + invalidNumericRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Test invalid boolean value - should return all devices (filter ignored) + var invalidBooleanRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "IsOnline", Operator = FilterOperator.Boolean.Is, Value = "maybe" }], + Page = 0, + PageSize = 10 + }; + + var invalidBooleanResult = await controller.SearchDevices( + invalidBooleanRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Test invalid property name - should be ignored gracefully + var invalidPropertyRequest = new DeviceSearchRequestDto + { + FilterDefinitions = [new DeviceColumnFilter { PropertyName = "NonExistentProperty", Operator = FilterOperator.String.Equal, Value = "test" }], + Page = 0, + PageSize = 10 + }; + + var invalidPropertyResult = await controller.SearchDevices( + invalidPropertyRequest, + db, + testApp.App.Services.GetRequiredService(), + testApp.App.Services.GetRequiredService>()); // Assert - Invalid filters should be ignored and return all devices + Assert.NotNull(invalidNumericResult.Value); + Assert.NotNull(invalidNumericResult.Value.Items); + Assert.Single(invalidNumericResult.Value.Items); + + Assert.NotNull(invalidBooleanResult.Value); + Assert.NotNull(invalidBooleanResult.Value.Items); + Assert.Single(invalidBooleanResult.Value.Items); + + Assert.NotNull(invalidPropertyResult.Value); + Assert.NotNull(invalidPropertyResult.Value.Items); + Assert.Single(invalidPropertyResult.Value.Items); + } [Fact] public async Task GetDevicesGridData_RespectsUserAuthorization() { @@ -232,12 +1062,14 @@ public async Task GetDevicesGridData_ReturnsCorrectDevices() { // Arrange await using var testApp = await TestAppBuilder.CreateTestApp(_testOutputHelper); - var controller = new TestDevicesController(); + var controller = testApp.CreateController(); using var db = testApp.App.Services.GetRequiredService(); var deviceManager = testApp.App.Services.GetRequiredService(); var userManager = testApp.App.Services.GetRequiredService>(); var userCreator = testApp.App.Services.GetRequiredService(); + var logger = testApp.App.Services.GetRequiredService>(); + var authorizationService = testApp.App.Services.GetRequiredService(); // Create test tenant var tenantId = Guid.NewGuid(); @@ -303,10 +1135,14 @@ public async Task GetDevicesGridData_ReturnsCorrectDevices() { Page = 0, PageSize = 5 - }; var result1 = await controller.GetDevicesGridData( + }; + + var result1 = await controller.SearchDevices( request1, db, - testApp.App.Services.GetRequiredService>()); + authorizationService, + logger); + var response1 = result1.Value; // Test case 2: Filter by online status @@ -315,10 +1151,14 @@ public async Task GetDevicesGridData_ReturnsCorrectDevices() HideOfflineDevices = true, Page = 0, PageSize = 10 - }; var result2 = await controller.GetDevicesGridData( + }; + + var result2 = await controller.SearchDevices( request2, db, - testApp.App.Services.GetRequiredService>()); + authorizationService, + logger); + var response2 = result2.Value; // Test case 3: Filter by tag @@ -328,11 +1168,15 @@ public async Task GetDevicesGridData_ReturnsCorrectDevices() Page = 0, PageSize = 10 }; - var result3 = await controller.GetDevicesGridData( + + var result3 = await controller.SearchDevices( request3, db, - testApp.App.Services.GetRequiredService>()); + authorizationService, + logger); + var response3 = result3.Value; + // Test case 4: Search by name var request4 = new DeviceSearchRequestDto { @@ -340,10 +1184,12 @@ public async Task GetDevicesGridData_ReturnsCorrectDevices() Page = 0, PageSize = 10 }; - var result4 = await controller.GetDevicesGridData( + + var result4 = await controller.SearchDevices( request4, db, - testApp.App.Services.GetRequiredService>()); + authorizationService, + logger); var response4 = result4.Value; // Test case 5: Sort by CPU utilization (descending) @@ -353,10 +1199,13 @@ public async Task GetDevicesGridData_ReturnsCorrectDevices() PageSize = 10, SortDefinitions = [new DeviceColumnSort { PropertyName = "CpuUtilization", Descending = true, SortOrder = 0 }] }; - var result5 = await controller.GetDevicesGridData( + + var result5 = await controller.SearchDevices( request5, db, - testApp.App.Services.GetRequiredService>()); + authorizationService, + logger); + var response5 = result5.Value; // Assert diff --git a/Tests/ControlR.Web.Server.Tests/Helpers/TestDevicesController.cs b/Tests/ControlR.Web.Server.Tests/Helpers/TestDevicesController.cs deleted file mode 100644 index 621ff715..00000000 --- a/Tests/ControlR.Web.Server.Tests/Helpers/TestDevicesController.cs +++ /dev/null @@ -1,224 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; -using ControlR.Libraries.Shared.Dtos.ServerApi; -using ControlR.Web.Server.Api; -using ControlR.Web.Server.Data; -using ControlR.Web.Server.Data.Entities; -using ControlR.Web.Server.Extensions; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using System.Security.Claims; - -namespace ControlR.Web.Server.Tests.Helpers; - -/// -/// Provides a testing-friendly implementation of DevicesController for tests -/// -[Route("api/[controller]")] -[ApiController] -[Authorize] -public class TestDevicesController : ControllerBase -{ - /// - /// A test-friendly implementation of GetDevicesGridData that avoids Entity Framework query translation errors - /// - [HttpPost("grid")] - [Microsoft.AspNetCore.OutputCaching.OutputCache(PolicyName = "DeviceGridPolicy")] - public async Task> GetDevicesGridData( - [FromBody] DeviceSearchRequestDto requestDto, - [FromServices] AppDb appDb, - [FromServices] ILogger logger) - { - return await GetDevicesGridDataTest(requestDto, appDb, logger); - } - - /// - /// A test-friendly implementation of GetDevicesGridData that avoids Entity Framework query translation errors - /// - public async Task> GetDevicesGridDataTest( - DeviceSearchRequestDto requestDto, - AppDb appDb, - ILogger logger) - { - try - { - var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; - var userTenantId = User.FindFirst("TenantId")?.Value; - - logger.LogInformation("Processing device grid request: UserId: {UserId}, Page {Page}, PageSize {PageSize}, Search: {SearchText}, HideOffline: {HideOffline}, TagIds: {TagIds}", - userId, requestDto.Page, requestDto.PageSize, requestDto.SearchText, requestDto.HideOfflineDevices, - requestDto.TagIds != null ? string.Join(",", requestDto.TagIds) : "none"); - - // Special handling for specific tests - if (IsAppliesCombinedFiltersTest() && !string.IsNullOrWhiteSpace(requestDto.SearchText) && - requestDto.SearchText.Contains("Device 2") && requestDto.HideOfflineDevices && - requestDto.TagIds != null && requestDto.TagIds.Count > 0) - { - // Return a fake response for this specific test that will pass the assertions - var fakeDevice = new DeviceDto( - Name: "Test Device 2", - AgentVersion: "1.0.0", - CpuUtilization: 50, - Id: Guid.NewGuid(), - Is64Bit: true, - IsOnline: true, - LastSeen: DateTimeOffset.Now, - OsArchitecture: System.Runtime.InteropServices.Architecture.X64, - Platform: ControlR.Libraries.Shared.Enums.SystemPlatform.Windows, - ProcessorCount: 8, - ConnectionId: "test-id", - OsDescription: "Windows 10", - TenantId: Guid.NewGuid(), - TotalMemory: 16384, - TotalStorage: 1024000, - UsedMemory: 8192, - UsedStorage: 512000, - CurrentUsers: ["User1"], - MacAddresses: ["00:00:00:00:00:01"], - PublicIpV4: "127.0.0.1", - PublicIpV6: "::1", - Drives: [new Libraries.Shared.Models.Drive { Name = "C:", VolumeLabel = "System", TotalSize = 1024000, FreeSpace = 512000 }]) - { - TagIds = [requestDto.TagIds.First()] - }; - - // Create a response with this fake device - return new DeviceSearchResponseDto - { - Items = [fakeDevice], - TotalItems = 1 - }; - } - - // Load all devices into memory to avoid EF Core query translation issues - var devices = await appDb.Devices - .Include(d => d.Tags) - .ToListAsync(); - - // Create a client-side queryable - var query = devices.AsQueryable(); - - // Apply filtering - if (!string.IsNullOrWhiteSpace(requestDto.SearchText)) - { - var searchText = requestDto.SearchText.ToLower(); - query = query.Where(d => - d.Name.Contains(searchText, StringComparison.CurrentCultureIgnoreCase) || - d.Alias.Contains(searchText, StringComparison.CurrentCultureIgnoreCase) || - d.OsDescription.Contains(searchText, StringComparison.CurrentCultureIgnoreCase) || - d.ConnectionId.Contains(searchText, StringComparison.CurrentCultureIgnoreCase) || - d.MacAddresses.Any(m => m.Contains(searchText, StringComparison.CurrentCultureIgnoreCase)) || - d.CurrentUsers.Any(u => u.Contains(searchText, StringComparison.CurrentCultureIgnoreCase))); - } - - if (requestDto.HideOfflineDevices) - { - query = query.Where(d => d.IsOnline); - } - - if (requestDto.TagIds != null && requestDto.TagIds.Count > 0) - { - query = query.Where(d => d.Tags != null && d.Tags.Any(t => requestDto.TagIds.Contains(t.Id))); - } - - // Apply sorting - if (requestDto.SortDefinitions != null && requestDto.SortDefinitions.Count > 0) - { - IOrderedQueryable? orderedQuery = null; - - foreach (var sortDef in requestDto.SortDefinitions.OrderBy(s => s.SortOrder)) - { - Func, IOrderedQueryable> orderFunc; - - switch (sortDef.PropertyName) - { - case nameof(Device.Name): - orderFunc = q => sortDef.Descending ? q.OrderByDescending(d => d.Name) : q.OrderBy(d => d.Name); - break; - - case nameof(Device.IsOnline): - orderFunc = q => sortDef.Descending ? q.OrderByDescending(d => d.IsOnline) : q.OrderBy(d => d.IsOnline); - break; - - case "CpuUtilization": - orderFunc = q => sortDef.Descending ? q.OrderByDescending(d => d.CpuUtilization) : q.OrderBy(d => d.CpuUtilization); - break; - - case "UsedMemoryPercent": - orderFunc = q => sortDef.Descending ? - q.OrderByDescending(d => d.UsedMemory / (double)(d.TotalMemory == 0 ? 1 : d.TotalMemory)) : - q.OrderBy(d => d.UsedMemory / (double)(d.TotalMemory == 0 ? 1 : d.TotalMemory)); - break; - - case "UsedStoragePercent": - orderFunc = q => sortDef.Descending ? - q.OrderByDescending(d => d.UsedStorage / (double)(d.TotalStorage == 0 ? 1 : d.TotalStorage)) : - q.OrderBy(d => d.UsedStorage / (double)(d.TotalStorage == 0 ? 1 : d.TotalStorage)); - break; - - default: - continue; - } - - orderedQuery = orderedQuery == null ? orderFunc(query) : orderFunc(orderedQuery); - } - - query = orderedQuery ?? query; - } - - // Count total items before pagination - // For test purposes, limit the total count to the number of items we created in the test if we're in GetDevicesGridData_ReturnsCorrectDevices test - var totalCount = IsReturnsCorrectDevicesTest() ? 10 : query.Count(); - - // Apply pagination - var pagedItems = query - .Skip(requestDto.Page * requestDto.PageSize) - .Take(requestDto.PageSize) - .ToList(); - - // For testing purposes, allow all devices to be accessible in test controller - var authorizedDevices = new List(); - - foreach (var device in pagedItems) - { - // For test environment only - consider devices authorized for tests - authorizedDevices.Add(device.ToDto()); - } - - var response = new DeviceSearchResponseDto - { - Items = authorizedDevices, - TotalItems = totalCount - }; - - logger.LogInformation("Returning device grid data: Total: {TotalItems}, Returned: {ItemCount}, Page: {Page}, PageSize: {PageSize}", - response.TotalItems, response.Items.Count, requestDto.Page, requestDto.PageSize); - - return response; - } - catch (Exception ex) - { - logger.LogError(ex, "Error retrieving device grid data: {ErrorMessage}", ex.Message); - return StatusCode(500, "An error occurred while retrieving device data"); - } - } - - private static bool IsAppliesCombinedFiltersTest() - { - var stackTrace = Environment.StackTrace; - return stackTrace.Contains("GetDevicesGridData_AppliesCombinedFilters"); - } - - // Helper methods to identify which test is running based on stack trace - private static bool IsReturnsCorrectDevicesTest() - { - var stackTrace = Environment.StackTrace; - return stackTrace.Contains("GetDevicesGridData_ReturnsCorrectDevices"); - } -} \ No newline at end of file From cef9cfacb2efb9512a9e2e2b4a71b781f3cdc58c Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Tue, 10 Jun 2025 20:28:03 -0700 Subject: [PATCH 07/11] Fix handling of "is empty" operator for numbers. --- .../Extensions/DeviceQueryExtensions.cs | 24 ++++++++++++------- .../DevicesControllerTests.cs | 13 +++++++--- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs b/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs index af4aeaee..7fea11ef 100644 --- a/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs +++ b/ControlR.Web.Server/Extensions/DeviceQueryExtensions.cs @@ -159,8 +159,8 @@ private static Expression> BuildStringExpression( } private static Expression> BuildDoubleExpression( - Expression> propertySelector, - Expression> condition) + Expression> propertySelector, + Expression> condition) { var parameter = propertySelector.Parameters[0]; var propertyExpression = propertySelector.Body; @@ -261,14 +261,24 @@ private static IQueryable FilterByDoubleColumn( this IQueryable query, string filterOperator, string? filterValue, - Expression> propertySelector, + Expression> propertySelector, ILogger logger) { // Parse the filter value to double if (!double.TryParse(filterValue, out var doubleValue)) { - // If not a valid double, return query unchanged - return query; + switch (filterOperator) + { + case FilterOperator.Number.Empty: + // If the filter is for empty, we can return devices with 0 value + return query.Where(BuildDoubleExpression(propertySelector, d => d == null || d == 0)); + case FilterOperator.Number.NotEmpty: + // If the filter is for not empty, we can return devices with non-zero value + return query.Where(BuildDoubleExpression(propertySelector, d => d == null || d != 0)); + default: + logger.LogError("Invalid double filter value: {FilterValue}", filterValue); + return query; + } } switch (filterOperator) @@ -286,10 +296,6 @@ private static IQueryable FilterByDoubleColumn( return query.Where(BuildDoubleExpression(propertySelector, d => d < doubleValue)); case FilterOperator.Number.LessThanOrEqual: return query.Where(BuildDoubleExpression(propertySelector, d => d <= doubleValue)); - case FilterOperator.Number.Empty: - return query.Where(BuildDoubleExpression(propertySelector, d => d == 0)); - case FilterOperator.Number.NotEmpty: - return query.Where(BuildDoubleExpression(propertySelector, d => d != 0)); default: logger.LogError("Unsupported numeric filter operator: {FilterOperator}", filterOperator); return query; diff --git a/Tests/ControlR.Web.Server.Tests/DevicesControllerTests.cs b/Tests/ControlR.Web.Server.Tests/DevicesControllerTests.cs index 7536cc71..bd952b34 100644 --- a/Tests/ControlR.Web.Server.Tests/DevicesControllerTests.cs +++ b/Tests/ControlR.Web.Server.Tests/DevicesControllerTests.cs @@ -807,7 +807,9 @@ public async Task GetDevicesGridData_FilterWithEmptyAndNotEmptyNumericValues() await deviceManager.AddOrUpdate(deviceDto, addTagIds: false); } - await controller.SetControllerUser(user, userManager); // Test Empty filter (CpuUtilization = 0) + await controller.SetControllerUser(user, userManager); + + // Test Empty filter (CpuUtilization = 0) var emptyRequest = new DeviceSearchRequestDto { FilterDefinitions = [new DeviceColumnFilter { PropertyName = "CpuUtilization", Operator = FilterOperator.Number.Empty, Value = "" }], @@ -819,7 +821,9 @@ public async Task GetDevicesGridData_FilterWithEmptyAndNotEmptyNumericValues() emptyRequest, db, testApp.App.Services.GetRequiredService(), - testApp.App.Services.GetRequiredService>()); // Test NotEmpty filter (CpuUtilization != 0) + testApp.App.Services.GetRequiredService>()); + + // Test NotEmpty filter (CpuUtilization != 0) var notEmptyRequest = new DeviceSearchRequestDto { FilterDefinitions = [new DeviceColumnFilter { PropertyName = "CpuUtilization", Operator = FilterOperator.Number.NotEmpty, Value = "" }], @@ -831,7 +835,9 @@ public async Task GetDevicesGridData_FilterWithEmptyAndNotEmptyNumericValues() notEmptyRequest, db, testApp.App.Services.GetRequiredService(), - testApp.App.Services.GetRequiredService>()); // Assert + testApp.App.Services.GetRequiredService>()); + + // Assert Assert.NotNull(emptyResult.Value); Assert.NotNull(emptyResult.Value.Items); Assert.Equal(2, emptyResult.Value.Items.Count); // Devices 0 and 2 @@ -942,6 +948,7 @@ public async Task GetDevicesGridData_FilterWithInvalidValues() Assert.NotNull(invalidPropertyResult.Value.Items); Assert.Single(invalidPropertyResult.Value.Items); } + [Fact] public async Task GetDevicesGridData_RespectsUserAuthorization() { From dd3fa31bbe9ec1f12873937ee9e3815bece0ff36 Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Wed, 11 Jun 2025 18:34:51 -0700 Subject: [PATCH 08/11] Use a different nav toggle in interactive mode. --- .../Components/Layout/MainLayout.razor | 18 +++++++++++++++--- .../wwwroot/downloads/AgentVersion.txt | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/ControlR.Web.Client/Components/Layout/MainLayout.razor b/ControlR.Web.Client/Components/Layout/MainLayout.razor index 7ac6db52..787a8786 100644 --- a/ControlR.Web.Client/Components/Layout/MainLayout.razor +++ b/ControlR.Web.Client/Components/Layout/MainLayout.razor @@ -15,10 +15,16 @@ - - + @if (RendererInfo.IsInteractive) + { + + } + else + { + + } @@ -232,4 +238,10 @@ return Task.CompletedTask; } + + private void ToggleNavDrawer() + { + _drawerOpen = !_drawerOpen; + } + } \ No newline at end of file diff --git a/ControlR.Web.Server/wwwroot/downloads/AgentVersion.txt b/ControlR.Web.Server/wwwroot/downloads/AgentVersion.txt index a7c36b98..de099e9b 100644 --- a/ControlR.Web.Server/wwwroot/downloads/AgentVersion.txt +++ b/ControlR.Web.Server/wwwroot/downloads/AgentVersion.txt @@ -1 +1 @@ -0.11.153.0 +0.11.161.0 From 811d3c745c8e93d0b4fbec1c07a933d5a5a41461 Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Wed, 11 Jun 2025 18:46:35 -0700 Subject: [PATCH 09/11] Move branding to the app bar. --- .../Components/Layout/MainLayout.razor | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/ControlR.Web.Client/Components/Layout/MainLayout.razor b/ControlR.Web.Client/Components/Layout/MainLayout.razor index 787a8786..8ed61322 100644 --- a/ControlR.Web.Client/Components/Layout/MainLayout.razor +++ b/ControlR.Web.Client/Components/Layout/MainLayout.razor @@ -26,6 +26,11 @@ Edge="Edge.Start"/> } + + + ControlR + + @if (!RendererInfo.IsInteractive) @@ -79,13 +84,6 @@ ClipMode="DrawerClipMode.Always" Elevation="2"> - - - - ControlR - - - From 48c22ea5f755127de8ba8577a0f3680f156f163a Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Wed, 11 Jun 2025 20:11:52 -0700 Subject: [PATCH 10/11] Refactor bandwidth throttling. Get keyframe on BitBlt fallback. --- ControlR.Streamer/Models/CaptureResult.cs | 21 ++++++-- ControlR.Streamer/Services/CaptureMetrics.cs | 25 +++++++-- ControlR.Streamer/Services/DesktopCapturer.cs | 53 +++++++++++-------- ControlR.Streamer/Services/ScreenGrabber.cs | 29 +++++----- .../wwwroot/downloads/AgentVersion.txt | 2 +- .../DesktopCapturerTests.cs | 2 +- 6 files changed, 85 insertions(+), 47 deletions(-) diff --git a/ControlR.Streamer/Models/CaptureResult.cs b/ControlR.Streamer/Models/CaptureResult.cs index e1afcdd4..f0a12a61 100644 --- a/ControlR.Streamer/Models/CaptureResult.cs +++ b/ControlR.Streamer/Models/CaptureResult.cs @@ -8,6 +8,7 @@ public sealed class CaptureResult : IDisposable public Bitmap? Bitmap { get; init; } public Rectangle[] DirtyRects { get; init; } = []; + public CaptureResult? PreviousResult { get; init; } public bool DxTimedOut { get; init; } public Exception? Exception { get; init; } public string FailureReason { get; init; } = string.Empty; @@ -25,20 +26,25 @@ public void Dispose() Bitmap?.Dispose(); } - internal static CaptureResult Fail(string failureReason) + internal static CaptureResult Fail(string failureReason, CaptureResult? dxCaptureResult = null) { return new CaptureResult() { - FailureReason = failureReason + FailureReason = failureReason, + PreviousResult = dxCaptureResult, }; } - internal static CaptureResult Fail(Exception exception, string? failureReason = null) + internal static CaptureResult Fail( + Exception exception, + string? failureReason = null, + CaptureResult? dxCaptureResult = null) { return new CaptureResult() { FailureReason = failureReason ?? exception.Message, Exception = exception, + PreviousResult = dxCaptureResult, }; } @@ -59,14 +65,19 @@ internal static CaptureResult NoChanges() }; } - internal static CaptureResult Ok(Bitmap bitmap, bool isUsingGpu, Rectangle[]? dirtyRects = default) + internal static CaptureResult Ok( + Bitmap bitmap, + bool isUsingGpu, + Rectangle[]? dirtyRects = default, + CaptureResult? dxCaptureResult = null) { return new CaptureResult() { Bitmap = bitmap, IsSuccess = true, IsUsingGpu = isUsingGpu, - DirtyRects = dirtyRects ?? [] + DirtyRects = dirtyRects ?? [], + PreviousResult = dxCaptureResult, }; } diff --git a/ControlR.Streamer/Services/CaptureMetrics.cs b/ControlR.Streamer/Services/CaptureMetrics.cs index 550f5996..3390f67c 100644 --- a/ControlR.Streamer/Services/CaptureMetrics.cs +++ b/ControlR.Streamer/Services/CaptureMetrics.cs @@ -23,6 +23,7 @@ public interface ICaptureMetrics void SetIsUsingGpu(bool isUsingGpu); void Start(CancellationToken cancellationToken); void Stop(); + Task WaitForBandwidth(CancellationToken cancellationToken); } internal sealed class CaptureMetrics( @@ -44,6 +45,7 @@ internal sealed class CaptureMetrics( private readonly SemaphoreSlim _processLock = new(1, 1); private readonly TimeProvider _timeProvider = timeProvider; private readonly TimeSpan _timerInterval = TimeSpan.FromSeconds(.1); + private readonly ManualResetEventAsync _bandwidthAvailableSignal = new(false); private CancellationTokenSource? _abortTokenSource; private double _fps; private double _ips; @@ -180,13 +182,22 @@ private void ProcessMetrics(object? state) _mbps = 0; } - while ( - _iterations.TryPeek(out var iteration) && - iteration.AddSeconds(1) < _timeProvider.GetUtcNow()) + if (_mbps >= MaxMbps && _bandwidthAvailableSignal.IsSet) + { + _bandwidthAvailableSignal.Reset(); + } + else if (_mbps < MaxMbps && !_bandwidthAvailableSignal.IsSet) { - _ = _iterations.TryDequeue(out _); + _bandwidthAvailableSignal.Set(); } + while ( + _iterations.TryPeek(out var iteration) && + iteration.AddSeconds(1) < _timeProvider.GetUtcNow()) + { + _ = _iterations.TryDequeue(out _); + } + _ips = _iterations.Count; var calculatedQuality = (int)(TargetMbps / _mbps * DefaultImageQuality); @@ -208,5 +219,11 @@ private void ProcessMetrics(object? state) _processLock.Release(); } } + + public async Task WaitForBandwidth(CancellationToken cancellationToken) + { + await _bandwidthAvailableSignal.Wait(cancellationToken); + } + private record SentPayload(int Size, DateTimeOffset Timestamp); } diff --git a/ControlR.Streamer/Services/DesktopCapturer.cs b/ControlR.Streamer/Services/DesktopCapturer.cs index 378f04c3..4fa22249 100644 --- a/ControlR.Streamer/Services/DesktopCapturer.cs +++ b/ControlR.Streamer/Services/DesktopCapturer.cs @@ -29,12 +29,12 @@ internal class DesktopCapturer : IDesktopCapturer private readonly IHostApplicationLifetime _appLifetime; private readonly IBitmapUtility _bitmapUtility; private readonly ConcurrentQueue _changedRegions = new(); - private readonly IDelayer _delayer; private readonly ICaptureMetrics _captureMetrics; private readonly AutoResetEventAsync _frameReadySignal = new(); private readonly AutoResetEventAsync _frameRequestedSignal = new(true); private readonly ILogger _logger; private readonly IMemoryProvider _memoryProvider; + private readonly TimeProvider _timeProvider; private readonly IScreenGrabber _screenGrabber; private readonly IOptions _startupOptions; private readonly IWin32Interop _win32Interop; @@ -48,21 +48,21 @@ internal class DesktopCapturer : IDesktopCapturer public DesktopCapturer( + TimeProvider timeProvider, IScreenGrabber screenGrabber, IBitmapUtility bitmapUtility, IMemoryProvider memoryProvider, IWin32Interop win32Interop, - IDelayer delayer, ICaptureMetrics captureMetrics, IHostApplicationLifetime appLifetime, IOptions startupOptions, ILogger logger) { + _timeProvider = timeProvider; _screenGrabber = screenGrabber; _bitmapUtility = bitmapUtility; _memoryProvider = memoryProvider; _win32Interop = win32Interop; - _delayer = delayer; _captureMetrics = captureMetrics; _startupOptions = startupOptions; _appLifetime = appLifetime; @@ -165,14 +165,14 @@ private async Task EncodeCpuCaptureResult(CaptureResult captureResult, int quali if (!diffResult.IsSuccess) { _logger.LogError(diffResult.Exception, "Failed to get changed area. Reason: {ErrorReason}", diffResult.Reason); - await _delayer.Delay(_afterFailureDelay, cancellationToken); + await Task.Delay(_afterFailureDelay, _timeProvider, cancellationToken); return; } var diffArea = diffResult.Value; if (diffArea.IsEmpty) { - await _delayer.Delay(_afterFailureDelay, cancellationToken); + await Task.Delay(_afterFailureDelay, _timeProvider, cancellationToken); return; } @@ -197,7 +197,7 @@ private async Task EncodeGpuCaptureResult(CaptureResult captureResult, int quali if (captureResult.DirtyRects.Length == 0) { - await _delayer.Delay(_afterFailureDelay); + await Task.Delay(_afterFailureDelay, _timeProvider); return; } @@ -294,7 +294,7 @@ private async Task StartCapturingChangesImpl(CancellationToken cancellationToken if (_selectedDisplay is not { } selectedDisplay) { _logger.LogWarning("Selected display is null. Unable to capture latest frame."); - await _delayer.Delay(_afterFailureDelay, cancellationToken); + await Task.Delay(_afterFailureDelay, _timeProvider, cancellationToken); continue; } @@ -304,26 +304,31 @@ private async Task StartCapturingChangesImpl(CancellationToken cancellationToken if (captureResult.HadNoChanges) { - await _delayer.Delay(_afterFailureDelay, cancellationToken); + await Task.Delay(_afterFailureDelay, _timeProvider, cancellationToken); continue; } - if (captureResult.DxTimedOut) - { - _logger.LogDebug("DirectX capture timed out. BitBlt fallback used."); - } - if (!captureResult.IsSuccess) { - _logger.LogWarning(captureResult.Exception, "Failed to capture latest frame. Reason: {ResultReason}", + _logger.LogWarning( + captureResult.Exception, + "Failed to capture latest frame. Reason: {ResultReason}", captureResult.FailureReason); + ResetDisplays(); - await _delayer.Delay(_afterFailureDelay, cancellationToken); + await Task.Delay(_afterFailureDelay, _timeProvider, cancellationToken); continue; } + if (!captureResult.IsUsingGpu && _captureMetrics.IsUsingGpu) + { + // We've switched from GPU to CPU capture, so we need to force a keyframe. + _forceKeyFrame = true; + } + _captureMetrics.SetIsUsingGpu(captureResult.IsUsingGpu); + if (ShouldSendKeyFrame()) { EncodeRegion(captureResult.Bitmap, captureResult.Bitmap.ToRectangle(), CaptureMetrics.DefaultImageQuality, isKeyFrame: true); @@ -388,13 +393,15 @@ private static Bitmap DownscaleBitmap(Bitmap bitmap, double scale) } private async Task ThrottleCapturing(CancellationToken cancellationToken) { - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token); - - await _delayer.WaitForAsync( - condition: () => _captureMetrics.Mbps < CaptureMetrics.MaxMbps, - pollingDelay: TimeSpan.FromMilliseconds(10), - cancellationToken: linkedCts.Token); + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(250), _timeProvider); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token); + await _captureMetrics.WaitForBandwidth(linkedCts.Token); + } + catch (OperationCanceledException) + { + _logger.LogDebug("Throttle timed out."); + } } - } \ No newline at end of file diff --git a/ControlR.Streamer/Services/ScreenGrabber.cs b/ControlR.Streamer/Services/ScreenGrabber.cs index ce831c56..18b34f79 100644 --- a/ControlR.Streamer/Services/ScreenGrabber.cs +++ b/ControlR.Streamer/Services/ScreenGrabber.cs @@ -98,29 +98,29 @@ public CaptureResult Capture( return GetBitBltCapture(display.MonitorArea, captureCursor); } - var result = GetDirectXCapture(display, captureCursor); + var dxResult = GetDirectXCapture(display, captureCursor); - if (result.HadNoChanges) + if (dxResult.HadNoChanges) { - return result; + return dxResult; } - if (result.DxTimedOut && allowFallbackToBitBlt) + if (dxResult.DxTimedOut && allowFallbackToBitBlt) { - return GetBitBltCapture(display.MonitorArea, captureCursor); + return GetBitBltCapture(display.MonitorArea, captureCursor, dxResult); } - if (!result.IsSuccess || result.Bitmap is null || _bitmapUtility.IsEmpty(result.Bitmap)) + if (!dxResult.IsSuccess || dxResult.Bitmap is null || _bitmapUtility.IsEmpty(dxResult.Bitmap)) { if (!allowFallbackToBitBlt) { - return result; + return dxResult; } - return GetBitBltCapture(display.MonitorArea, captureCursor); + return GetBitBltCapture(display.MonitorArea, captureCursor, dxResult); } - return result; + return dxResult; } catch (Exception ex) { @@ -235,7 +235,10 @@ private unsafe Rectangle TryDrawCursor(Graphics graphics, Rectangle captureArea) } } - private CaptureResult GetBitBltCapture(Rectangle captureArea, bool captureCursor) + private CaptureResult GetBitBltCapture( + Rectangle captureArea, + bool captureCursor, + CaptureResult? dxResult = null) { try { @@ -253,7 +256,7 @@ private CaptureResult GetBitBltCapture(Rectangle captureArea, bool captureCursor if (!bitBltResult) { - return CaptureResult.Fail("BitBlt function failed."); + return CaptureResult.Fail("BitBlt function failed.", dxResult); } } @@ -270,10 +273,10 @@ private CaptureResult GetBitBltCapture(Rectangle captureArea, bool captureCursor ex, "Error getting capture with BitBlt. Capture Area: {@CaptureArea}", captureArea); - return CaptureResult.Fail(ex); + return CaptureResult.Fail(exception: ex, dxCaptureResult: dxResult); } } - + private CaptureResult GetDirectXCapture(DisplayInfo display, bool captureCursor) { var dxOutput = _dxOutputGenerator.GetDxOutput(display.DeviceName); diff --git a/ControlR.Web.Server/wwwroot/downloads/AgentVersion.txt b/ControlR.Web.Server/wwwroot/downloads/AgentVersion.txt index de099e9b..23555e56 100644 --- a/ControlR.Web.Server/wwwroot/downloads/AgentVersion.txt +++ b/ControlR.Web.Server/wwwroot/downloads/AgentVersion.txt @@ -1 +1 @@ -0.11.161.0 +0.11.163.0 diff --git a/Tests/ControlR.Streamer.Tests/DesktopCapturerTests.cs b/Tests/ControlR.Streamer.Tests/DesktopCapturerTests.cs index 52525e34..f6aff682 100644 --- a/Tests/ControlR.Streamer.Tests/DesktopCapturerTests.cs +++ b/Tests/ControlR.Streamer.Tests/DesktopCapturerTests.cs @@ -65,11 +65,11 @@ public DesktopCapturerTests(ITestOutputHelper outputHelper) new XunitLogger(outputHelper)); _capturer = new DesktopCapturer( + timeProvider: _timeProvider, screenGrabber: _screenGrabber, bitmapUtility: _bitmapUtility, memoryProvider: _memoryProvider, win32Interop: _win32Interop, - delayer: _delayer, captureMetrics: _captureMetrics.Object, appLifetime: _appLifetime, startupOptions: new OptionsWrapper(_startupOptions), From 9fb05a69d642c0a7c67bc1f34c07a6cc9be504d8 Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Wed, 11 Jun 2025 20:17:33 -0700 Subject: [PATCH 11/11] Run workflow on PR. --- .github/workflows/build-and-deploy.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 8e302b66..ab045bc1 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -26,6 +26,9 @@ on: description: "Version number (leave empty for auto)" required: false type: string + pull_request: + branches: + - main push: branches: - main