From 8d4e273b424a03a7d37aec13996fc4938cdf297c Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 27 Aug 2026 11:10:43 +0300 Subject: [PATCH 01/14] feat: let an aggregation's roll-up target be set on create, and record its runs AggregationTarget lived on SubscriptionUpdate only, so every aggregation was born on Input regardless of what was asked for; it now flows through the shared create/update applier like every other pipeline field. AggregationJob also writes a run record now (into the receive_attempt table, same shape a receiver uses), so a roll-up's history isn't silent. --- .../Resources/Subscriptions/Search.cs | 5 + .../SubscriptionConfigurationApplier.cs | 3 + SW.Bitween.Api/Services/AggregationJob.cs | 40 +++ .../Tests/AggregationTests.cs | 238 ++++++++++++++++++ SW.Bitween.Sdk/Model/Subscription.cs | 12 +- 5 files changed, 297 insertions(+), 1 deletion(-) diff --git a/SW.Bitween.Api/Resources/Subscriptions/Search.cs b/SW.Bitween.Api/Resources/Subscriptions/Search.cs index 0083bdfb..302bfd08 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/Search.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/Search.cs @@ -56,6 +56,11 @@ join document in _dbContext.Set() on subscriber.DocumentId equals docu AggregationForId = subscriber.AggregationForId, Temporary = subscriber.Temporary, ReceiveOn = subscriber.ReceiveOn, + // The next-fire time of the other scheduled type. Left out, an aggregation + // row had no next run to show at all — ReceiveOn is only ever set for + // Receiving, and the list page reads one field for both. + AggregateOn = subscriber.AggregateOn, + AggregationTarget = subscriber.AggregationTarget, PausedOn = subscriber.PausedOn, IsRunning = subscriber.IsRunning, ConsecutiveFailures = subscriber.ConsecutiveFailures, diff --git a/SW.Bitween.Api/Resources/Subscriptions/SubscriptionConfigurationApplier.cs b/SW.Bitween.Api/Resources/Subscriptions/SubscriptionConfigurationApplier.cs index f9264d4e..a6a17b0e 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/SubscriptionConfigurationApplier.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/SubscriptionConfigurationApplier.cs @@ -37,6 +37,9 @@ public static async Task Apply(BitweenDbContext dbContext, Subscription entity, entity.WorkGroupId = model.WorkGroupId; entity.ResponseSubscriptionId = model.ResponseSubscriptionId; entity.ResponseMessageTypeName = model.ResponseMessageTypeName; + // Meaningless for every other type, where it stays at its default — but harmless + // there, and applying it unconditionally is what stops create and update disagreeing. + entity.AggregationTarget = model.AggregationTarget; // Only when the caller said something about them. A Receiving subscription with no // schedules is what SetSchedules throws on, and a create that mentions no schedule at diff --git a/SW.Bitween.Api/Services/AggregationJob.cs b/SW.Bitween.Api/Services/AggregationJob.cs index 818e661f..fceb6761 100644 --- a/SW.Bitween.Api/Services/AggregationJob.cs +++ b/SW.Bitween.Api/Services/AggregationJob.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; using SW.Bitween.Domain; +using SW.Bitween.Model; using SW.Scheduler; using System; using System.Linq; @@ -25,6 +26,15 @@ public async Task Execute(AggregationJobParams jobParams) if (aggSub == null) return; + var startedOn = DateTime.UtcNow; + // Decided as the run goes and written once at the end. Recording inside both the try + // and the catch wrote two rows for a single run whenever something threw after the + // roll-up was already built — SetSchedules does exactly that on an aggregation that + // somehow has no schedule. + var outcome = ReceiveOutcome.NoNewData; + string errorMessage = null; + string[] createdExchangeIds = []; + try { var xchangeQuery = @@ -46,7 +56,11 @@ from agg in xa.DefaultIfEmpty() var aggXchange = await xchangeService.CreateXchange(aggSub, xchangeAggregationFile); dbContext.Add(aggXchange); targetXchangeList.ForEach(id => dbContext.Add(new XchangeAggregation(id, aggXchange.Id))); + outcome = ReceiveOutcome.Received; + createdExchangeIds = [aggXchange.Id]; } + // Otherwise the outcome stays NoNewData: nothing outstanding is an ordinary, quiet + // result, the same thing a receiver reports when the folder it polls is empty. aggSub.SetSchedules(); aggSub.SetHealth(); @@ -54,9 +68,35 @@ from agg in xa.DefaultIfEmpty() catch (Exception ex) { aggSub.SetHealth(ex.ToString()); + errorMessage = ex.ToString(); + // A run that already built its roll-up did the thing it exists to do; a throw after + // that is bookkeeping, and calling the run failed would misreport delivered work. + // The error is kept on the attempt either way, and on the integration's health. + if (createdExchangeIds.Length == 0) outcome = ReceiveOutcome.Failed; logger.LogError(ex, "Error processing aggregation for subscription {SubscriptionId}", jobParams.SubscriptionId); } + RecordAttempt(aggSub.Id, startedOn, outcome, errorMessage, createdExchangeIds); + await dbContext.SaveChangesAsync(); } + + /// + /// Writes the run into the same history a receiver's runs go into, which is what lets one + /// table on the integration page show a run beside the exchange it produced. The entity is + /// named for the job that came first; nothing on it is specific to receiving. + /// + private void RecordAttempt(int subscriptionId, DateTime startedOn, ReceiveOutcome outcome, + string errorMessage, string[] exchangeIds) + { + dbContext.Add(new ReceiveAttempt + { + SubscriptionId = subscriptionId, + StartedOn = startedOn, + FinishedOn = DateTime.UtcNow, + Outcome = outcome, + ErrorMessage = errorMessage, + ExchangeIds = exchangeIds, + }); + } } diff --git a/SW.Bitween.IntegrationTests/Tests/AggregationTests.cs b/SW.Bitween.IntegrationTests/Tests/AggregationTests.cs index 9171eff6..aacfdb48 100644 --- a/SW.Bitween.IntegrationTests/Tests/AggregationTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/AggregationTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -157,4 +158,241 @@ public async Task Aggregation_job_does_nothing_for_inactive_subscription() var count = await db.Set().CountAsync(x => x.SubscriptionId == aggSub.Id); Assert.Equal(0, count); } + + // ─── Run history ──────────────────────────────────────────────────────── + // + // Every run is written into the same history a receiver's runs go into, so the integration + // page can show one table of runs with the exchange each produced instead of two tables + // that cannot be joined. Before this, an aggregation recorded nothing at all. + + [Fact] + public async Task A_run_that_rolls_something_up_records_the_exchange_it_made() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var job = scope.ServiceProvider.GetRequiredService(); + var cache = _fixture.App.Services.GetRequiredService(); + + var sourceDoc = new Document(null, "Agg Attempt Doc", DocumentFormat.Json); + db.Set().Add(sourceDoc); + await db.SaveChangesAsync(); + var sourceSub = new Subscription("Agg Attempt Source", sourceDoc.Id); + sourceSub.Inactive = false; + db.Set().Add(sourceSub); + await db.SaveChangesAsync(); + + var xchange = new Xchange(sourceSub, new XchangeFile("{\"n\":1}")); + db.Set().Add(xchange); + await db.SaveChangesAsync(); + db.Set().Add(new XchangeResult(xchange.Id, null, null)); + await db.SaveChangesAsync(); + + var aggSub = new Subscription("Agg Attempt", sourceSub.Id, Partner.SystemId); + aggSub.Inactive = false; + // A real aggregation always has one — SetSchedules throws without it, and the run would + // then report a failure it did not have. + aggSub.SetSchedules([new Schedule(Recurrence.Daily, TimeSpan.FromHours(2))]); + db.Set().Add(aggSub); + await db.SaveChangesAsync(); + + cache.Revoke(); + + await job.Execute(new AggregationJobParams(aggSub.Id, null)); + + var attempt = await db.Set().SingleAsync(a => a.SubscriptionId == aggSub.Id); + Assert.Equal(ReceiveOutcome.Received, attempt.Outcome); + Assert.Null(attempt.ErrorMessage); + + var rollUp = await db.Set().SingleAsync(x => x.SubscriptionId == aggSub.Id); + Assert.Equal([rollUp.Id], attempt.ExchangeIds); + } + + [Fact] + public async Task A_run_with_nothing_outstanding_records_no_new_data_rather_than_nothing() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var job = scope.ServiceProvider.GetRequiredService(); + var cache = _fixture.App.Services.GetRequiredService(); + + var sourceDoc = new Document(null, "Agg Empty Attempt Doc", DocumentFormat.Json); + db.Set().Add(sourceDoc); + await db.SaveChangesAsync(); + // A source with no exchanges at all — the run has nothing to collect. + var sourceSub = new Subscription("Agg Empty Attempt Source", sourceDoc.Id); + sourceSub.Inactive = false; + db.Set().Add(sourceSub); + await db.SaveChangesAsync(); + + var aggSub = new Subscription("Agg Empty Attempt", sourceSub.Id, Partner.SystemId); + aggSub.Inactive = false; + // A real aggregation always has one — SetSchedules throws without it, and the run would + // then report a failure it did not have. + aggSub.SetSchedules([new Schedule(Recurrence.Daily, TimeSpan.FromHours(2))]); + db.Set().Add(aggSub); + await db.SaveChangesAsync(); + + cache.Revoke(); + + await job.Execute(new AggregationJobParams(aggSub.Id, null)); + + var attempt = await db.Set().SingleAsync(a => a.SubscriptionId == aggSub.Id); + Assert.Equal(ReceiveOutcome.NoNewData, attempt.Outcome); + Assert.Empty(attempt.ExchangeIds); + // The quiet outcome still creates no exchange — that part has not changed. + Assert.Equal(0, await db.Set().CountAsync(x => x.SubscriptionId == aggSub.Id)); + } + + [Fact] + public async Task An_inactive_aggregation_records_no_run_at_all() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var job = scope.ServiceProvider.GetRequiredService(); + + var sourceDoc = new Document(null, "Agg Skipped Attempt Doc", DocumentFormat.Json); + db.Set().Add(sourceDoc); + await db.SaveChangesAsync(); + var sourceSub = new Subscription("Agg Skipped Attempt Source", sourceDoc.Id); + db.Set().Add(sourceSub); + await db.SaveChangesAsync(); + + // Inactive by default — the job returns before it does anything, and a run that never + // happened must not appear in the history as a quiet success. + var aggSub = new Subscription("Agg Skipped Attempt", sourceSub.Id, Partner.SystemId); + db.Set().Add(aggSub); + await db.SaveChangesAsync(); + + await job.Execute(new AggregationJobParams(aggSub.Id, null)); + + Assert.Empty(await db.Set().Where(a => a.SubscriptionId == aggSub.Id).ToListAsync()); + } + + // ─── Configuration ────────────────────────────────────────────────────── + // + // Which file a roll-up links to is the one aggregation setting no UI has ever offered. + // It reached the entity only through the generic property copy in the update handler, so + // create could not set it at all and every aggregation started on Input whatever the + // caller asked for. These pin the field to the shared applier both handlers run. + + private static int _seq; + private static string Unique(string prefix) => $"{prefix}-{Interlocked.Increment(ref _seq)}"; + + /// An integration to roll up, and a partner to attribute the roll-up to. + private async Task<(int sourceId, int partnerId)> Groundwork() + { + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var doc = new Document(null, Unique("Agg config doc"), DocumentFormat.Json); + db.Set().Add(doc); + var partner = new Partner(Unique("Agg config partner")); + db.Set().Add(partner); + await db.SaveChangesAsync(); + + var source = new Subscription(Unique("Agg config source"), doc.Id); + db.Set().Add(source); + await db.SaveChangesAsync(); + + return (source.Id, partner.Id); + } + + private static ScheduleView[] Daily() => + [new ScheduleView { Recurrence = Recurrence.Daily, Hours = 2 }]; + + private async Task CreateAggregation(int sourceId, int partnerId, XchangeFileType? target) + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + + var model = new SubscriptionCreate + { + Name = Unique("Agg config"), + Type = SubscriptionType.Aggregation, + AggregationForId = sourceId, + PartnerId = partnerId, + Schedules = Daily(), + }; + // Left unset on purpose by the caller testing the default, so that the test proves the + // default rather than the applier's willingness to copy whatever it was handed. + if (target.HasValue) model.AggregationTarget = target.Value; + + return (int)await handler.Handle(model); + } + + [Fact] + public async Task An_aggregation_can_be_created_collecting_the_mapped_file() + { + var (sourceId, partnerId) = await Groundwork(); + var id = await CreateAggregation(sourceId, partnerId, XchangeFileType.Output); + + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var entity = await db.Set().SingleAsync(s => s.Id == id); + + Assert.Equal(XchangeFileType.Output, entity.AggregationTarget); + } + + [Fact] + public async Task An_aggregation_collects_what_came_in_unless_told_otherwise() + { + var (sourceId, partnerId) = await Groundwork(); + var id = await CreateAggregation(sourceId, partnerId, null); + + await using var scope = _fixture.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var entity = await db.Set().SingleAsync(s => s.Id == id); + + Assert.Equal(XchangeFileType.Input, entity.AggregationTarget); + } + + [Fact] + public async Task Changing_which_file_is_collected_is_kept() + { + var (sourceId, partnerId) = await Groundwork(); + var id = await CreateAggregation(sourceId, partnerId, XchangeFileType.Input); + + await using (var scope = _fixture.CreateScope()) + { + scope.Superuser(); + var update = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await update.Handle(id, new SubscriptionUpdate + { + Name = Unique("Agg config renamed"), + AggregationForId = sourceId, + PartnerId = partnerId, + Schedules = Daily(), + AggregationTarget = XchangeFileType.Response, + }); + } + + await using var check = _fixture.CreateScope(); + var db = check.ServiceProvider.GetRequiredService(); + var entity = await db.Set().SingleAsync(s => s.Id == id); + + Assert.Equal(XchangeFileType.Response, entity.AggregationTarget); + } + + [Fact] + public async Task The_list_reports_when_an_aggregation_next_runs() + { + var (sourceId, partnerId) = await Groundwork(); + var id = await CreateAggregation(sourceId, partnerId, XchangeFileType.Output); + + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var search = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + + var response = (SearchyResponse)await search.Handle( + new SearchyRequest { PageSize = 500, PageIndex = 0 }); + var row = response.Result.Single(r => r.Id == id); + + // ReceiveOn is only ever set for Receiving, and the list page reads one next-run field + // for both scheduled types — so without AggregateOn in the projection every aggregation + // row showed no next run at all, for a job that plainly has one. + Assert.NotNull(row.AggregateOn); + Assert.Null(row.ReceiveOn); + Assert.Equal(XchangeFileType.Output, row.AggregationTarget); + } } diff --git a/SW.Bitween.Sdk/Model/Subscription.cs b/SW.Bitween.Sdk/Model/Subscription.cs index 5211a967..39f177b4 100644 --- a/SW.Bitween.Sdk/Model/Subscription.cs +++ b/SW.Bitween.Sdk/Model/Subscription.cs @@ -195,6 +195,17 @@ public abstract class SubscriptionConfiguration : SubscriptionCreateUpdateBase public int? RetryPolicyId { get; set; } public CustomRetryPolicy CustomRetryPolicy { get; set; } + + /// + /// Aggregation only: which file of each collected exchange the roll-up links to. + /// + /// Here rather than on , where it used to live, because + /// it is a choice a person makes and not runtime state. On update alone it reached the + /// entity through the generic property copy, so create could not set it and every + /// aggregation started on whatever was wanted. + /// + /// + public XchangeFileType AggregationTarget { get; set; } } /// @@ -244,7 +255,6 @@ public class SubscriptionUpdate : SubscriptionConfiguration public DateTime? AggregateOn { get; set; } public int ConsecutiveFailures { get; set; } public string LastException { get; set; } - public XchangeFileType AggregationTarget { get; set; } public DateTime? PausedOn { get; set; } public string CategoryCode { get; set; } public string CategoryDescription { get; set; } From 994e587c6c3754ff70acb022d846634132619205 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 27 Aug 2026 11:10:51 +0300 Subject: [PATCH 02/14] feat: wire aggregationTarget and aggregateNow into the API client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-trips the new field through create/update, and adds aggregateNow to match the existing receiveNow — the endpoint and permission already existed, just no client method. --- SW.Bitween.Web/ClientApp/src/api/client.ts | 8 ++++ .../ClientApp/src/api/http/integrations.ts | 43 +++++++++++++++---- SW.Bitween.Web/ClientApp/src/api/types.ts | 27 ++++++++++-- 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index c861fbae..46056d7f 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -2,6 +2,7 @@ import type { AddBusRouteInput, AttachPartnerInput } from "./http/gateways"; import type { AdapterInfo, AdapterKind, + AggregationTarget, ApiGateway, ApiGatewayAttachment, ApiGatewayDetail, @@ -179,6 +180,10 @@ export interface ApiClient { informationTypeId: number; /** Required by the types that carry their own partner — Internal and ApiCall. */ partnerId?: number | null; + /** Aggregation only: whose exchanges get rolled up. Required, and fixed once created. */ + aggregationForId?: number | null; + /** Aggregation only: which file of each collected exchange the roll-up links to. */ + aggregationTarget?: AggregationTarget; receiverId?: string | null; receiverProperties?: Record; validatorId?: string | null; @@ -214,6 +219,7 @@ export interface ApiClient { | "schedules" | "responseIntegrationId" | "responseMessageTypeName" + | "aggregationTarget" > >, ): Promise; @@ -221,6 +227,8 @@ export interface ApiClient { /** Toggles paused: paused integrations accept work but hold it. */ pauseIntegration(id: number): Promise; receiveNow(id: number): Promise; + /** Runs an aggregation's roll-up now instead of waiting for its schedule. */ + aggregateNow(id: number): Promise; /** Run history for one scheduled integration, newest first. Empty for unscheduled types. */ listIntegrationRuns(id: number, limit?: number): Promise; searchReceiveAttempts( diff --git a/SW.Bitween.Web/ClientApp/src/api/http/integrations.ts b/SW.Bitween.Web/ClientApp/src/api/http/integrations.ts index 910ce616..a258d4af 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/integrations.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/integrations.ts @@ -1,6 +1,7 @@ import type { ApiClient } from "../client"; import { ApiRequestError, + type AggregationTarget, type Integration, type IntegrationDetail, type IntegrationInfo, @@ -93,7 +94,7 @@ interface RawSubscription { isRunning: boolean | null; consecutiveFailures: number; lastException: string | null; - aggregationTarget?: string; + aggregationTarget?: AggregationTarget; } const SUB_TYPE_BY_NUM: Record = { @@ -133,6 +134,13 @@ const toSchedules = (raw: RawSchedule[] | null): Schedule[] => backwards: s.backwards, })); +/** + * When the schedule fires next. The two scheduled types keep it in different columns — + * `ReceiveOn` for Receiving, `AggregateOn` for Aggregation — and only ever one of them is + * set, so taking whichever exists lets every screen read one field. + */ +const nextRunOf = (raw: RawSubscription): string | null => raw.receiveOn ?? raw.aggregateOn ?? null; + function toIntegration(raw: RawSubscription, idOverride?: number): Integration { return { id: raw.id ?? idOverride!, @@ -157,8 +165,9 @@ function toIntegration(raw: RawSubscription, idOverride?: number): Integration { responseIntegrationId: raw.responseSubscriptionId ?? null, responseMessageTypeName: raw.responseMessageTypeName ?? null, aggregationForId: raw.aggregationForId ?? null, + aggregationTarget: raw.aggregationTarget ?? "Input", isRunning: raw.isRunning ?? false, - nextReceiveOn: raw.receiveOn ?? null, + nextReceiveOn: nextRunOf(raw), consecutiveFailures: raw.consecutiveFailures ?? 0, lastException: raw.lastException ?? null, // Subscription has no CreatedOn column on the backend. @@ -196,14 +205,15 @@ type UpdatableFields = Partial< | "schedules" | "responseIntegrationId" | "responseMessageTypeName" + | "aggregationTarget" > >; /** * POST /subscriptions/{id} replaces the whole record, and Update.cs's model * (SubscriptionUpdate) carries several fields this UI never shows (categoryId, - * aggregationTarget, temporary, …) — omitting them would silently reset them - * to their type default. So every write reads the current full record first + * temporary, …) — omitting them would silently reset them to their type + * default. So every write reads the current full record first * and splices `changes` on top of it, mirroring writePartner()'s pattern. * * Secret adapter property values arrive masked as the literal string @@ -240,7 +250,8 @@ async function applyChanges(id: number, current: RawSubscription, changes: Updat responseMessageTypeName: changes.responseMessageTypeName !== undefined ? changes.responseMessageTypeName : current.responseMessageTypeName, temporary: current.temporary, - aggregationTarget: current.aggregationTarget, + aggregationTarget: + changes.aggregationTarget !== undefined ? changes.aggregationTarget : current.aggregationTarget, pausedOn: current.pausedOn, receiveOn: current.receiveOn, aggregateOn: current.aggregateOn, @@ -279,7 +290,9 @@ function toIntegrationRow( schedules.length > 0 && (type === "Receiving" || type === "Aggregation") ? schedulesSummary(schedules) : undefined, - nextReceiveOn: raw.receiveOn ?? null, + nextReceiveOn: nextRunOf(raw), + aggregationForId: raw.aggregationForId ?? null, + aggregationTarget: raw.aggregationTarget ?? "Input", createdOn: "", }; } @@ -402,9 +415,17 @@ export const integrationMethods = { async createIntegration(input: { type: IntegrationType; name: string; + /** + * Ignored for Aggregation — the backend constructor forces the built-in + * "Aggregation Document" whatever the caller sends, so there is nothing to pick. + */ informationTypeId: number; - /** Required by the types that carry their own partner — Internal and ApiCall. */ + /** Required by the types that carry their own partner — Internal, ApiCall and Aggregation. */ partnerId?: number | null; + /** Aggregation only: whose exchanges get rolled up. Required, and fixed once created. */ + aggregationForId?: number | null; + /** Aggregation only: which file of each collected exchange the roll-up links to. */ + aggregationTarget?: AggregationTarget; receiverId?: string | null; receiverProperties?: Record; validatorId?: string | null; @@ -427,7 +448,8 @@ export const integrationMethods = { documentId: input.informationTypeId, type: input.type, partnerId: input.partnerId ?? null, - aggregationForId: null, + aggregationForId: input.aggregationForId ?? null, + aggregationTarget: input.aggregationTarget ?? "Input", receiverId: input.receiverId ?? null, receiverProperties: toKvArray(input.receiverProperties ?? {}), validatorId: input.validatorId ?? null, @@ -470,6 +492,11 @@ export const integrationMethods = { return toIntegration(await fetchRaw(id), id); }, + async aggregateNow(id: number): Promise { + await post(`/subscriptions/${id}/aggregatenow`, {}); + return toIntegration(await fetchRaw(id), id); + }, + listIntegrationRuns(id: number, limit = 20): Promise { return get(`/subscriptions/runs?subscriptionId=${id}&limit=${limit}`); }, diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 4e35d18c..4a9cfab5 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -425,9 +425,15 @@ export interface NotifierDetail extends Notifier { // ——— Integrations (subscriptions) ——— /** - * Backend Subscription.Type. Aggregation exists in data but is deferred in - * this UI; Internal and ApiCall are legacy — shown and editable, never created. + * Backend Subscription.Type. Internal and ApiCall are legacy — shown and + * editable, never created. */ + +/** + * Which file of each collected exchange an aggregation's roll-up links to: what came + * in, what the mapper produced, or what the destination handed back. + */ +export type AggregationTarget = "Input" | "Output" | "Response"; /** * The editable fields of an integration being defined inline, while whatever points * at it is being made. Mirrors the studio's own draft — deliberately, so the canvas @@ -539,7 +545,14 @@ export interface Integration { /** Feed the handler's response into another integration. */ responseIntegrationId: number | null; responseMessageTypeName: string | null; + /** + * Aggregation only: whose exchanges get rolled up. Fixed at creation — the backend + * property has a private setter and the configuration applier deliberately skips it, + * so no update can repoint a live roll-up. + */ aggregationForId: number | null; + /** Aggregation only. Meaningless for every other type, where it stays "Input". */ + aggregationTarget: AggregationTarget; // — health (read-only) — isRunning: boolean; /** Receiving (and Aggregation) only — when the schedule will next fire, not when it last did. */ @@ -562,8 +575,16 @@ export interface IntegrationRow { consecutiveFailures: number; lastException: string | null; scheduleSummary?: string; - /** Receiving (and Aggregation) only — when the schedule will next fire, not when it last did. */ + /** + * Receiving and Aggregation only — when the schedule will next fire, not when it last + * did. The two types keep it in different columns on the backend (`ReceiveOn` vs + * `AggregateOn`); this is whichever one applies. + */ nextReceiveOn: string | null; + /** Aggregation only: whose exchanges it rolls up. */ + aggregationForId: number | null; + /** Aggregation only: which file of each collected exchange the roll-up links to. */ + aggregationTarget: AggregationTarget; createdOn: string; } From b87aa2b4f35d3665343909c61fb013c2a289db0f Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 27 Aug 2026 11:11:00 +0300 Subject: [PATCH 03/14] feat: let a table column header carry a hover tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standing rule going forward — a derived or abbreviated header (Reliability reading 3/10, say) needs a tooltip on the header, not just the cell, since that's what a person reads first. --- .../ClientApp/src/components/ui/Table.tsx | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx b/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx index 08b34a3d..b07e55c9 100644 --- a/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx @@ -3,6 +3,14 @@ import type { ReactNode } from "react"; export interface Column { /** Header text. Empty string for action/icon columns. */ header: string; + /** + * What the column means, shown on hovering the header. + * + * Required reading for any header whose value is derived, abbreviated or otherwise not + * self-explanatory — "Reliability" reading `3/10` told nobody anything, and the header is + * what a person reads before they think to hover a cell. Plain words, not the formula. + */ + headerTitle?: string; /** Cell renderer. */ cell: (row: T) => ReactNode; /** Extra classes on both header and cells — width, alignment, wrapping. */ @@ -61,8 +69,10 @@ export function Table({ {columns.map((c, i) => ( - - {c.header} + + + {c.header} + ))} @@ -135,8 +145,10 @@ export function MiniTable({ {columns.map((c, i) => ( - - {c.header} + + + {c.header} + ))} From bee09594e94e4247797cd231c73d594ef401f2e8 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 27 Aug 2026 11:11:18 +0300 Subject: [PATCH 04/14] feat: give the aggregation studio card real editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail was a single "Aggregation" node reading "Not editable yet" while the schedule fault badge was pinned to that same uneditable node — the page could report a broken schedule and offer no way to fix it. It's now Rolls up + Schedule + Transformation + Delivery + Response like any other pipeline, and the fault moved to the Schedule node it actually belongs to. Adds "Roll up now" and "Roll these up" to the integration page header. --- .../components/config/AggregationFields.tsx | 93 +++++++++++++++++++ .../NewGatewayIntegrationPage.tsx | 2 + .../pages/integrations/IntegrationPage.tsx | 83 +++++++++++++++-- .../src/pages/integrations/studio/faces.ts | 25 ++++- .../src/pages/integrations/studio/model.ts | 5 +- .../src/pages/integrations/studio/stages.ts | 20 +++- .../scheduled-jobs/NewScheduledJobPage.tsx | 2 + 7 files changed, 212 insertions(+), 18 deletions(-) create mode 100644 SW.Bitween.Web/ClientApp/src/components/config/AggregationFields.tsx diff --git a/SW.Bitween.Web/ClientApp/src/components/config/AggregationFields.tsx b/SW.Bitween.Web/ClientApp/src/components/config/AggregationFields.tsx new file mode 100644 index 00000000..75440862 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/components/config/AggregationFields.tsx @@ -0,0 +1,93 @@ +import { Link } from "react-router"; +import type { AggregationTarget } from "../../api"; +import { Field, Select } from "../ui/forms"; + +/** + * How each choice reads to a person. The enum names the file from the system's side + * ("Output"); these name it from the source exchange's side, which is the only way to + * tell them apart without already knowing the pipeline. + * + * One definition, three lengths: the dropdown and the studio node and the list column + * all describe the same setting, and three hand-written wordings drift. + */ +export const AGGREGATION_TARGET_LABEL: Record = { + Input: "What came in", + Output: "What the mapper produced", + Response: "What the destination replied", +}; + +export const AGGREGATION_TARGET_DETAIL: Record = { + Input: "links to what came in", + Output: "links to what the mapper produced", + Response: "links to what the destination replied", +}; + +const TARGET_OPTIONS = (Object.keys(AGGREGATION_TARGET_LABEL) as AggregationTarget[]).map((value) => ({ + value, + label: AGGREGATION_TARGET_LABEL[value], +})); + +/** + * The two things that make an aggregation what it is: whose exchanges it collects, and + * which of their files the roll-up links to. + * + * Shared by the integration's studio card and the create dialog, so the wording a person + * reads while making one is the wording they read afterwards. The source is display-only + * here and picked by the caller instead — it is fixed at creation by the backend, whose + * `AggregationForId` has a private setter and is skipped by the configuration applier. + */ +export function AggregationFields({ + source, + target, + onTargetChange, + disabled, +}: { + /** + * The integration being rolled up. `null` while one is still being chosen; omit it + * entirely when the caller shows the source itself and only the collected-file half + * is left to ask. + */ + source?: { id: number; name: string } | null; + target: AggregationTarget; + onTargetChange: (target: AggregationTarget) => void; + disabled?: boolean; +}) { + return ( +
+ {source !== undefined && ( + + {source ? ( +

+ + {source.name} + +

+ ) : ( +

Not set

+ )} +
+ )} + + +
@@ -152,10 +223,8 @@ export function ReceiveAttemptsPanel({ subscriptionId }: { subscriptionId: numbe {attempts.isPending ? ( ) : rows.length === 0 ? ( - - {outcome - ? "Try a different filter." - : "This integration hasn't checked for new data since this history started being kept."} + + {outcome ? "Try a different filter." : w.emptyHint} ) : ( ( {timeAgo(a.startedOn)} ), }, - { header: "Result", cell: (a) => }, - { header: "Exchange", cell: (a) => }, + { + header: "Result", + headerTitle: w.resultHint, + cell: (a) => , + }, + { + header: w.exchangeHeader, + headerTitle: w.exchangeHint, + cell: (a) => , + }, ]} /> )} From 53f4376ebe894716d3be2b8ca8468df85f3d2de4 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 27 Aug 2026 11:11:43 +0300 Subject: [PATCH 06/14] feat: add its own Aggregations page, and create it on the studio canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Own page under Integrations rather than a filter on Scheduled jobs, since the columns that matter are different ones — what it rolls up and which file it collects, not an information type every aggregation shares. Creating opens the same rail the scheduled-job canvas uses; both the list's New button and a source integration's "Roll these up" land there, the latter with the source pre-filled and fixed. --- .../src/components/config/ScheduleEditor.tsx | 4 +- SW.Bitween.Web/ClientApp/src/nav.ts | 4 + .../pages/aggregations/AggregationsPage.tsx | 378 ++++++++++++++++++ .../pages/aggregations/NewAggregationPage.tsx | 360 +++++++++++++++++ SW.Bitween.Web/ClientApp/src/router.tsx | 18 + 5 files changed, 762 insertions(+), 2 deletions(-) create mode 100644 SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx create mode 100644 SW.Bitween.Web/ClientApp/src/pages/aggregations/NewAggregationPage.tsx diff --git a/SW.Bitween.Web/ClientApp/src/components/config/ScheduleEditor.tsx b/SW.Bitween.Web/ClientApp/src/components/config/ScheduleEditor.tsx index c3a69038..06942a6b 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/ScheduleEditor.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/ScheduleEditor.tsx @@ -121,7 +121,7 @@ function ScheduleDialog({ ); } -/** Controlled list of run schedules for receivers. */ +/** Controlled list of run schedules, for either scheduled type. */ export function ScheduleEditor({ schedules, onChange, @@ -137,7 +137,7 @@ export function ScheduleEditor({
{schedules.length === 0 ? (

- No schedule yet — this receiver never runs on its own. + No schedule yet — this never runs on its own.

) : (
    diff --git a/SW.Bitween.Web/ClientApp/src/nav.ts b/SW.Bitween.Web/ClientApp/src/nav.ts index 7012f172..de0dadce 100644 --- a/SW.Bitween.Web/ClientApp/src/nav.ts +++ b/SW.Bitween.Web/ClientApp/src/nav.ts @@ -6,6 +6,7 @@ import { CalendarClock, FileText, Handshake, + FileStack, Layers, Network, RefreshCw, @@ -61,6 +62,9 @@ export const NAV_GROUPS: NavGroup[] = [ { label: "API gateways", path: "/api-gateways", icon: Webhook, permissions: ["api-gateways.view"] }, { label: "Bus gateways", path: "/bus-gateways", icon: Cable, permissions: ["bus-gateways.view"] }, { label: "Scheduled jobs", path: "/scheduled-jobs", icon: CalendarClock, permissions: ["subscriptions.view"] }, + // Directly under scheduled jobs: it is the other thing that runs on a schedule, + // and it collects what one of these produced. + { label: "Aggregations", path: "/aggregations", icon: FileStack, permissions: ["subscriptions.view"] }, // After the three ways work enters, because it is the picture of how they join up // rather than a fourth kind of them. Gated on the bus alone: bus messages are what // carry work *between* gateways, so without that permission there is no flow to map. diff --git a/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx new file mode 100644 index 00000000..55193811 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx @@ -0,0 +1,378 @@ +import { useMemo, useState } from "react"; +import { Link, useNavigate, useSearchParams } from "react-router"; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Layers, Play, Plus, Search } from "lucide-react"; +import { api, type IntegrationRow, type ScheduleHealth } from "../../api"; +import { Can, useSessionCan } from "../../auth/guards"; +import { PageHeader } from "../../components/layout/PageHeader"; +import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; +import { ConfirmDialog } from "../../components/ui/overlays"; +import { Pagination } from "../../components/ui/Pagination"; +import { Table } from "../../components/ui/Table"; +import { AGGREGATION_TARGET_LABEL } from "../../components/config/AggregationFields"; +import { + HealthBadge, + IntegrationStatusBadges, + LinkListCell, + scheduleFault, + useIntegrationsCache, + useRetryPolicyNames, + useWorkGroupNames, +} from "../../components/config/shared"; +import { formatDateTime, formatDurationMs, timeAgo, timeUntil } from "../../lib/dates"; + +function AggregateNowButton({ job }: { job: IntegrationRow }) { + const queryClient = useQueryClient(); + const [confirming, setConfirming] = useState(false); + const aggregate = useMutation({ + mutationFn: () => api.aggregateNow(job.id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["integration-rows"] }); + void queryClient.invalidateQueries({ queryKey: ["integration-rows-search"] }); + void queryClient.invalidateQueries({ queryKey: ["last-runs"] }); + }, + }); + + return ( + <> + + {confirming && ( + { + await aggregate.mutateAsync(); + }} + onClose={() => setConfirming(false)} + /> + )} + + ); +} + +/** The scheduler disagreeing with the integration's own record — see the scheduled-jobs page. */ +function ScheduleFault({ health }: { health: ScheduleHealth | undefined }) { + const fault = scheduleFault(health); + if (!fault) return null; + return ( + + {fault.label} + + ); +} + +/** + * Aggregations — the other scheduled type. On a schedule, one collects another + * integration's successful exchanges and creates a single exchange whose payload is a + * JSON list of links to their files. + * + * Its own page rather than a row on Scheduled jobs, because the columns that matter are + * different ones: what it rolls up and which file it collects, in place of the + * information type every aggregation shares. + */ +const PAGE_SIZE = 25; + +export function AggregationsPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + const q = searchParams.get("q") ?? ""; + const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; + const canOperate = useSessionCan("subscriptions.operate"); + + const rows = useQuery({ + queryKey: ["integration-rows-search", "Aggregation", q, offset], + queryFn: () => api.searchIntegrationRows({ search: q, type: "Aggregation", offset, limit: PAGE_SIZE }), + placeholderData: keepPreviousData, + }); + // The list rows don't carry work group or retry policy; the integrations cache does, + // and it is also where the source integration's name comes from. + const setups = useIntegrationsCache().data ?? []; + const setupById = useMemo(() => new Map(setups.map((s) => [s.id, s])), [setups]); + const nameById = useMemo(() => new Map(setups.map((s) => [s.id, s.name])), [setups]); + const workGroupNames = useWorkGroupNames(); + const retryPolicyNames = useRetryPolicyNames(); + const lastRuns = useQuery({ queryKey: ["last-runs"], queryFn: () => api.listLastRuns() }).data ?? []; + const lastRunById = useMemo(() => new Map(lastRuns.map((r) => [r.integrationId, r])), [lastRuns]); + const health = + useQuery({ queryKey: ["schedule-health"], queryFn: () => api.listScheduleHealth() }).data ?? []; + const healthById = useMemo(() => new Map(health.map((h) => [h.integrationId, h])), [health]); + + const setParam = (key: string, value: string | null, resetOffset = true) => + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (value) next.set(key, value); + else next.delete(key); + if (resetOffset) next.delete("offset"); + return next; + }, + { replace: true }, + ); + + const filtered = rows.data?.result ?? []; + const total = rows.data?.total ?? 0; + + return ( +
    + + + + } + /> + +
    + + setParam("q", e.target.value || null)} + placeholder="Search aggregations" + aria-label="Search aggregations" + className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" + /> +
    + + {rows.isPending ? ( + + ) : filtered.length === 0 ? ( + } title={q ? "No aggregations match" : "No aggregations yet"}> + {q + ? "Try a different search." + : "Create one here, or open the integration you want summarised and choose “Roll these up”."} + + ) : ( +
r.id} + minWidth="min-w-270" + onRowClick={(r) => navigate(`/subscriptions/${r.id}`)} + footer={ + setParam("offset", String(o), false)} + /> + } + columns={[ + { + header: "Aggregation", + headerTitle: "The roll-up job itself. Open it to configure what it delivers.", + truncate: true, + cell: (r) => {r.name}, + }, + { + // The whole point of the row: an aggregation with no source name is one + // whose source was deleted, and it will never produce anything again. + header: "Rolls up", + headerTitle: "The integration whose successful exchanges this collects. Fixed when the aggregation was created.", + truncate: true, + cell: (r) => { + const name = r.aggregationForId === null ? null : nameById.get(r.aggregationForId); + if (r.aggregationForId === null) + return Not set; + return name ? ( + e.stopPropagation()} + className="block truncate text-[13px] text-ink-700 hover:text-crimson-700 hover:underline" + > + {name} + + ) : ( + + ); + }, + }, + { + header: "Collects", + headerTitle: "Which file of each collected exchange the roll-up links to. Links only \u2014 the files are not combined.", + truncate: true, + cell: (r) => ( + + {AGGREGATION_TARGET_LABEL[r.aggregationTarget]} + + ), + }, + { + header: "Last run", + headerTitle: "When the roll-up last ran, how long it took, and whether someone triggered it by hand.", + className: "whitespace-nowrap", + cell: (r) => { + const run = lastRunById.get(r.id); + if (!run) + return ( + + — + + ); + const running = run.success === null; + return ( + <> + {timeAgo(run.startedOn)} + + {running + ? "running…" + : [ + run.success === false ? "failed" : null, + run.durationMs !== null ? formatDurationMs(run.durationMs) : null, + run.manual ? "manual" : null, + ] + .filter(Boolean) + .join(" · ")} + + + ); + }, + }, + { + header: "Reliability", + headerTitle: "How many of the last runs succeeded, from the scheduler\u2019s own history (about 30 days). 3/10 means 3 of the last 10 finished runs worked.", + className: "whitespace-nowrap", + cell: (r) => { + const run = lastRunById.get(r.id); + if (!run || run.recentTotal === 0) return ; + const failed = run.recentTotal - run.recentSucceeded; + return ( + 0 ? "text-danger-700" : "text-ink-600"}`} + title={`${run.recentSucceeded} of the last ${run.recentTotal} finished runs succeeded`} + > + {run.recentSucceeded}/{run.recentTotal} + + ); + }, + }, + { + // The subscription's own AggregateOn, the aggregation counterpart of the + // ReceiveOn the scheduled-jobs page shows. + header: "Next run", + headerTitle: "When the schedule fires next. A dash means no schedule, so it only runs when someone presses Roll up now.", + className: "whitespace-nowrap", + cell: (r) => + r.nextReceiveOn ? ( + <> + {timeUntil(r.nextReceiveOn)} + {formatDateTime(r.nextReceiveOn)} + + ) : ( + + ), + }, + { + header: "Partner", + headerTitle: "Who the roll-up exchange belongs to. Not the partners of the exchanges it collected \u2014 one roll-up can cover many.", + truncate: true, + cell: (r) => ( + ({ + key: p.id, + name: p.name, + href: `/partners/${p.id}`, + }))} + /> + ), + }, + { + header: "Work group", + headerTitle: "Which queue lane the roll-up runs in \u2014 the difference between idle and queued behind other work.", + truncate: true, + cell: (r) => { + const id = setupById.get(r.id)?.workGroupId ?? null; + const name = id === null ? null : (workGroupNames.get(id) ?? null); + if (id === null) return Ungrouped; + return name ? ( + e.stopPropagation()} + className="block truncate text-[13px] text-ink-700 hover:text-crimson-700 hover:underline" + > + {name} + + ) : ( + + ); + }, + }, + { + header: "Retry policy", + headerTitle: "What happens when the roll-up\u2019s delivery fails. None means a failure is recorded and left alone.", + truncate: true, + cell: (r) => { + const id = setupById.get(r.id)?.retryPolicyId ?? null; + const name = id === null ? null : (retryPolicyNames.get(id) ?? null); + if (id === null) return None; + return name ? ( + e.stopPropagation()} + className="block truncate text-[13px] text-ink-700 hover:text-crimson-700 hover:underline" + > + {name} + + ) : ( + + ); + }, + }, + { + header: "Status", + headerTitle: "Whether it is turned on, holding work, and executing right now. Hover a badge for what it means.", + cell: (r) => ( + + + + + + ), + }, + { + header: "Last error", + headerTitle: "The most recent failure this aggregation recorded. Open the row for the full run history.", + truncate: true, + cell: (r) => + r.lastException ? ( + + {r.lastException} + + ) : ( + + ), + }, + { + header: "", + align: "right", + cell: (r) => canOperate && , + }, + ]} + /> + )} + + ); +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/aggregations/NewAggregationPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/aggregations/NewAggregationPage.tsx new file mode 100644 index 00000000..5a4738ab --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/aggregations/NewAggregationPage.tsx @@ -0,0 +1,360 @@ +import { useState } from "react"; +import { useNavigate, useSearchParams } from "react-router"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { X } from "lucide-react"; +import { Button, FormError } from "../../components/ui/basics"; +import { Checkbox, Field, TextInput } from "../../components/ui/forms"; +import { Panel } from "../../components/ui/Panel"; +import { SearchSelect } from "../../components/ui/SearchSelect"; +import { AdapterConfig, useAdapterCatalog } from "../../components/config/AdapterConfig"; +import { AggregationFields } from "../../components/config/AggregationFields"; +import { ScheduleEditor } from "../../components/config/ScheduleEditor"; +import { PartnerPicker } from "../../components/config/pickers"; +import { useIntegrationsCache } from "../../components/config/shared"; +import { api, type AggregationTarget, type Schedule } from "../../api"; +import { STAGES, type StageId } from "../integrations/studio/stages"; +import { StageRail } from "../integrations/studio/StageRail"; +import { adapterIncomplete, faceOf } from "../integrations/studio/faces"; +import { ResponseFields } from "../integrations/studio/ResponseFields"; +import type { Draft as StudioDraft } from "../integrations/studio/model"; +import { BackLink } from "../../components/ui/BackLink"; + +/** Local draft state with the patch-and-clear shape the other create pages use. */ +function useDraft(initial: T) { + const [draft, setDraft] = useState(initial); + const update = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); + return [draft, update] as const; +} + +/** The whole pipeline — the same nodes the studio page edits for a saved aggregation. */ +const STAGES_HERE: StageId[] = ["aggregation", "schedule", "transformation", "delivery", "response"]; + +type Draft = Pick< + StudioDraft, + | "name" + | "schedules" + | "aggregationTarget" + | "mapperId" + | "mapperProperties" + | "handlerId" + | "handlerProperties" + | "responseIntegrationId" + | "responseMessageTypeName" +> & { + aggregationForId: number | null; + partnerId: number | null; + /** + * Whether the partner shown is the person's own choice. Without this, "cleared it" and + * "hasn't chosen yet" look identical and the source's partner keeps coming back. + */ + partnerTouched: boolean; + enable: boolean; +}; + +const EMPTY: Draft = { + name: "", + aggregationForId: null, + partnerId: null, + partnerTouched: false, + aggregationTarget: "Input", + // A sensible default so the node starts valid, edited through the full recurrence editor + // on demand — matching the scheduled-job page. An aggregation has no other trigger, so a + // draft that starts with none would open on a broken node. + schedules: [{ recurrence: "Daily", days: 0, hours: 0, minutes: 0, backwards: false }] as Schedule[], + mapperId: null, + mapperProperties: {}, + handlerId: null, + handlerProperties: {}, + responseIntegrationId: null, + responseMessageTypeName: null, + enable: false, +}; + +/** + * Creating an aggregation, on the same pipeline the studio page edits — the scheduled-job + * page's shape, for the same reason: you learn the rail once, and after Create you land on + * the page you were already looking at. + * + * Arrives with `?source=` when started from the integration being rolled up, in which case + * the source is fixed and shown rather than picked. Either way it is fixed after creation: + * the backend's `AggregationForId` has a private setter and the configuration applier skips + * it, so no update can repoint a live roll-up. + */ +export function NewAggregationPage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [params] = useSearchParams(); + const fixedSourceId = params.get("source") ? Number(params.get("source")) : null; + + const [stage, setStage] = useState("aggregation"); + + const allIntegrations = useIntegrationsCache(); + const receivers = useAdapterCatalog("receiver"); + const validators = useAdapterCatalog("validator"); + const mappers = useAdapterCatalog("mapper"); + const handlers = useAdapterCatalog("handler"); + + const [draft, update] = useDraft({ ...EMPTY, aggregationForId: fixedSourceId }); + + const source = allIntegrations.data?.find((s) => s.id === draft.aggregationForId) ?? null; + + // An aggregation may point at another aggregation — the backend does not stop it, and a + // chain of roll-ups summarising roll-ups is not a shape anyone has asked for. + const candidates = (allIntegrations.data ?? []).filter((s) => s.type !== "Aggregation"); + + // The source's own partner where it has one, until the person says otherwise. It is a + // suggestion rather than an inheritance: a Receiving source usually has no partner at all, + // and the roll-up's partner answers a different question from the source's anyway. + const partnerId = draft.partnerTouched ? draft.partnerId : (source?.partnerIds[0] ?? null); + + const create = useMutation({ + mutationFn: () => + api.createIntegration({ + type: "Aggregation", + name: draft.name.trim(), + // Ignored for this type — the backend forces the built-in Aggregation Document. + informationTypeId: 0, + partnerId, + aggregationForId: draft.aggregationForId, + aggregationTarget: draft.aggregationTarget, + schedules: draft.schedules, + mapperId: draft.mapperId, + mapperProperties: draft.mapperProperties, + handlerId: draft.handlerId, + handlerProperties: draft.handlerProperties, + responseIntegrationId: draft.responseIntegrationId, + responseMessageTypeName: draft.responseMessageTypeName, + enabled: draft.enable, + }), + onSuccess: (created) => { + void queryClient.invalidateQueries(); + navigate(`/subscriptions/${created.id}`); + }, + }); + + // faceOf works off the studio's full draft shape; the fields this type never has are empty. + const studioDraft: StudioDraft = { + ...draft, + enabled: draft.enable, + workGroupId: null, + retryPolicyId: null, + receiverId: null, + receiverProperties: {}, + validatorId: null, + validatorProperties: {}, + matchExpression: null, + }; + + const faces = STAGES_HERE.map((id) => + faceOf(id, { + type: "Aggregation", + draft: studioDraft, + catalogs: { receivers, validators, mappers, handlers }, + integrationNames: allIntegrations.data, + aggregationForId: draft.aggregationForId, + unsaved: true, + }), + ); + + const unfilled = [ + adapterIncomplete(mappers, draft.mapperId, draft.mapperProperties) && "transformation", + adapterIncomplete(handlers, draft.handlerId, draft.handlerProperties) && "delivery", + ].filter((m): m is string => typeof m === "string"); + + const missing = [ + draft.aggregationForId === null && "something to roll up", + draft.name.trim().length < 2 && "a name", + partnerId === null && "a partner", + // As strict as the scheduled-job page, for the same reason: a roll-up with nowhere to go + // is built and then thrown away. It also keeps the rail honest — the Delivery node reads + // "Needed" while nothing is set, and that has to mean something. + draft.handlerId === null && "a delivery", + // Create does not demand a schedule, but update does — one saved without could never be + // saved again from its own page. The surface that can make that mistake refuses to. + draft.schedules.length === 0 && "a schedule", + unfilled.length > 0 && `the required fields on ${unfilled.join(" and ")}`, + ].filter((m): m is string => typeof m === "string"); + + const renderStage = (stageId: StageId) => { + const { label, description } = STAGES[stageId]; + switch (stageId) { + case "aggregation": + return ( + + update({ aggregationTarget })} + /> + + ); + case "schedule": + return ( + + update({ schedules })} + disabled={false} + /> + + ); + case "transformation": + return ( + + update({ mapperId, mapperProperties })} + disabled={false} + noneLabel="None — the list of links is delivered as it is" + /> +

+ What arrives here is a JSON list of links, not the documents themselves. Combining + them into one file is this step's job, or the delivery's. +

+
+ ); + case "delivery": + return ( + + update({ handlerId, handlerProperties })} + disabled={false} + required + /> + + ); + case "response": + return ( + + + + ); + default: + return null; + } + }; + + return ( +
+ + +

New aggregation

+

+ On a schedule, collects another integration's successful exchanges into one exchange + listing links to their files. It does not combine the files — the transformation or the + delivery does that. +

+ +
+
+ + update({ name: e.target.value })} + /> + +
+ {fixedSourceId === null && ( +
+ + v !== "" && update({ aggregationForId: Number(v) })} + placeholder="Pick an integration…" + options={candidates.map((s) => ({ value: String(s.id), label: s.name, hint: s.type }))} + /> + +
+ )} +
+ + update({ partnerId: v === "none" ? null : v, partnerTouched: true })} + /> + +
+
+ + + + {stage !== null && ( +
+ {renderStage(stage)} + +
+ )} + +
+ update({ enable: e.target.checked })} + /> +
+ {missing.length > 0 && ( +

+ Still needs {missing.slice(0, -1).join(", ")} + {missing.length > 1 ? " and " : ""} + {missing.at(-1)}. +

+ )} + + +
+
+ + {create.error?.message} +
+ ); +} diff --git a/SW.Bitween.Web/ClientApp/src/router.tsx b/SW.Bitween.Web/ClientApp/src/router.tsx index 7556e8f4..b0ed681b 100644 --- a/SW.Bitween.Web/ClientApp/src/router.tsx +++ b/SW.Bitween.Web/ClientApp/src/router.tsx @@ -35,6 +35,8 @@ import { PartnerPage } from "./pages/partners/PartnerPage"; import { PartnersPage } from "./pages/partners/PartnersPage"; import { NewScheduledJobPage } from "./pages/scheduled-jobs/NewScheduledJobPage"; import { ScheduledJobsPage } from "./pages/scheduled-jobs/ScheduledJobsPage"; +import { AggregationsPage } from "./pages/aggregations/AggregationsPage"; +import { NewAggregationPage } from "./pages/aggregations/NewAggregationPage"; import { RetryPoliciesPage } from "./pages/retry-policies/RetryPoliciesPage"; import { RetryPolicyPage } from "./pages/retry-policies/RetryPolicyPage"; import { MembersTab } from "./pages/team/MembersTab"; @@ -215,6 +217,22 @@ export const router = createBrowserRouter([ ), }, + { + path: "aggregations", + element: ( + + + + ), + }, + { + path: "aggregations/new", + element: ( + + + + ), + }, { path: "api-gateways/new", element: ( From 3307f4176620711e24e9d62c3de31467be12fb6a Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 27 Aug 2026 11:11:50 +0300 Subject: [PATCH 07/14] feat: link an aggregation's exchange to what it rolled up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Id filter already matches AggregationXchangeId, so "Retries & aggregation family" resolved this correctly from the source side — the roll-up itself just never offered the same link back. --- .../src/pages/exchanges/ExchangeDrawer.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx index b0067691..8c8fc970 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx @@ -7,6 +7,7 @@ import { useSessionCan } from "../../auth/guards"; import { Badge, Button } from "../../components/ui/basics"; import { ConfirmDialog } from "../../components/ui/overlays"; import { formatDateTime, duration, timeUntil } from "../../lib/dates"; +import { useIntegrationsCache } from "../../components/config/shared"; import { RetryDialog, journeyStages, type JourneyStage } from "./shared"; const STAGE_TONES: Record = { @@ -56,6 +57,10 @@ export function ExchangeDrawer({ x }: { x: ExchangeRow }) { const stages = journeyStages(x); const canOperate = useSessionCan("exchanges.operate"); const queryClient = useQueryClient(); + // An aggregation's exchange is the roll-up — its payload is the list of links to + // everything it collected, so it is the one exchange worth offering that drill from. + const isRollUp = + useIntegrationsCache().data?.find((i) => i.id === x.integrationId)?.type === "Aggregation"; const files: Record = { Input: x.files.input, @@ -223,6 +228,20 @@ export function ExchangeDrawer({ x }: { x: ExchangeRow }) { )} + {isRollUp && ( + + {/* The Id filter matches AggregationXchangeId as well as Id, so this one + link is already the list of everything collected into this exchange — + what was missing was anything saying so from the roll-up's own side. */} + + The exchanges this collected + + + )} {x.aggregationXchangeId && ( Date: Thu, 27 Aug 2026 11:58:03 +0300 Subject: [PATCH 08/14] feat: add a searchable multi-select filter for integration usage --- .../config/IntegrationMultiFilter.tsx | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 SW.Bitween.Web/ClientApp/src/components/config/IntegrationMultiFilter.tsx diff --git a/SW.Bitween.Web/ClientApp/src/components/config/IntegrationMultiFilter.tsx b/SW.Bitween.Web/ClientApp/src/components/config/IntegrationMultiFilter.tsx new file mode 100644 index 00000000..458c9e7a --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/components/config/IntegrationMultiFilter.tsx @@ -0,0 +1,122 @@ +import { useEffect, useRef, useState } from "react"; +import { ChevronDown, Search } from "lucide-react"; +import type { IntegrationInfo } from "../../api"; +import { INTEGRATION_TYPE_LABELS } from "./shared"; + +/** + * Pick any number of integrations, to filter a table down to the rows connected to at + * least one of them — "which partners does this integration use", read the other way. + * + * Modelled on `ReferenceMenu`'s local popover rather than the portalled `Popover`: a + * filter row sits above the table, not inside its `overflow-x-auto` wrapper, so nothing + * clips it and the lighter, in-flow panel is enough. + */ +export function IntegrationMultiFilter({ + integrations, + selected, + onChange, + label = "Filter by integration", +}: { + integrations: IntegrationInfo[]; + selected: number[]; + onChange: (ids: number[]) => void; + label?: string; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const needle = query.trim().toLowerCase(); + const matches = integrations.filter((i) => !needle || i.name.toLowerCase().includes(needle)); + const selectedSet = new Set(selected); + + const toggle = (id: number) => + onChange(selectedSet.has(id) ? selected.filter((x) => x !== id) : [...selected, id]); + + const selectedNames = integrations.filter((i) => selectedSet.has(i.id)).map((i) => i.name); + const buttonLabel = + selectedNames.length === 0 + ? "Any integration" + : selectedNames.length === 1 + ? selectedNames[0] + : `${selectedNames.length} integrations`; + + return ( +
+ + {open && ( +
+
+ + setQuery(e.target.value)} + placeholder="Search integrations" + aria-label="Search integrations" + className="h-8 w-full rounded-md border border-ink-200 bg-white pr-2 pl-8 text-[13px] placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" + /> +
+ {selectedNames.length > 0 && ( + + )} +
+ {matches.length === 0 ? ( +

No integrations match.

+ ) : ( + matches.map((i) => ( + + )) + )} +
+
+ )} +
+ ); +} From 3295816e410d908fff68cc3add2e22a6f6175b9f Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 27 Aug 2026 11:58:07 +0300 Subject: [PATCH 09/14] feat: filter Partners by integration usage --- .../src/pages/partners/PartnersPage.tsx | 98 ++++++++++++++----- 1 file changed, 74 insertions(+), 24 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx index b99edff1..1e8e6891 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx @@ -1,32 +1,36 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Handshake, Plus, Search } from "lucide-react"; import { api } from "../../api"; import { Can } from "../../auth/guards"; +import { IntegrationMultiFilter } from "../../components/config/IntegrationMultiFilter"; import { PartnerDialog } from "../../components/config/PartnerDialog"; import { PageHeader } from "../../components/layout/PageHeader"; import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; -import { UsedByCell, usePartnerIntegrations } from "../../components/config/shared"; +import { UsedByCell, useIntegrationsCache, usePartnerIntegrations } from "../../components/config/shared"; const PAGE_SIZE = 25; +/** ?integrations=3,5 — no id can be 0, so filter/join round-trip cleanly through this. */ +const parseIds = (raw: string | null): number[] => + raw + ? raw + .split(",") + .map(Number) + .filter((n) => Number.isInteger(n) && n > 0) + : []; + export function PartnersPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const [creating, setCreating] = useState(false); const q = searchParams.get("q") ?? ""; + const integrationIds = parseIds(searchParams.get("integrations")); const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; - const partners = useQuery({ - queryKey: ["partners-search", q, offset], - queryFn: () => api.searchPartners({ search: q, offset, limit: PAGE_SIZE }), - placeholderData: keepPreviousData, - }); - const partnerIntegrations = usePartnerIntegrations(); - const setParam = (key: string, value: string | null, resetOffset = true) => setSearchParams( (prev) => { @@ -39,8 +43,44 @@ export function PartnersPage() { { replace: key === "q" }, ); - const rows = partners.data?.result ?? []; - const total = partners.data?.total ?? 0; + const partnerIntegrations = usePartnerIntegrations(); + const integrations = useIntegrationsCache().data ?? []; + + // The backend's Search endpoint has no "id is in this set" filter, so filtering by + // which integrations a partner is wired to can't be pushed down like the name search + // can. Every partner is already loaded once for `usePartnerIntegrations` above, so + // reusing the same full list here — and paging it client-side — costs nothing extra + // and stays correct across pages. The plain search case keeps the server-paged path, + // since that's the one that has to scale. + const filtering = integrationIds.length > 0; + + const serverSearch = useQuery({ + queryKey: ["partners-search", q, offset], + queryFn: () => api.searchPartners({ search: q, offset, limit: PAGE_SIZE }), + placeholderData: keepPreviousData, + enabled: !filtering, + }); + const allPartners = useQuery({ + queryKey: ["partners-all"], + queryFn: () => api.listPartners(), + enabled: filtering, + }); + + const filteredSorted = useMemo(() => { + if (!filtering) return []; + const needle = q.trim().toLowerCase(); + const wanted = new Set(integrationIds); + return (allPartners.data ?? []).filter((p) => { + if (needle && !p.name.toLowerCase().includes(needle)) return false; + const usedBy = partnerIntegrations.get(p.id) ?? []; + return usedBy.some((i) => wanted.has(i.id)); + }); + }, [filtering, allPartners.data, partnerIntegrations, q, integrationIds]); + + const isPending = filtering ? allPartners.isPending : serverSearch.isPending; + const rows = filtering ? filteredSorted.slice(offset, offset + PAGE_SIZE) : (serverSearch.data?.result ?? []); + const total = filtering ? filteredSorted.length : (serverSearch.data?.total ?? 0); + const filtered = q || integrationIds.length > 0; return (
@@ -56,23 +96,33 @@ export function PartnersPage() { } /> -
- - setParam("q", e.target.value || null)} - placeholder="Search partners" - aria-label="Search partners" - className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" - /> +
+
+ + setParam("q", e.target.value || null)} + placeholder="Search partners" + aria-label="Search partners" + className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" + /> +
+
+ setParam("integrations", ids.length ? ids.join(",") : null)} + label="Filter by integration" + /> +
- {partners.isPending ? ( + {isPending ? ( ) : rows.length === 0 ? ( - } title={q ? "No partners match" : "No partners yet"}> - {q ? "Try a different search." : "Create the first partner you exchange data with."} + } title={filtered ? "No partners match" : "No partners yet"}> + {filtered ? "Try a different search or filter." : "Create the first partner you exchange data with."} ) : (
Date: Thu, 27 Aug 2026 11:58:11 +0300 Subject: [PATCH 10/14] feat: filter Global values by integration usage --- .../global-values/GlobalValueSetsPage.tsx | 61 ++++++++++++++----- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx index 5a632ca5..b8310857 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx @@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Plus, Search, SlidersHorizontal } from "lucide-react"; import { api, referencesGlobal } from "../../api"; import { Can } from "../../auth/guards"; +import { IntegrationMultiFilter } from "../../components/config/IntegrationMultiFilter"; import { PageHeader } from "../../components/layout/PageHeader"; import { Button, EmptyState, FormError, LoadingBlock } from "../../components/ui/basics"; import { Field, TextInput } from "../../components/ui/forms"; @@ -77,10 +78,20 @@ function CreateValueSetDialog({ onClose }: { onClose: () => void }) { ); } +/** ?integrations=3,5 — no id can be 0, so filter/join round-trip cleanly through this. */ +const parseIds = (raw: string | null): number[] => + raw + ? raw + .split(",") + .map(Number) + .filter((n) => Number.isInteger(n) && n > 0) + : []; + export function GlobalValueSetsPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const q = searchParams.get("q") ?? ""; + const integrationIds = parseIds(searchParams.get("integrations")); const creating = searchParams.get("new") === "1"; const sets = useQuery({ queryKey: ["value-sets"], queryFn: () => api.listValueSets() }); @@ -99,10 +110,13 @@ export function GlobalValueSetsPage() { const filtered = useMemo(() => { const needle = q.trim().toLowerCase(); - return (sets.data ?? []).filter( - (s) => !needle || s.name.toLowerCase().includes(needle) || s.id.includes(needle), - ); - }, [sets.data, q]); + const wantedIntegrations = integrations.filter((i) => integrationIds.includes(i.id)); + return (sets.data ?? []).filter((s) => { + if (needle && !s.name.toLowerCase().includes(needle) && !s.id.includes(needle)) return false; + if (integrationIds.length > 0 && !wantedIntegrations.some((i) => referencesGlobal(i, s.id))) return false; + return true; + }); + }, [sets.data, q, integrationIds, integrations]); return (
@@ -118,23 +132,38 @@ export function GlobalValueSetsPage() { } /> -
- - setParam("q", e.target.value || null)} - placeholder="Search value sets" - aria-label="Search value sets" - className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" - /> +
+
+ + setParam("q", e.target.value || null)} + placeholder="Search value sets" + aria-label="Search value sets" + className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" + /> +
+
+ setParam("integrations", ids.length ? ids.join(",") : null)} + label="Filter by integration" + /> +
{sets.isPending ? ( ) : filtered.length === 0 ? ( - } title={q ? "No value sets match" : "No value sets yet"}> - {q ? "Try a different search." : "Create a set of shared values your adapters can reference."} + } + title={q || integrationIds.length > 0 ? "No value sets match" : "No value sets yet"} + > + {q || integrationIds.length > 0 + ? "Try a different search or filter." + : "Create a set of shared values your adapters can reference."} ) : (
Date: Thu, 27 Aug 2026 11:58:24 +0300 Subject: [PATCH 11/14] feat: add format, bus and integration filters to Information types --- SW.Bitween.Web/ClientApp/src/api/client.ts | 10 +- .../ClientApp/src/api/http/documents.ts | 8 +- .../InformationTypesPage.tsx | 127 +++++++++++++++--- 3 files changed, 123 insertions(+), 22 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index 46056d7f..7f60c498 100644 --- a/SW.Bitween.Web/ClientApp/src/api/client.ts +++ b/SW.Bitween.Web/ClientApp/src/api/client.ts @@ -17,6 +17,7 @@ import type { GlobalValuesSetRow, InformationType, InformationTypeDetail, + InformationTypeFormat, InformationTypeRow, Integration, IntegrationDetail, @@ -130,6 +131,8 @@ export interface ApiClient { listInformationTypes(): Promise; searchInformationTypes(query: { search: string; + format?: InformationTypeFormat | null; + busEnabled?: boolean | null; offset: number; limit: number; }): Promise>; @@ -259,7 +262,12 @@ export interface ApiClient { // — API gateways — listApiGateways(): Promise; - searchApiGateways(query: { search: string; offset: number; limit: number }): Promise>; + searchApiGateways(query: { + search: string; + inactive?: boolean | null; + offset: number; + limit: number; + }): Promise>; getApiGateway(id: number): Promise; searchGatewayAttachments( apiGatewayId: number, diff --git a/SW.Bitween.Web/ClientApp/src/api/http/documents.ts b/SW.Bitween.Web/ClientApp/src/api/http/documents.ts index 9e1652ee..a92ed776 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/documents.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/documents.ts @@ -150,11 +150,17 @@ export const documentMethods = { async searchInformationTypes(query: { search: string; + format?: InformationTypeFormat | null; + busEnabled?: boolean | null; offset: number; limit: number; }): Promise> { const qs = buildListQuery({ - filters: [["Name", SEARCHY_RULE.contains, query.search.trim()]], + filters: [ + ["Name", SEARCHY_RULE.contains, query.search.trim()], + ["DocumentFormat", SEARCHY_RULE.equalsTo, query.format ?? ""], + ["BusEnabled", SEARCHY_RULE.equalsTo, query.busEnabled == null ? "" : String(query.busEnabled)], + ], offset: query.offset, limit: query.limit, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx index 672ffcf9..a3924563 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx @@ -1,12 +1,14 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { FileText, Plus, Search } from "lucide-react"; import { api } from "../../api"; import { Can } from "../../auth/guards"; import { InformationTypeDialog } from "../../components/config/InformationTypeDialog"; +import { IntegrationMultiFilter } from "../../components/config/IntegrationMultiFilter"; import { PageHeader } from "../../components/layout/PageHeader"; import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; +import { Select } from "../../components/ui/forms"; import { CodeBadge } from "../../components/ui/Panel"; import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; @@ -14,19 +16,57 @@ import { UsedByCell, useIntegrationsCache } from "../../components/config/shared const PAGE_SIZE = 25; +const FORMAT_OPTIONS = [ + { value: "", label: "Any format" }, + { value: "Json", label: "JSON" }, + { value: "Xml", label: "XML" }, +]; + +const BUS_OPTIONS = [ + { value: "", label: "Any bus status" }, + { value: "true", label: "On the bus" }, + { value: "false", label: "Not on the bus" }, +]; + +/** ?integrations=3,5 — no id can be 0, so filter/join round-trip cleanly through this. */ +const parseIds = (raw: string | null): number[] => + raw + ? raw + .split(",") + .map(Number) + .filter((n) => Number.isInteger(n) && n > 0) + : []; + export function InformationTypesPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const [creating, setCreating] = useState(false); const q = searchParams.get("q") ?? ""; + const format = searchParams.get("format") as "Json" | "Xml" | null; + const busParam = searchParams.get("bus"); + const busEnabled = busParam === "true" ? true : busParam === "false" ? false : null; + const integrationIds = parseIds(searchParams.get("integrations")); const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; - const types = useQuery({ - queryKey: ["information-types-search", q, offset], - queryFn: () => api.searchInformationTypes({ search: q, offset, limit: PAGE_SIZE }), + const integrations = useIntegrationsCache().data ?? []; + + // The backend's Search endpoint has no "id is in this set" filter, so filtering by + // which integrations use a type can't be pushed down like name/format/bus can. Falls + // back to the full list, filtered and paged client-side, only while that filter is + // active — same trade-off as the Partners and Global values pages. + const filtering = integrationIds.length > 0; + + const serverSearch = useQuery({ + queryKey: ["information-types-search", q, format, busEnabled, offset], + queryFn: () => api.searchInformationTypes({ search: q, format, busEnabled, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, + enabled: !filtering, + }); + const allTypes = useQuery({ + queryKey: ["information-types-all"], + queryFn: () => api.listInformationTypes(), + enabled: filtering, }); - const integrations = useIntegrationsCache().data ?? []; const setParam = (key: string, value: string | null, resetOffset = true) => setSearchParams( @@ -40,8 +80,22 @@ export function InformationTypesPage() { { replace: key === "q" }, ); - const rows = types.data?.result ?? []; - const total = types.data?.total ?? 0; + const filteredSorted = useMemo(() => { + if (!filtering) return []; + const needle = q.trim().toLowerCase(); + const wanted = new Set(integrationIds); + return (allTypes.data ?? []).filter((t) => { + if (needle && !t.name.toLowerCase().includes(needle)) return false; + if (format && t.format !== format) return false; + if (busEnabled !== null && t.busEnabled !== busEnabled) return false; + const usedBy = integrations.filter((s) => s.informationTypeId === t.id); + return usedBy.some((s) => wanted.has(s.id)); + }); + }, [filtering, allTypes.data, q, format, busEnabled, integrationIds, integrations]); + + const isPending = filtering ? allTypes.isPending : serverSearch.isPending; + const rows = filtering ? filteredSorted.slice(offset, offset + PAGE_SIZE) : (serverSearch.data?.result ?? []); + const total = filtering ? filteredSorted.length : (serverSearch.data?.total ?? 0); return (
@@ -74,23 +128,56 @@ export function InformationTypesPage() { } /> -
- - setParam("q", e.target.value || null)} - placeholder="Search by name" - aria-label="Search information types" - className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" - /> +
+
+ + setParam("q", e.target.value || null)} + placeholder="Search by name" + aria-label="Search information types" + className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" + /> +
+
+ setParam("bus", e.target.value || null)} + options={BUS_OPTIONS} + /> +
+
+ setParam("integrations", ids.length ? ids.join(",") : null)} + label="Filter by integration" + /> +
- {types.isPending ? ( + {isPending ? ( ) : rows.length === 0 ? ( - } title={q ? "No information types match" : "No information types yet"}> - {q ? "Try a different search." : "Define the first kind of document your integrations will carry."} + } + title={q || format || busParam || integrationIds.length > 0 ? "No information types match" : "No information types yet"} + > + {q || format || busParam || integrationIds.length > 0 + ? "Try a different search or filter." + : "Define the first kind of document your integrations will carry."} ) : (
Date: Thu, 27 Aug 2026 11:58:30 +0300 Subject: [PATCH 12/14] feat: add status filter to API gateways --- .../ClientApp/src/api/http/gateways.ts | 12 ++++- .../pages/api-gateways/ApiGatewaysPage.tsx | 48 +++++++++++++------ 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts b/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts index ef76bcdd..6f86053c 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts @@ -136,9 +136,17 @@ export const gatewayMethods = { return (res.result ?? []).map(toApiGatewayRow); }, - async searchApiGateways(query: { search: string; offset: number; limit: number }): Promise> { + async searchApiGateways(query: { + search: string; + inactive?: boolean | null; + offset: number; + limit: number; + }): Promise> { const qs = buildListQuery({ - filters: [["Name", SEARCHY_RULE.contains, query.search.trim()]], + filters: [ + ["Name", SEARCHY_RULE.contains, query.search.trim()], + ["Inactive", SEARCHY_RULE.equalsTo, query.inactive === null || query.inactive === undefined ? "" : String(query.inactive)], + ], offset: query.offset, limit: query.limit, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx index f26d1f5e..6cf97961 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx @@ -6,6 +6,7 @@ import { Can } from "../../auth/guards"; import { PageHeader } from "../../components/layout/PageHeader"; import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; import { Pagination } from "../../components/ui/Pagination"; +import { Select } from "../../components/ui/forms"; import { Table } from "../../components/ui/Table"; import { LinkListCell, @@ -20,15 +21,23 @@ import { */ const PAGE_SIZE = 25; +const STATUS_OPTIONS = [ + { value: "", label: "Any status" }, + { value: "false", label: "Active" }, + { value: "true", label: "Deactivated" }, +]; + export function ApiGatewaysPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const q = searchParams.get("q") ?? ""; + const inactiveParam = searchParams.get("inactive"); + const inactive = inactiveParam === "true" ? true : inactiveParam === "false" ? false : null; const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; const gateways = useQuery({ - queryKey: ["api-gateways-search", q, offset], - queryFn: () => api.searchApiGateways({ search: q, offset, limit: PAGE_SIZE }), + queryKey: ["api-gateways-search", q, inactive, offset], + queryFn: () => api.searchApiGateways({ search: q, inactive, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, }); const integrationsById = useIntegrationRowsById(); @@ -62,23 +71,34 @@ export function ApiGatewaysPage() { } /> -
- - setParam("q", e.target.value || null)} - placeholder="Search gateways" - aria-label="Search API gateways" - className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" - /> +
+
+ + setParam("q", e.target.value || null)} + placeholder="Search gateways" + aria-label="Search API gateways" + className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" + /> +
+
+
Date: Thu, 27 Aug 2026 11:58:32 +0300 Subject: [PATCH 13/14] feat: add status filter to Scheduled jobs --- .../scheduled-jobs/ScheduledJobsPage.tsx | 49 +++++++++++++------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx index 7d6689dd..89c2c7ff 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx @@ -7,6 +7,7 @@ import { Can, useSessionCan } from "../../auth/guards"; import { PageHeader } from "../../components/layout/PageHeader"; import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; import { ConfirmDialog } from "../../components/ui/overlays"; +import { Select } from "../../components/ui/forms"; import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; import { @@ -65,6 +66,12 @@ function ReceiveNowButton({ job }: { job: IntegrationRow }) { * means the job is not going to run, while Status still reads "Active" — so it * outranks the ordinary badges rather than sitting beside them. */ +const STATUS_OPTIONS = [ + { value: "", label: "Any status" }, + { value: "false", label: "Active" }, + { value: "true", label: "Disabled" }, +]; + function ScheduleFault({ health }: { health: ScheduleHealth | undefined }) { const fault = scheduleFault(health); if (!fault) return null; @@ -89,13 +96,16 @@ export function ScheduledJobsPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const q = searchParams.get("q") ?? ""; + const inactiveParam = searchParams.get("inactive"); + const inactive = inactiveParam === "true" ? true : inactiveParam === "false" ? false : null; const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; const canOperate = useSessionCan("subscriptions.operate"); const canSeeInfoTypes = useSessionCan("documents.view"); const rows = useQuery({ - queryKey: ["integration-rows-search", "Receiving", q, offset], - queryFn: () => api.searchIntegrationRows({ search: q, type: "Receiving", offset, limit: PAGE_SIZE }), + queryKey: ["integration-rows-search", "Receiving", q, inactive, offset], + queryFn: () => + api.searchIntegrationRows({ search: q, type: "Receiving", inactive, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, }); // The list rows don't carry work group or retry policy; the integrations @@ -140,23 +150,34 @@ export function ScheduledJobsPage() { } /> -
- - setParam("q", e.target.value || null)} - placeholder="Search jobs" - aria-label="Search scheduled jobs" - className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" - /> +
+
+ + setParam("q", e.target.value || null)} + placeholder="Search jobs" + aria-label="Search scheduled jobs" + className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" + /> +
+
+
Date: Thu, 27 Aug 2026 11:58:35 +0300 Subject: [PATCH 14/14] feat: add status filter to Aggregations --- .../pages/aggregations/AggregationsPage.tsx | 51 +++++++++++++------ 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx index 55193811..1d476378 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx @@ -7,6 +7,7 @@ import { Can, useSessionCan } from "../../auth/guards"; import { PageHeader } from "../../components/layout/PageHeader"; import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; import { ConfirmDialog } from "../../components/ui/overlays"; +import { Select } from "../../components/ui/forms"; import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; import { AGGREGATION_TARGET_LABEL } from "../../components/config/AggregationFields"; @@ -64,6 +65,12 @@ function AggregateNowButton({ job }: { job: IntegrationRow }) { ); } +const STATUS_OPTIONS = [ + { value: "", label: "Any status" }, + { value: "false", label: "Active" }, + { value: "true", label: "Disabled" }, +]; + /** The scheduler disagreeing with the integration's own record — see the scheduled-jobs page. */ function ScheduleFault({ health }: { health: ScheduleHealth | undefined }) { const fault = scheduleFault(health); @@ -90,12 +97,15 @@ export function AggregationsPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const q = searchParams.get("q") ?? ""; + const inactiveParam = searchParams.get("inactive"); + const inactive = inactiveParam === "true" ? true : inactiveParam === "false" ? false : null; const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; const canOperate = useSessionCan("subscriptions.operate"); const rows = useQuery({ - queryKey: ["integration-rows-search", "Aggregation", q, offset], - queryFn: () => api.searchIntegrationRows({ search: q, type: "Aggregation", offset, limit: PAGE_SIZE }), + queryKey: ["integration-rows-search", "Aggregation", q, inactive, offset], + queryFn: () => + api.searchIntegrationRows({ search: q, type: "Aggregation", inactive, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, }); // The list rows don't carry work group or retry policy; the integrations cache does, @@ -140,24 +150,35 @@ export function AggregationsPage() { } /> -
- - setParam("q", e.target.value || null)} - placeholder="Search aggregations" - aria-label="Search aggregations" - className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" - /> +
+
+ + setParam("q", e.target.value || null)} + placeholder="Search aggregations" + aria-label="Search aggregations" + className="h-9 w-full rounded-lg border border-ink-200 bg-white pr-3 pl-9 text-sm placeholder:text-ink-400 focus:border-crimson-400 focus:ring-2 focus:ring-crimson-100 focus:outline-none" + /> +
+
+