diff --git a/SW.Bitween.Api/Resources/Xchanges/XchangeFilters.cs b/SW.Bitween.Api/Resources/Xchanges/XchangeFilters.cs index b3ddf3ae..7043cc3f 100644 --- a/SW.Bitween.Api/Resources/Xchanges/XchangeFilters.cs +++ b/SW.Bitween.Api/Resources/Xchanges/XchangeFilters.cs @@ -253,6 +253,24 @@ internal static IQueryable ApplySpecialFilters(this IQueryable f.Field == "PromotedPropertyKey").ToList(); + var key = keyFilters.Select(f => f.Value?.ToString()).FirstOrDefault(v => !string.IsNullOrEmpty(v))?.ToLower(); + foreach (var keyFilter in keyFilters) + condition.Filters.Remove(keyFilter); + + // Stored as "key:value,key:value": the key comes first or after a comma, which keeps "id" + // from matching "orderId:". Asked of lower(properties_raw) as it is, not with a comma put in + // front: Postgres keeps statistics for that expression (its trigram index), and without them + // it guessed almost nothing matched, so a common key fetched and sorted every match rather + // than reading the newest 25 - 6s against 1M exchanges. + var keyTag = "," + key + ":"; + if (key != null) + query = query.Where(i => i.PromotedPropertiesRaw.ToLower().StartsWith(key + ":") + || i.PromotedPropertiesRaw.ToLower().Contains(keyTag)); + var propertiesFilters = condition.Filters .Where(f => f.Field == "PromotedPropertiesRaw").ToList(); foreach (var propertyFilter in propertiesFilters) @@ -264,6 +282,19 @@ internal static IQueryable ApplySpecialFilters(this IQueryable i.PromotedPropertiesRaw.ToLower().Contains(value)); + + // With a key, only inside that key's value: what follows ",key:" up to the next comma. + // A value that itself holds a comma is only searched up to it, since the stored text + // cannot tell that comma from the one before the next key. IndexOf rather than Contains + // for the same estimate reason as the key above: Postgres guesses a third of rows pass + // an unknown comparison but under 1% pass an unknown LIKE. + if (key != null) + query = from i in query + let rest = ("," + i.PromotedPropertiesRaw.ToLower()) + .Substring(("," + i.PromotedPropertiesRaw.ToLower()).IndexOf(keyTag) + keyTag.Length) + "," + where rest.Substring(0, rest.IndexOf(",")).IndexOf(value) >= 0 + select i; + condition.Filters.Remove(propertyFilter); } diff --git a/SW.Bitween.Api/SW.Bitween.Api.csproj b/SW.Bitween.Api/SW.Bitween.Api.csproj index 44124cda..5b9d36d7 100644 --- a/SW.Bitween.Api/SW.Bitween.Api.csproj +++ b/SW.Bitween.Api/SW.Bitween.Api.csproj @@ -38,8 +38,8 @@ them to ask the broker what exists; without this it would have to rebuild each of them from configuration and get them subtly wrong. Already in the app through SW.Bitween.Web - keep the version in step with it. --> - - + + diff --git a/SW.Bitween.IntegrationTests/SW.Bitween.IntegrationTests.csproj b/SW.Bitween.IntegrationTests/SW.Bitween.IntegrationTests.csproj index e98dc48b..4dabaa37 100644 --- a/SW.Bitween.IntegrationTests/SW.Bitween.IntegrationTests.csproj +++ b/SW.Bitween.IntegrationTests/SW.Bitween.IntegrationTests.csproj @@ -55,7 +55,7 @@ - + diff --git a/SW.Bitween.IntegrationTests/Tests/PromotedPropertySearchTests.cs b/SW.Bitween.IntegrationTests/Tests/PromotedPropertySearchTests.cs new file mode 100644 index 00000000..969f3156 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/PromotedPropertySearchTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// The Exchanges page's promoted property filter: a key picked from the list and text that has to +/// be found anywhere inside that key's value. +/// +[Collection("Bitween")] +public class PromotedPropertySearchTests(BitweenFixture fixture) +{ + [Fact] + public async Task A_picked_key_finds_part_of_its_own_value_and_nothing_in_other_keys() + { + await using var scope = fixture.CreateScope(); + scope.Superuser(); + var db = scope.ServiceProvider.GetRequiredService(); + + var document = new Document(null, $"Prop search {Guid.NewGuid():N}"[..24], DocumentFormat.Json); + document.SetDictionaries(new Dictionary + { ["orderNo"] = "$.o", ["customer"] = "$.c", ["refOrderNo"] = "$.r" }); + db.Add(document); + await db.SaveChangesAsync(); + + var service = scope.ServiceProvider.GetRequiredService(); + async Task Exchange(params (string Key, string Value)[] properties) + { + var xchange = await service.CreateXchange(document, null, new XchangeFile("{}")); + var filtered = new FilterResult(); + foreach (var (key, value) in properties) + filtered.Properties[key] = value; + db.Add(new XchangePromotedProperties(xchange.Id, filtered)); + await db.SaveChangesAsync(); + return xchange.Id; + } + + var a = await Exchange(("orderNo", "SO-1001-X"), ("customer", "Acme")); + // "1001" sits after orderNo here, in the next key's value. + var b = await Exchange(("orderNo", "PO-7"), ("customer", "Zed-1001")); + // A key whose name ends in the picked one. + var c = await Exchange(("refOrderNo", "R-1001")); + + await using var readScope = fixture.CreateScope(); + readScope.Superuser(); + async Task Found(params string[] filters) + { + var page = (SearchyResponse)await ActivatorUtilities + .CreateInstance(readScope.ServiceProvider) + .Handle(new SearchyRequest(filters.Append($"DocumentId:1:{document.Id}").ToArray()), false, null); + return page.Result.Select(r => r.Id).Order().ToArray(); + } + + string[] Ids(params string[] ids) => ids.Order().ToArray(); + + // The middle of the value, which a "orderNo:1001" term never found. + Assert.Equal(Ids(a), await Found("PromotedPropertyKey:1:orderNo", "PromotedPropertiesRaw:4:1001")); + Assert.Equal(Ids(a), await Found("PromotedPropertyKey:1:ORDERNO", "PromotedPropertiesRaw:4:01-x")); + Assert.Equal(Ids(b), await Found("PromotedPropertyKey:1:orderNo", "PromotedPropertiesRaw:4:7")); + // A key on its own: "carries this property at all". + Assert.Equal(Ids(a, b), await Found("PromotedPropertyKey:1:orderNo")); + // No key: anywhere, as before. + Assert.Equal(Ids(a, b, c), await Found("PromotedPropertiesRaw:4:1001")); + } +} diff --git a/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj b/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj index da7a5756..86fc73aa 100644 --- a/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj +++ b/SW.Bitween.UnitTests/SW.Bitween.UnitTests.csproj @@ -17,7 +17,7 @@ - + diff --git a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts index 0b0c8161..baa57549 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts @@ -193,13 +193,11 @@ function buildExchangeFilters(query: ExchangeQuery): URLSearchParams { params.append("filter", `ReceiveAttemptId:1:${query.receiveAttemptId}`); if (query.correlationId?.trim()) params.append("filter", `CorrelationId:1:${query.correlationId.trim()}`); if (query.latest) params.append("filter", "LatestOnly:1:true"); - // PromotedPropertiesRaw is stored as "key:value,key:value", so prefixing the key turns - // the same substring match into a scoped one - no schema or endpoint change needed. - // Typing "merchant:Acme" into the value box has therefore always worked; the picker - // just makes it something you can find. - const propertyValue = query.property?.trim() ?? ""; - const propertyTerm = query.propertyKey ? `${query.propertyKey}:${propertyValue}` : propertyValue; - if (propertyTerm) params.append("filter", `PromotedPropertiesRaw:4:${propertyTerm}`); + // The key goes apart from the value so the server can look anywhere inside that key's value. + // Sent as one "key:value" term it only matched values starting with what was typed. + if (query.propertyKey) params.append("filter", `PromotedPropertyKey:1:${query.propertyKey}`); + const propertyValue = query.property?.trim(); + if (propertyValue) params.append("filter", `PromotedPropertiesRaw:4:${propertyValue}`); if (query.errorContains?.trim()) params.append("filter", `ErrorContains:4:${query.errorContains.trim()}`); if (query.isRetry) params.append("filter", "IsRetry:1:true"); if (query.scheduledRetry) params.append("filter", "HasScheduledRetry:1:true"); diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx index 0a310798..e667a999 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Link, useSearchParams } from "react-router"; import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowUp, Download, Pause, Play, Plus, RotateCcw, X } from "lucide-react"; +import { ArrowUp, Download, Pause, Play, Plus, RefreshCw, RotateCcw, X } from "lucide-react"; import { api, type BulkRetrySelection, @@ -94,12 +94,16 @@ export function ExchangesPage() { // A relative range ("last hour") is worked out each time the list is asked for, so a live list // keeps showing the last hour rather than the hour before the page was opened. - const { data, isLoading, dataUpdatedAt } = useQuery({ + const { data, isLoading, isFetching, isPlaceholderData, dataUpdatedAt, refetch } = useQuery({ queryKey: keys.exchanges.search(viewSearch(searchParams) + `&offset=${query.offset}`), queryFn: () => api.searchExchanges(readQuery(searchParams)), refetchInterval: refreshMs || false, placeholderData: keepPreviousData, }); + // Loads someone is waiting on: a changed filter or page (the old rows stay up meanwhile), or the + // Refresh button. Not the background ticks of live refresh, which would dim the list every 15s. + const [refreshing, setRefreshing] = useState(false); + const reloading = isPlaceholderData || refreshing; // --- counts and volume --- // With a time filter, one request answers both. Without one the list covers all time, which @@ -124,6 +128,16 @@ export function ExchangesPage() { retry: false, }); const volume = timed ? breakdown.data : chartBreakdown.data; + + // Now, rather than at the next tick - or at all, paused. The counts and the chart come along so + // they never disagree with the rows. The chart's query is off when the list is timed, and a + // refetch would run it anyway, against no parameters. + const refresh = () => { + setRefreshing(true); + void Promise.all([refetch(), breakdown.refetch(), chartParams ? chartBreakdown.refetch() : null]).finally(() => + setRefreshing(false), + ); + }; const rangeEndsNow = !searchParams.get("to") && searchParams.get("range") !== "yesterday"; const subscriptions = useSubscriptionsCache().data ?? []; @@ -347,7 +361,13 @@ export function ExchangesPage() { description="Every message that flows through Bitween. Open one to follow its journey and read its documents." actions={ <> - + + ); } diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesQueryBar.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesQueryBar.test.tsx index 9f8868ba..3a662e00 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesQueryBar.test.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesQueryBar.test.tsx @@ -305,3 +305,31 @@ describe("pausing", () => { expect(filters(asked)).toContain("StartedOn:6:2026-09-20T10:00:00.001Z"); }); }); + +describe("refresh", () => { + it("asks for the list and the counts again at once, even paused", async () => { + const api = backend(); + const { user } = renderApp("/exchanges", { handlers: api.handlers }); + + // Paused, nothing else asks: every request after the click is the button's. + await user.click(await screen.findByRole("button", { name: "Pause live updates" })); + await screen.findByText("SO-0"); + const before = { listed: api.listed.length, summarised: api.summarised.length }; + + await user.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(api.listed.length).toBeGreaterThan(before.listed)); + expect(api.summarised.length).toBeGreaterThan(before.summarised); + }); +}); + +describe("promoted property", () => { + it("sends the picked key apart from the value, so any part of that key's value matches", async () => { + const api = backend(); + renderApp("/exchanges?propertyKey=orderNo&property=1001", { handlers: api.handlers }); + + await waitFor(() => expect(api.listed.length).toBeGreaterThan(0)); + const sent = filters(api.listed.at(-1)!); + expect(sent).toContain("PromotedPropertyKey:1:orderNo"); + expect(sent).toContain("PromotedPropertiesRaw:4:1001"); + }); +}); diff --git a/SW.Bitween.Web/SW.Bitween.Web.csproj b/SW.Bitween.Web/SW.Bitween.Web.csproj index be0d47b6..184a34dd 100644 --- a/SW.Bitween.Web/SW.Bitween.Web.csproj +++ b/SW.Bitween.Web/SW.Bitween.Web.csproj @@ -46,7 +46,7 @@ - + diff --git a/docs/configuration.md b/docs/configuration.md index 97f9c4ad..af966da7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,6 +45,20 @@ published in this repository. On startup, while that account still has the defau Once the password is anything else the value is ignored, so it can stay in place or be removed. +### Bus messages from other applications + +A message another application publishes to Bitween's bus can carry its user's token. Bitween checks +it with its own `Token` values, so a publisher signing with a different key fails every message it +sends: it goes straight to the queue's `.bad` queue and no exchange is created. Give both +applications the same `Token` values. Until they match, `Bus:OnInvalidUserToken` +(env: `Bus__OnInvalidUserToken`) can change what happens: + +| Value | Effect | +|---|---| +| `Fail` *(default)* | The message goes to the `.bad` queue without retrying. | +| `ContinueAnonymous` | The message is processed without a user, and a warning is logged. | +| `ContinueAnonymousSilently` | The same, without the warning. | + ## `Bitween` section | Key | Default | Description | diff --git a/docs/operations.md b/docs/operations.md index 740b786b..8a1327fd 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -6,7 +6,7 @@ ## Finding an exchange -The Exchanges page filters by status, subscription, partner, information type, exchange ids, correlation id, a promoted property value and a date range. Filters live in the URL, so a filtered view can be shared as a link. +The Exchanges page filters by status, subscription, partner, information type, exchange ids, correlation id, a promoted property value and a date range. Pick a promoted property to search inside its value only; any part of the value matches, ignoring case. Filters live in the URL, so a filtered view can be shared as a link. The list reloads itself every 15 seconds by default (adjustable, or paused); **Refresh**, beside the pause button, reloads the list and its counts right away. - Search by **promoted property** to find a business document, such as an order number. - Search by **correlation id** to see every exchange in one flow, including exchanges created from responses.