diff --git a/SW.Bitween.Api/Resources/Subscriptions/Search.cs b/SW.Bitween.Api/Resources/Subscriptions/Search.cs index 0083bdf..302bfd0 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 f9264d4..a6a17b0 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 818e661..fceb676 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 9171eff..aacfdb4 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 5211a96..39f177b 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; } diff --git a/SW.Bitween.Web/ClientApp/src/api/client.ts b/SW.Bitween.Web/ClientApp/src/api/client.ts index 21f919f..670260e 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, @@ -16,6 +17,7 @@ import type { GlobalValuesSetRow, InformationType, InformationTypeDetail, + InformationTypeFormat, InformationTypeRow, Subscription, SubscriptionDetail, @@ -129,6 +131,8 @@ export interface ApiClient { listInformationTypes(): Promise; searchInformationTypes(query: { search: string; + format?: InformationTypeFormat | null; + busEnabled?: boolean | null; offset: number; limit: number; }): Promise>; @@ -179,6 +183,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 +222,7 @@ export interface ApiClient { | "schedules" | "responseSubscriptionId" | "responseMessageTypeName" + | "aggregationTarget" > >, ): Promise; @@ -221,6 +230,8 @@ export interface ApiClient { /** Toggles paused: paused subscriptions accept work but hold it. */ pauseSubscription(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 subscription, newest first. Empty for unscheduled types. */ listSubscriptionRuns(id: number, limit?: number): Promise; searchReceiveAttempts( @@ -251,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 0bed1fd..8278b0d 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/api/http/gateways.ts b/SW.Bitween.Web/ClientApp/src/api/http/gateways.ts index ffff5ec..fc6bcec 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/api/http/subscriptions.ts b/SW.Bitween.Web/ClientApp/src/api/http/subscriptions.ts index 0266ac9..d5f2e4a 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/subscriptions.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/subscriptions.ts @@ -1,6 +1,7 @@ import type { ApiClient } from "../client"; import { ApiRequestError, + type AggregationTarget, type Subscription, type SubscriptionDetail, type SubscriptionInfo, @@ -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 toSubscription(raw: RawSubscription, idOverride?: number): Subscription { return { id: raw.id ?? idOverride!, @@ -157,8 +165,9 @@ function toSubscription(raw: RawSubscription, idOverride?: number): Subscription responseSubscriptionId: 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" | "responseSubscriptionId" | "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 toSubscriptionRow( 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 subscriptionMethods = { async createSubscription(input: { type: SubscriptionType; 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 subscriptionMethods = { 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 subscriptionMethods = { return toSubscription(await fetchRaw(id), id); }, + async aggregateNow(id: number): Promise { + await post(`/subscriptions/${id}/aggregatenow`, {}); + return toSubscription(await fetchRaw(id), id); + }, + listSubscriptionRuns(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 d748d45..3ee6743 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 { // ——— Subscriptions (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 a subscription 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 Subscription { /** Feed the handler's response into another subscription. */ responseSubscriptionId: 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 SubscriptionRow { 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; } 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 0000000..6ff44ff --- /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 subscription'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 subscription 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

+ )} +
+ )} + + + setQuery(e.target.value)} + placeholder="Search subscriptions" + aria-label="Search subscriptions" + 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 subscriptions match.

+ ) : ( + matches.map((i) => ( + + )) + )} +
+ + )} + + ); +} diff --git a/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx b/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx index 08b34a3..b07e55c 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} + ))} diff --git a/SW.Bitween.Web/ClientApp/src/nav.ts b/SW.Bitween.Web/ClientApp/src/nav.ts index aef57ea..7ef6c18 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, @@ -62,6 +63,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 0000000..80c5ad8 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx @@ -0,0 +1,399 @@ +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 SubscriptionRow, 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 { 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"; +import { + HealthBadge, + SubscriptionStatusBadges, + LinkListCell, + scheduleFault, + useSubscriptionsCache, + useRetryPolicyNames, + useWorkGroupNames, +} from "../../components/config/shared"; +import { formatDateTime, formatDurationMs, timeAgo, timeUntil } from "../../lib/dates"; + +function AggregateNowButton({ job }: { job: SubscriptionRow }) { + const queryClient = useQueryClient(); + const [confirming, setConfirming] = useState(false); + const aggregate = useMutation({ + mutationFn: () => api.aggregateNow(job.id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["subscription-rows"] }); + void queryClient.invalidateQueries({ queryKey: ["subscription-rows-search"] }); + void queryClient.invalidateQueries({ queryKey: ["last-runs"] }); + }, + }); + + return ( + <> + + {confirming && ( + { + await aggregate.mutateAsync(); + }} + onClose={() => setConfirming(false)} + /> + )} + + ); +} + +const STATUS_OPTIONS = [ + { value: "", label: "Any status" }, + { value: "false", label: "Active" }, + { value: "true", label: "Disabled" }, +]; + +/** The scheduler disagreeing with the subscription'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 + * subscription'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 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: ["subscription-rows-search", "Aggregation", q, inactive, offset], + queryFn: () => + api.searchSubscriptionRows({ search: q, type: "Aggregation", inactive, offset, limit: PAGE_SIZE }), + placeholderData: keepPreviousData, + }); + // The list rows don't carry work group or retry policy; the subscriptions cache does, + // and it is also where the source subscription's name comes from. + const setups = useSubscriptionsCache().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.subscriptionId, r])), [lastRuns]); + const health = + useQuery({ queryKey: ["schedule-health"], queryFn: () => api.listScheduleHealth() }).data ?? []; + const healthById = useMemo(() => new Map(health.map((h) => [h.subscriptionId, 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" + /> +
+
+ 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" + /> +
+
+ 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("subscriptions", ids.length ? ids.join(",") : null)} + label="Filter by subscription" + /> +
{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 || subscriptionIds.length > 0 ? "No value sets match" : "No value sets yet"} + > + {q || subscriptionIds.length > 0 + ? "Try a different search or filter." + : "Create a set of shared values your adapters can reference."} ) : ( + 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 subscriptionIds = parseIds(searchParams.get("subscriptions")); 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 subscriptions = useSubscriptionsCache().data ?? []; + + // The backend's Search endpoint has no "id is in this set" filter, so filtering by + // which subscriptions 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 = subscriptionIds.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 subscriptions = useSubscriptionsCache().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(subscriptionIds); + 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 = subscriptions.filter((s) => s.informationTypeId === t.id); + return usedBy.some((s) => wanted.has(s.id)); + }); + }, [filtering, allTypes.data, q, format, busEnabled, subscriptionIds, subscriptions]); + + 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("subscriptions", ids.length ? ids.join(",") : null)} + label="Filter by subscription" + /> +
- {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 subscriptions will carry."} + } + title={q || format || busParam || subscriptionIds.length > 0 ? "No information types match" : "No information types yet"} + > + {q || format || busParam || subscriptionIds.length > 0 + ? "Try a different search or filter." + : "Define the first kind of document your subscriptions will carry."} ) : (
+ 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 subscriptionIds = parseIds(searchParams.get("subscriptions")); 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 partnerSubscriptions = usePartnerSubscriptions(); - 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 partnerSubscriptions = usePartnerSubscriptions(); + const subscriptions = useSubscriptionsCache().data ?? []; + + // The backend's Search endpoint has no "id is in this set" filter, so filtering by + // which subscriptions a partner is wired to can't be pushed down like the name search + // can. Every partner is already loaded once for `usePartnerSubscriptions` 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 = subscriptionIds.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(subscriptionIds); + return (allPartners.data ?? []).filter((p) => { + if (needle && !p.name.toLowerCase().includes(needle)) return false; + const usedBy = partnerSubscriptions.get(p.id) ?? []; + return usedBy.some((i) => wanted.has(i.id)); + }); + }, [filtering, allPartners.data, partnerSubscriptions, q, subscriptionIds]); + + 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 || subscriptionIds.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("subscriptions", ids.length ? ids.join(",") : null)} + label="Filter by subscription" + /> +
- {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."} ) : (
api.searchSubscriptionRows({ search: q, type: "Receiving", offset, limit: PAGE_SIZE }), + queryKey: ["subscription-rows-search", "Receiving", q, inactive, offset], + queryFn: () => + api.searchSubscriptionRows({ search: q, type: "Receiving", inactive, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, }); // The list rows don't carry work group or retry policy; the subscriptions @@ -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" + /> +
+
+
{ @@ -126,6 +128,15 @@ export function SubscriptionPage() { onSuccess: invalidate, }); + const aggregate = useMutation({ + mutationFn: () => api.aggregateNow(subscriptionId), + onSuccess: async () => { + await invalidate(); + void queryClient.invalidateQueries({ queryKey: ["subscription-runs", subscriptionId] }); + void queryClient.invalidateQueries({ queryKey: ["last-runs"] }); + }, + }); + const receive = useMutation({ mutationFn: () => api.receiveNow(subscriptionId), onSuccess: async () => { @@ -153,6 +164,8 @@ export function SubscriptionPage() { const isInternal = s.type === "Internal"; const isApiCall = s.type === "ApiCall"; const isAggregation = s.type === "Aggregation"; + const aggregationSource = + allSubscriptions.data?.find((x) => x.id === s.aggregationForId) ?? null; const paused = s.pausedOn !== null; const entryPoints = entryPointsOf(s); @@ -185,8 +198,13 @@ export function SubscriptionPage() { catalogs: { receivers, validators, mappers, handlers }, entryPoints, nextRunOn: s.nextReceiveOn, - fault: id === "schedule" || id === "aggregation" ? fault : undefined, + // The fault belongs to the Schedule node now that aggregations have one. It used + // to be pinned to the aggregation node too, which was the only node they had — + // and that node could not be edited, so the page reported a broken schedule and + // offered no way to fix it. + fault: id === "schedule" ? fault : undefined, subscriptionNames: allSubscriptions.data, + aggregationForId: s.aggregationForId, }), ); @@ -231,7 +249,12 @@ export function SubscriptionPage() { ); case "schedule": return ( - + set("schedules", schedules)} @@ -242,10 +265,12 @@ export function SubscriptionPage() { case "aggregation": return ( -

- Aggregation settings aren't editable in the new UI yet — they arrive in a later phase. - Everything else on this page works as usual. -

+ set("aggregationTarget", aggregationTarget)} + disabled={!canEdit} + />
); case "validation": @@ -357,6 +382,36 @@ export function SubscriptionPage() { Receive now )} + {isAggregation && canOperate && ( + + )} + {/* Creating the aggregation from here rather than from a type picker: the + thing being rolled up is the page you are standing on, so it can never + be the wrong one. Not offered on an aggregation — roll-ups of roll-ups + are not a shape anyone has asked for, and the picker refuses them too. */} + {!isAggregation && ( + + + + )} {canOperate && ( - {receive.error?.message} + {receive.error?.message ?? aggregate.error?.message} @@ -444,6 +499,18 @@ export function SubscriptionPage() { /> )} + {confirmingAggregate && ( + { + await aggregate.mutateAsync(); + }} + onClose={() => setConfirmingAggregate(false)} + /> + )} + {deleting && ( api.listRetryPolicies() }); - // Receiving gets its own attempt history (ReceiveAttemptsPanel) instead — the scheduler's - // run history there is Quartz vocabulary an operator has no reason to know, and it always - // reports success even when the receive step itself failed (see ReceivingJob). + // Both scheduled types keep their own attempt history and show it in one table + // (ReceiveAttemptsPanel) instead of the scheduler's run history beside a separate exchange + // list. The scheduler's history is Quartz vocabulary an operator has no reason to know, it + // always reports success even when the job's own step failed, and — the reason two tables + // were worse than one — nothing joins a run to the exchange it produced. const receiving = s.type === "Receiving"; + const aggregation = s.type === "Aggregation"; + const attemptKind: AttemptKind | null = receiving ? "receiving" : aggregation ? "aggregation" : null; const runs = useQuery({ queryKey: ["subscription-runs", s.id], queryFn: () => api.listSubscriptionRuns(s.id, 20), - enabled: scheduled && !receiving, + enabled: scheduled && attemptKind === null, }); // Just for the "Last run" fact above — ReceiveAttemptsPanel fetches its own page. const latestAttempt = useQuery({ queryKey: ["receive-attempts", s.id, null, 0, 1], queryFn: () => api.searchReceiveAttempts(s.id, { outcome: null, offset: 0, limit: 1 }), - enabled: receiving, + enabled: attemptKind !== null, }); const lastReceiveRun: SubscriptionRun | undefined = ((): SubscriptionRun | undefined => { const a = latestAttempt.data?.result[0]; @@ -220,7 +224,7 @@ export function Overview({ <> {s.nextReceiveOn ? timeUntil(s.nextReceiveOn) : "—"} - + )} @@ -295,9 +299,9 @@ export function Overview({ on resume.

)} - {/* Receiving gets this per-attempt instead, in ReceiveAttemptsPanel below — showing - it again here duplicated the same error twice on one page. */} - {s.lastException && !receiving && ( + {/* The scheduled types get this per-attempt instead, in ReceiveAttemptsPanel below — + showing it again here duplicated the same error twice on one page. */} + {s.lastException && attemptKind === null && (
           {s.lastException}
         
@@ -311,15 +315,15 @@ export function Overview({ /> )} - {receiving && ( + {attemptKind !== null && ( - + )}
- {scheduled && !receiving && ( + {scheduled && attemptKind === null && ( @@ -333,7 +337,7 @@ export function Overview({
- {!receiving && ( + {attemptKind === null && ( diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ReceiveAttemptsPanel.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ReceiveAttemptsPanel.tsx index 4a6d425..2c0ddd3 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ReceiveAttemptsPanel.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ReceiveAttemptsPanel.tsx @@ -13,12 +13,67 @@ import { formatDateTime, timeAgo } from "../../../lib/dates"; const PAGE_SIZE = 25; -const OUTCOME_OPTIONS: { value: string; label: string }[] = [ - { value: "", label: "All" }, - { value: "Failed", label: "Couldn't check" }, - { value: "NoNewData", label: "Nothing new" }, - { value: "Received", label: "Received data" }, -]; +/** + * Both scheduled types write into the same history, so this panel serves both — but a + * receiver "checks for new data" and an aggregation "rolls up what its source produced", + * and one set of words cannot honestly describe both. The outcomes underneath are the + * same three; only what they mean to a person changes. + */ +export type AttemptKind = "receiving" | "aggregation"; + +const WORDING: Record< + AttemptKind, + { + description: string; + empty: string; + emptyHint: string; + exchangeHeader: string; + exchangeHint: string; + resultHint: string; + failed: string; + failedHint: string; + nothing: string; + nothingHint: string; + outcomes: { value: string; label: string }[]; + } +> = { + receiving: { + description: "Every time this subscription checked for new data — what it found, and what happened to it.", + empty: "No checks recorded yet", + emptyHint: "This subscription hasn't checked for new data since this history started being kept.", + exchangeHeader: "Exchange", + exchangeHint: "The exchanges this run created, one per document it received.", + resultHint: "What the run found when it checked its source.", + failed: "Couldn't check", + failedHint: "The run could not reach or read its source — no documents were received.", + nothing: "Nothing new", + nothingHint: "Checked for new data — there was nothing to receive this time.", + outcomes: [ + { value: "", label: "All" }, + { value: "Failed", label: "Couldn't check" }, + { value: "NoNewData", label: "Nothing new" }, + { value: "Received", label: "Received data" }, + ], + }, + aggregation: { + description: "Every time this aggregation ran — whether it had anything to collect, and the exchange it produced.", + empty: "No runs recorded yet", + emptyHint: "This aggregation hasn't run since this history started being kept.", + exchangeHeader: "Roll-up", + exchangeHint: "The single exchange this run produced. Opening it also lists everything it collected.", + resultHint: "Whether the run found anything outstanding to roll up.", + failed: "Couldn't run", + failedHint: "The run threw before it finished — nothing was rolled up.", + nothing: "Nothing to roll up", + nothingHint: "Ran, but the source had no new successful exchanges waiting. No exchange is created for an empty period.", + outcomes: [ + { value: "", label: "All" }, + { value: "Failed", label: "Couldn't run" }, + { value: "NoNewData", label: "Nothing to roll up" }, + { value: "Received", label: "Rolled up" }, + ], + }, +}; function CopyErrorButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); @@ -69,16 +124,27 @@ function ErrorText({ text }: { text: string }) { ); } -function AttemptResult({ attempt }: { attempt: ReceiveAttemptRow }) { +function AttemptResult({ attempt, kind }: { attempt: ReceiveAttemptRow; kind: AttemptKind }) { + const w = WORDING[kind]; if (attempt.outcome === "Failed") return ( - Couldn't check + + {w.failed} + {attempt.errorMessage && } ); - if (attempt.outcome === "NoNewData") - return Nothing new; + if (attempt.outcome === "NoNewData") return {w.nothing}; + // No count for an aggregation: the attempt holds the one roll-up it made, not the number + // of exchanges that went into it — saying "Rolled up 1" would be a plain lie. The link in + // the next column opens the roll-up together with everything it collected. + if (kind === "aggregation") + return ( + + Rolled up + + ); return ( Received {attempt.exchanges.length} item{attempt.exchanges.length === 1 ? "" : "s"} @@ -113,7 +179,14 @@ function AttemptExchanges({ exchanges }: { exchanges: ReceiveAttemptRow["exchang * that couldn't even connect shows up here just as much as one that produced an exchange — * "Exchanges" as a title would undersell the rows that have none. */ -export function ReceiveAttemptsPanel({ subscriptionId }: { subscriptionId: number }) { +export function ReceiveAttemptsPanel({ + subscriptionId, + kind, +}: { + subscriptionId: number; + kind: AttemptKind; +}) { + const w = WORDING[kind]; const [outcome, setOutcome] = useState(null); const [offset, setOffset] = useState(0); @@ -131,9 +204,7 @@ export function ReceiveAttemptsPanel({ subscriptionId }: { subscriptionId: numbe

Runs

-

- Every time this subscription checked for new data — what it found, and what happened to it. -

+

{w.description}

( {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) => , + }, ]} /> )} diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/faces.ts b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/faces.ts index 419cda4..bc6edaf 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/faces.ts +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/faces.ts @@ -1,4 +1,5 @@ import type { AdapterInfo, SubscriptionType } from "../../../api"; +import { AGGREGATION_TARGET_DETAIL } from "../../../components/config/AggregationFields"; import { schedulesSummary } from "../../../lib/schedules"; import { formatDateTime } from "../../../lib/dates"; import type { StageFace } from "./StageRail"; @@ -8,6 +9,7 @@ import { locationHint, stageDirty, type Draft, type EntryPoint } from "./model"; const labelOf = (catalog: { data: AdapterInfo[] | undefined }, id: string | null) => catalog.data?.find((a) => a.id === id)?.label ?? id; + /** * Whether a chosen adapter still has a required property empty. * @@ -43,8 +45,10 @@ export interface FaceInput { nextRunOn?: string | null; /** Only ever set on the Schedule node — see `StageFace.fault`. */ fault?: StageFace["fault"]; - /** Names for the "feed the response into" target. */ + /** Names for the "feed the response into" target, and for the aggregation's source. */ subscriptionNames?: { id: number; name: string }[]; + /** Aggregation only: whose exchanges it rolls up. Fixed at creation, so not on the draft. */ + aggregationForId?: number | null; /** * Set while the subscription is being defined and nothing is saved yet. A delivery-less * subscription that already exists is a legal thing that records and stops; one being @@ -61,7 +65,8 @@ export interface FaceInput { * while editing is how the two drift apart. */ export function faceOf(stageId: StageId, input: FaceInput): StageFace { - const { type, draft: d, catalogs, saved, entryPoints = [], nextRunOn, fault, subscriptionNames, unsaved } = input; + const { type, draft: d, catalogs, saved, entryPoints = [], nextRunOn, fault, subscriptionNames, + aggregationForId, unsaved } = input; const dirty = saved ? stageDirty(stageId, d, saved) : false; switch (stageId) { @@ -100,8 +105,20 @@ export function faceOf(stageId: StageId, input: FaceInput): StageFace { state: d.schedules.length ? "set" : "missing", fault, }; - case "aggregation": - return { id: stageId, dirty, title: "Not editable yet", state: "none", fault }; + case "aggregation": { + // The source is what the node is *for*, so it is the title even though the only + // editable half is which file gets collected — an aggregation without a source is + // not a thing the backend will create. + const source = subscriptionNames?.find((x) => x.id === aggregationForId)?.name; + return { + id: stageId, + dirty, + title: source ?? (aggregationForId === null || aggregationForId === undefined ? "No source" : "Deleted subscription"), + detail: AGGREGATION_TARGET_DETAIL[d.aggregationTarget], + state: aggregationForId ? "set" : "missing", + fault, + }; + } case "validation": return { id: stageId, diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/model.ts b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/model.ts index f1b4476..58dcfea 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/model.ts +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/model.ts @@ -20,6 +20,7 @@ export type Draft = Pick< | "schedules" | "responseSubscriptionId" | "responseMessageTypeName" + | "aggregationTarget" >; export const draftOf = (d: SubscriptionDetail): Draft => ({ @@ -39,6 +40,7 @@ export const draftOf = (d: SubscriptionDetail): Draft => ({ schedules: structuredClone(d.schedules), responseSubscriptionId: d.responseSubscriptionId, responseMessageTypeName: d.responseMessageTypeName, + aggregationTarget: d.aggregationTarget, }); /** @@ -66,6 +68,7 @@ export const EMPTY_SUBSCRIPTION: Draft = { schedules: [], responseSubscriptionId: null, responseMessageTypeName: null, + aggregationTarget: "Input", }; /** @@ -84,7 +87,7 @@ const STAGE_FIELDS: Record = { trigger: ["matchExpression"], source: ["receiverId", "receiverProperties"], schedule: ["schedules"], - aggregation: [], + aggregation: ["aggregationTarget"], validation: ["validatorId", "validatorProperties"], transformation: ["mapperId", "mapperProperties"], delivery: ["handlerId", "handlerProperties"], diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/stages.ts b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/stages.ts index 0098a8a..6965690 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/stages.ts +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/stages.ts @@ -37,7 +37,11 @@ export const STAGES: Record ), }, + { + path: "aggregations", + element: ( + + + + ), + }, + { + path: "aggregations/new", + element: ( + + + + ), + }, { path: "api-gateways/new", element: (