diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs index f2e5395560..5210f5b5a8 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs @@ -61,9 +61,8 @@ public async Task GetChannelAsync() } // Create new channel - var channel = await _channelProvider.GetChannelAsync() ?? throw new InvalidOperationException("Channel cannot be null."); - await EnsureQueueDeclaredAsync(channel); - return channel; + await EnsureQueueDeclaredAsync(); + return await _channelProvider.GetChannelAsync() ?? throw new InvalidOperationException("Channel cannot be null."); } catch { @@ -180,7 +179,7 @@ public void ReturnChannel(IChannel channel) } } - private async Task EnsureQueueDeclaredAsync(IChannel channel) + private async Task EnsureQueueDeclaredAsync() { if (_queueDeclared) return; @@ -189,7 +188,7 @@ private async Task EnsureQueueDeclaredAsync(IChannel channel) { if (_queueDeclared) return; - await DeclareQueueAsync(channel); + await DeclareQueueAsync(); _queueDeclared = true; } catch (Exception ex) @@ -203,84 +202,141 @@ private async Task EnsureQueueDeclaredAsync(IChannel channel) } } - private async Task DeclareQueueAsync(IChannel channel) + // Every declaration runs on its own short-lived channel: a 406 PRECONDITION_FAILED closes the + // channel it happened on, so it must never be a channel handed back to a publisher or consumer, + // nor one reused for the next declaration. + private async Task DeclareQueueAsync() { + var dlxName = $"{_queueName}.dlx"; + var dlqName = $"{_queueName}{QueueingConstants.DeadletterAddition}"; + + // Main topology (DLX, DLQ, main queue) is required for publishing and consuming. try { - var dlxName = $"{_queueName}.dlx"; - var dlqName = $"{_queueName}{QueueingConstants.DeadletterAddition}"; - var retryExchange = $"{_queueName}{QueueingConstants.RetryAddition}"; - - // Ensure DLX exchange exists - await channel.ExchangeDeclareAsync(dlxName, ExchangeType.Direct, durable: true, autoDelete: false, arguments: null); + await WithDeclarationChannelAsync(async channel => + { + await channel.ExchangeDeclareAsync(dlxName, ExchangeType.Direct, durable: true, autoDelete: false, arguments: null); - // Ensure DLQ exists and is bound to DLX - await channel.QueueDeclareAsync(dlqName, durable: true, exclusive: false, autoDelete: false, - arguments: new Dictionary - { - { "x-queue-type", "quorum" }, - { "x-overflow", "reject-publish" } - }); - await channel.QueueBindAsync(dlqName, dlxName, dlqName, arguments: null); + await channel.QueueDeclareAsync(dlqName, durable: true, exclusive: false, autoDelete: false, + arguments: new Dictionary + { + { "x-queue-type", "quorum" }, + { "x-overflow", "reject-publish" } + }); + await channel.QueueBindAsync(dlqName, dlxName, dlqName, arguments: null); - await channel.ExchangeDeclareAsync(retryExchange, ExchangeType.Direct, durable: true, autoDelete: false, arguments: null); - foreach (var retryDelay in QueueingConstants.RetryDelays) - { - var retryQueue = $"{retryExchange}.{GetRetryDelayKey(retryDelay)}"; await channel.QueueDeclareAsync( - retryQueue, + _queueName, durable: true, exclusive: false, autoDelete: false, arguments: new Dictionary { { "x-queue-type", "quorum" }, - { "x-message-ttl", checked((int)retryDelay.TotalMilliseconds) }, - { "x-dead-letter-exchange", $"{_queueName}.exchange" }, - { "x-dead-letter-routing-key", _queueName }, { "x-overflow", "reject-publish" }, - { "x-dead-letter-strategy", "at-least-once" } + { "x-dead-letter-exchange", dlxName }, + { "x-dead-letter-routing-key", dlqName }, + { "x-dead-letter-strategy", "at-least-once" }, + { "x-delivery-limit", 10 } }); - await channel.QueueBindAsync(retryQueue, retryExchange, retryQueue, arguments: null); - } + }); + } + catch (global::RabbitMQ.Client.Exceptions.OperationInterruptedException ex) when (IsInequivalentArgs(ex)) + { + _logger.LogWarning( + ex, + "Queue {QueueName} exists with incompatible config. Using existing queue in compatibility mode.", + _queueName); + } - // Declare main queue with DLX args - await channel.QueueDeclareAsync( - _queueName, - durable: true, - exclusive: false, - autoDelete: false, - arguments: new Dictionary - { - { "x-queue-type", "quorum" }, - { "x-overflow", "reject-publish" }, - { "x-dead-letter-exchange", dlxName }, - { "x-dead-letter-routing-key", dlqName }, - { "x-dead-letter-strategy", "at-least-once" }, - { "x-delivery-limit", 10 } - }); + await WithDeclarationChannelAsync(BindToExchangeAsync); - await BindToExchangeAsync(channel); + // Retry topology is best-effort: a mismatch here must not block the main publish/consume path. + await DeclareRetryQueuesAsync(); + } + + private async Task DeclareRetryQueuesAsync() + { + var retryExchange = $"{_queueName}{QueueingConstants.RetryAddition}"; + + try + { + await WithDeclarationChannelAsync(channel => + channel.ExchangeDeclareAsync(retryExchange, ExchangeType.Direct, durable: true, autoDelete: false, arguments: null)); } - catch (global::RabbitMQ.Client.Exceptions.OperationInterruptedException ex) + catch (Exception ex) { - if (ex.ShutdownReason?.ReplyCode == 406 && - ex.ShutdownReason.ReplyText.Contains("inequivalent arg")) + _logger.LogError(ex, "Failed to declare retry exchange {RetryExchange}; failed messages cannot be scheduled for retry.", retryExchange); + return; + } + + foreach (var retryDelay in QueueingConstants.RetryDelays) + { + var retryQueue = $"{retryExchange}.{GetRetryDelayKey(retryDelay)}"; + try + { + await WithDeclarationChannelAsync(async channel => + { + await channel.QueueDeclareAsync( + retryQueue, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new Dictionary + { + { "x-queue-type", "quorum" }, + { "x-message-ttl", checked((int)retryDelay.TotalMilliseconds) }, + { "x-dead-letter-exchange", $"{_queueName}.exchange" }, + { "x-dead-letter-routing-key", _queueName }, + { "x-overflow", "reject-publish" }, + { "x-dead-letter-strategy", "at-least-once" } + }); + }); + } + catch (global::RabbitMQ.Client.Exceptions.OperationInterruptedException ex) when (IsInequivalentArgs(ex)) { _logger.LogWarning( ex, - "Queue {QueueName} exists with incompatible config. Using existing queue in compatibility mode.", - _queueName); + "Retry queue {RetryQueue} exists with incompatible config; using it as-is. Delete the queue to have it redeclared with current arguments.", + retryQueue); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to declare retry queue {RetryQueue}.", retryQueue); + continue; + } - await BindToExchangeAsync(channel); + try + { + await WithDeclarationChannelAsync(channel => + channel.QueueBindAsync(retryQueue, retryExchange, retryQueue, arguments: null)); } - else + catch (Exception ex) { - throw; + _logger.LogError(ex, "Failed to bind retry queue {RetryQueue} to {RetryExchange}.", retryQueue, retryExchange); } } } + private async Task WithDeclarationChannelAsync(Func declare) + { + var channel = await _channelProvider.GetChannelAsync() + ?? throw new InvalidOperationException($"Unable to obtain a channel to declare topology for queue {_queueName}."); + try + { + await declare(channel); + } + finally + { + // The underlying provider disposes the channel instead of pooling it if a 406 closed it. + _channelProvider.ReturnChannel(channel); + } + } + + private static bool IsInequivalentArgs(global::RabbitMQ.Client.Exceptions.OperationInterruptedException ex) => + ex.ShutdownReason?.ReplyCode == 406 && + ex.ShutdownReason.ReplyText.Contains("inequivalent arg"); + private static string GetRetryDelayKey(TimeSpan delay) => $"{(int)delay.TotalSeconds}s"; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/EfCoreAuditLogRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/EfCoreAuditLogRepository.cs index e4e816ad99..2cda176f72 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/EfCoreAuditLogRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/EfCoreAuditLogRepository.cs @@ -136,7 +136,10 @@ public virtual async Task> GetEntityChangeByTypeW }))); var totalCount = await query.LongCountAsync(GetCancellationToken(cancellationToken)); - var sortedQuery = ApplySorting(query, sorting); + var userOrder = await GetUserOrderAsync(sorting, cancellationToken); + var sortedQuery = userOrder != null + ? ApplyUserSorting(query, userOrder, sorting!) + : ApplySorting(query, sorting); var items = await sortedQuery .Skip(skipCount) .Take(maxResultCount) @@ -145,6 +148,45 @@ public virtual async Task> GetEntityChangeByTypeW return (totalCount, items); } + private static bool IsUserSorting(string? sorting, out bool bySurname, out bool descending) + { + var parts = (sorting ?? string.Empty).Trim().ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries); + var field = parts.Length > 0 ? parts[0] : string.Empty; + bySurname = field == "usersurname"; + descending = parts.Length > 1 && parts[1] == "desc"; + return field is "userfirstname" or "usersurname"; + } + + // Identity users live in a different DbContext, so users are ranked in memory and the rank is translated to array_position. + private async Task GetUserOrderAsync(string? sorting, CancellationToken cancellationToken) + { + if (!IsUserSorting(sorting, out var bySurname, out _)) + { + return null; + } + + var users = await identityUserRepository.GetListAsync( + sorting: null, maxResultCount: int.MaxValue, skipCount: 0, includeDetails: false, + cancellationToken: GetCancellationToken(cancellationToken)); + + var ordered = bySurname + ? users.OrderBy(u => u.Surname ?? string.Empty, StringComparer.OrdinalIgnoreCase).ThenBy(u => u.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase) + : users.OrderBy(u => u.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase).ThenBy(u => u.Surname ?? string.Empty, StringComparer.OrdinalIgnoreCase); + + return ordered.Select(u => u.Id).ToArray(); + } + + private static IOrderedQueryable ApplyUserSorting( + IQueryable query, + Guid[] userOrder, + string sorting) + { + IsUserSorting(sorting, out _, out var descending); + return descending + ? query.OrderByDescending(x => x.UserId == null ? -1 : Array.IndexOf(userOrder, x.UserId.Value)).ThenByDescending(x => x.ChangeTime) + : query.OrderBy(x => x.UserId == null ? -1 : Array.IndexOf(userOrder, x.UserId.Value)).ThenByDescending(x => x.ChangeTime); + } + private static IOrderedQueryable ApplySorting( IQueryable query, string? sorting) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs index f594c25120..5c665cabfc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/FormNotificationsApiController.cs @@ -170,7 +170,7 @@ public async Task> GetResolvedTemplateRecipi if (string.Equals(identifier, "AdditionalContact", StringComparison.OrdinalIgnoreCase)) { - emailAddresses.UnionWith(await _scheduledNotificationHelper.GetAdditionalContactEmailsAsync(applicationId.Value)); + emailAddresses.UnionWith(await scheduledNotificationHelper.GetAdditionalContactEmailsAsync(applicationId.Value)); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/UnityAdmin/Index.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/UnityAdmin/Index.css index 51ba040be7..b1a25b3400 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/UnityAdmin/Index.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/UnityAdmin/Index.css @@ -41,12 +41,56 @@ padding-right: 0; flex-grow: 1; flex-shrink: 1; - flex-basis: 0; + flex-basis: auto; min-width: 0; width: auto; max-height: none; } +/* The Endpoints stylesheet bundled on this page forces #AbpContentToolbar visible; + keep it tight so the admin sections sit directly beneath it. */ +#AbpContentToolbar { + padding: 0; + margin-top: 10px; +} + +#reconciliation-report-div, +#audit-logs-div, +#audit-log-settings-div { + margin-top: 0; +} + +#audit-logs-div .reconciliation-filter-inputs { + align-items: center; +} + +#audit-logs-div .reconciliation-filter-inputs > div:has(> #audit-search) { + display: flex; + align-items: center; + align-self: center; + width: 250px; + height: auto; + margin: 0 10px; +} + +#audit-logs-div .tbl-search-label { + display: none; +} + +#audit-search, +#audit-entity-type { + height: 38px; + box-sizing: border-box; +} + +#audit-search { + display: block; + width: 100%; + padding: 6px 12px; + margin-top: 0; + align-self: flex-end; +} + #audit-logs-div .reconciliation-filter-buttons { align-items: flex-end !important; margin-left: auto; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/UnityAdmin/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/UnityAdmin/Index.js index b9a400e2bf..dc9c3ac795 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/UnityAdmin/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/UnityAdmin/Index.js @@ -5,7 +5,6 @@ $(function () { let recDt = null; let auditDt = null; let filterData = {}; - let auditSearchTimer = null; const UIElements = { reconciliationReportMenu: $('#reconciliation-report-menu-item'), @@ -468,9 +467,72 @@ $(function () { reloadAuditTable(); } + // Client-side only: hides loaded rows that don't contain the search text, without a server round trip function handleAuditSearchInput() { - clearTimeout(auditSearchTimer); - auditSearchTimer = setTimeout(reloadAuditTable, 300); + if (!auditDt) { + return; + } + + const term = (UIElements.auditSearch.val() || '').trim().toLowerCase(); + const rows = $(auditDt.rows({ page: 'current' }).nodes()); + let visibleCount = 0; + rows.each(function () { + const isMatch = !term || $(this).text().toLowerCase().includes(term); + $(this).toggle(isMatch); + if (isMatch) { + visibleCount++; + } + }); + + // Paging and totals come from the server, so describe the search result in terms of the current page + const container = $(auditDt.table().container()); + const info = container.find('.dt-info, .dataTables_info'); + info.find('.audit-search-info').remove(); + info.children().not('.audit-search-info').show(); + + const body = $(auditDt.table().body()); + body.find('.audit-search-empty').remove(); + + if (!term) { + return; + } + + if (info.children().length === 0) { + info.wrapInner(''); + } + // Use the table's own language strings so the info and empty row read like a standard DataTables search + const settings = auditDt.settings()[0]; + const lang = settings.oLanguage; + info.children().hide(); + info.append($('', { + class: 'audit-search-info', + text: formatAuditSearchInfo(settings, visibleCount ? lang.sInfo : lang.sInfoEmpty, visibleCount) + + ' ' + formatAuditSearchInfo(settings, lang.sInfoFiltered, visibleCount, rows.length) + })); + + if (visibleCount === 0 && rows.length > 0) { + body.append($('', { class: 'audit-search-empty' }).append($('', { + class: 'dt-empty', + colspan: auditDt.columns(':visible').count(), + text: lang.sZeroRecords + }))); + } + } + + // Mirrors DataTables' own info macros, scoped to the rows on the current page + function formatAuditSearchInfo(settings, template, count, max) { + const total = max ?? count; + const format = number => settings.fnFormatNumber(number); + const entries = number => settings.api.i18n('entries', '', number); + return (template || '') + .replaceAll('_START_', format(count ? 1 : 0)) + .replaceAll('_END_', format(count)) + .replaceAll('_MAX_', format(total)) + .replaceAll('_TOTAL_', format(count)) + .replaceAll('_ENTRIES_', entries(count)) + .replaceAll('_ENTRIES-MAX_', entries(total)) + .replaceAll('_ENTRIES-TOTAL_', entries(count)) + .trim(); } function reloadAuditTable() { @@ -510,10 +572,9 @@ $(function () { endTime: dates.endTime, entityTypeFullName: UIElements.auditEntityType.val() || null, changeType: UIElements.auditChangeType.val() ? Number(UIElements.auditChangeType.val()) : null, - filter: UIElements.auditSearch.val() || null, propertyName: getAuditColumnSearch(2), - serviceName: getAuditColumnSearch(8), - methodName: getAuditColumnSearch(9) + serviceName: getAuditColumnSearch(7), + methodName: getAuditColumnSearch(8) }; } ), @@ -521,14 +582,13 @@ $(function () { { title: 'Change time', data: 'changeTime', render: formatAuditDate }, { title: 'Entity', data: 'entityName', name: 'entityName', searchable: true, orderable: false, render: formatAuditEntityLink }, { title: 'Property', data: 'propertyName', render: $.fn.dataTable.render.text() }, - { title: 'Original value', data: 'originalValue', render: $.fn.dataTable.render.text() }, - { title: 'New value', data: 'newValue', render: $.fn.dataTable.render.text() }, + { title: 'Original value', data: 'originalValue', orderable: false, render: $.fn.dataTable.render.text() }, + { title: 'New value', data: 'newValue', orderable: false, render: $.fn.dataTable.render.text() }, { title: 'Change', data: 'changeType', render: formatChangeType }, - { title: 'Name', data: 'userFirstName', render: $.fn.dataTable.render.text() }, - { title: 'Surname', data: 'userSurname', render: $.fn.dataTable.render.text() }, - { title: 'Service', data: 'serviceName', render: $.fn.dataTable.render.text() }, - { title: 'Method', data: 'methodName', render: $.fn.dataTable.render.text() }, - { title: 'URL', data: 'url', render: $.fn.dataTable.render.text() } + { title: 'User', data: 'userFirstName', render: formatAuditUser }, + { title: 'Service', data: 'serviceName', orderable: false, render: $.fn.dataTable.render.text() }, + { title: 'Method', data: 'methodName', orderable: false, render: $.fn.dataTable.render.text() }, + { title: 'URL', data: 'url', orderable: false, render: $.fn.dataTable.render.text() } ], processing: true }) @@ -543,12 +603,19 @@ $(function () { }); } + // Re-apply the search to each newly loaded page + auditDt.on('draw', handleAuditSearchInput); } function getAuditColumnSearch(index) { return auditDt ? auditDt.column(index).search() || null : null; } + function formatAuditUser(data, renderType, row) { + const fullName = [row?.userFirstName, row?.userSurname].filter(Boolean).join(' '); + return $('', { text: fullName }).prop('outerHTML'); + } + function formatAuditDate(data) { const formattedDate = data ? luxon.DateTime.fromISO(data, { locale: abp.localization.currentCulture.name }).toLocaleString(luxon.DateTime.DATETIME_MED)