From 81436f24cf8a36ca5ec403192bc0086a50b542d5 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 13 Sep 2026 11:06:22 +0300 Subject: [PATCH 01/12] fix: show the whole exchange id, elapsed in ms, and real file sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sizes were never projected — XchangeRow had an InputFileSize nothing assigned and no output/response fields, so every stage read "0 b". Co-Authored-By: Claude Opus 5 (1M context) --- SW.Bitween.Api/Resources/Xchanges/Search.cs | 5 ++++ SW.Bitween.Sdk/Model/Xchange.cs | 2 ++ .../ClientApp/src/api/http/exchanges.ts | 23 ++++++++++++------- .../src/components/config/shared.tsx | 10 ++++---- SW.Bitween.Web/ClientApp/src/lib/dates.ts | 18 ++++++++------- 5 files changed, 38 insertions(+), 20 deletions(-) diff --git a/SW.Bitween.Api/Resources/Xchanges/Search.cs b/SW.Bitween.Api/Resources/Xchanges/Search.cs index 78fcc2e7..f796ccef 100644 --- a/SW.Bitween.Api/Resources/Xchanges/Search.cs +++ b/SW.Bitween.Api/Resources/Xchanges/Search.cs @@ -79,6 +79,11 @@ from delayedRetry in drGroup.DefaultIfEmpty() InputFileName = xchange.InputName, OutputFileName = result.OutputName, ResponseFileName = result.ResponseName, + // The same three counts the file keys above are already derived from. + // Left unassigned, every stage reported its document as "0 b". + InputFileSize = xchange.InputSize, + OutputFileSize = result.OutputSize, + ResponseFileSize = result.ResponseSize, CorrelationId = xchange.CorrelationId, // xchange.PartnerId is the authoritative source (set at creation from the // gateway/bus-route partner, or the subscription's own PartnerId as a diff --git a/SW.Bitween.Sdk/Model/Xchange.cs b/SW.Bitween.Sdk/Model/Xchange.cs index 94f5032d..e82a9d8d 100644 --- a/SW.Bitween.Sdk/Model/Xchange.cs +++ b/SW.Bitween.Sdk/Model/Xchange.cs @@ -210,7 +210,9 @@ public class XchangeRow public int InputFileSize { get; set; } public string InputFileHash { get; set; } public string OutputFileName { get; set; } + public int OutputFileSize { get; set; } public string ResponseFileName { get; set; } + public int ResponseFileSize { get; set; } public string InputUrl { get; set; } public string OutputUrl { get; set; } diff --git a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts index b04e1284..9084e746 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts @@ -33,6 +33,9 @@ interface RawXchangeRow { inputFileName: string | null; outputFileName: string | null; responseFileName: string | null; + inputFileSize: number; + outputFileSize: number; + responseFileSize: number; inputKey: string | null; outputKey: string | null; responseKey: string | null; @@ -125,15 +128,19 @@ const toExchangeRow = (raw: RawXchangeRow, partnerNameById: Map) exception: raw.exception, promotedProperties: raw.promotedProperties, mapperSkipped: raw.mapperId === null, - // Search's projection never populates file sizes/hashes (always 0 at the - // source) — show the name, which is real, with a size of 0 rather than - // fabricating one. Existence is keyed off `*Key` (backend only emits one once - // the file actually has bytes), not the file name, since gateway/manually - // created exchanges have no name yet content still exists. + // Existence is keyed off `*Key` (backend only emits one once the file actually + // has bytes), not the file name, since gateway/manually created exchanges have + // no name yet content still exists. files: { - input: raw.inputKey ? { name: raw.inputFileName ?? "input", size: 0, key: raw.inputKey } : null, - mapped: raw.outputKey ? { name: raw.outputFileName ?? "mapped", size: 0, key: raw.outputKey } : null, - handled: raw.responseKey ? { name: raw.responseFileName ?? "handled", size: 0, key: raw.responseKey } : null, + input: raw.inputKey + ? { name: raw.inputFileName ?? "input", size: raw.inputFileSize ?? 0, key: raw.inputKey } + : null, + mapped: raw.outputKey + ? { name: raw.outputFileName ?? "mapped", size: raw.outputFileSize ?? 0, key: raw.outputKey } + : null, + handled: raw.responseKey + ? { name: raw.responseFileName ?? "handled", size: raw.responseFileSize ?? 0, key: raw.responseKey } + : null, }, }); diff --git a/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx b/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx index 81dfb7fe..a3219328 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx @@ -227,7 +227,8 @@ export function PromotedProps({ * Shown when the information type promotes nothing, or promotes nothing this * payload carried. A bare em dash left the row with no identity at all — the id * is a poor name but it is the only one left, and it makes the row addressable. - * Truncated because the drawer carries it in full, with a copy button. + * Shown whole: an id cut to its first eight characters is not something you can + * match against a log line or quote to anyone, which is the only thing it is for. */ fallbackId?: string; }) { @@ -240,11 +241,12 @@ export function PromotedProps({ // information type can promote three paths that a payload never filled, and // "merchant= orderRef= destination=" then names every exchange of that type equally — three // chips that say which fields exist and nothing about which record this is. + // break-all: the id is one unbroken run of hex with nowhere natural to wrap, and + // these lists also sit in ~360px side panels, where nowrap pushed the last column + // off the edge. if (!namesSomething(properties)) return fallbackId ? ( - - {fallbackId.slice(0, 8)}… - + {fallbackId} ) : ( — ); diff --git a/SW.Bitween.Web/ClientApp/src/lib/dates.ts b/SW.Bitween.Web/ClientApp/src/lib/dates.ts index 12eec5e9..27551c30 100644 --- a/SW.Bitween.Web/ClientApp/src/lib/dates.ts +++ b/SW.Bitween.Web/ClientApp/src/lib/dates.ts @@ -67,11 +67,13 @@ export const formatDurationMs = (ms: number): string => { return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; }; -/** "12s", "1m 42s" — elapsed time between two instants. */ -export const duration = (fromIso: string, toIso: string): string => { - const seconds = Math.max(0, Math.round((new Date(toIso).getTime() - new Date(fromIso).getTime()) / 1000)); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ${seconds % 60}s`; - return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; -}; +/** + * "14ms", "1.1s", "2m 3s" — elapsed time between two instants. + * + * Milliseconds, not whole seconds: most exchanges finish inside one, so rounding + * to the second reported nearly all of them as "0s" — which reads as "no time at + * all" rather than as a measurement, and hides the difference between a 4ms run + * and a 900ms one. + */ +export const duration = (fromIso: string, toIso: string): string => + formatDurationMs(Math.max(0, new Date(toIso).getTime() - new Date(fromIso).getTime())); From 317862dfb84c97c436f17f19f7118d7b3454df86 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 13 Sep 2026 11:33:30 +0300 Subject: [PATCH 02/12] fix: show a dash, not "NaNh NaNm", for an unusable duration Co-Authored-By: Claude Opus 5 (1M context) --- SW.Bitween.Web/ClientApp/src/lib/dates.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/lib/dates.ts b/SW.Bitween.Web/ClientApp/src/lib/dates.ts index 27551c30..22c4cd7d 100644 --- a/SW.Bitween.Web/ClientApp/src/lib/dates.ts +++ b/SW.Bitween.Web/ClientApp/src/lib/dates.ts @@ -59,6 +59,9 @@ export const formatDateTime = (iso: string) => { /** "14ms", "1.1s", "2m 3s" — from an elapsed millisecond count. */ export const formatDurationMs = (ms: number): string => { + // NaN compares false against every bound below, so without this an unusable + // number falls through every branch and is rendered as "NaNh NaNm". + if (!Number.isFinite(ms)) return "—"; if (ms < 1000) return `${Math.round(ms)}ms`; const seconds = ms / 1000; if (seconds < 60) return `${seconds.toFixed(1)}s`; @@ -75,5 +78,11 @@ export const formatDurationMs = (ms: number): string => { * all" rather than as a measurement, and hides the difference between a 4ms run * and a 900ms one. */ -export const duration = (fromIso: string, toIso: string): string => - formatDurationMs(Math.max(0, new Date(toIso).getTime() - new Date(fromIso).getTime())); +export const duration = (fromIso: string, toIso: string): string => { + // Either end missing or unparseable means there is no elapsed time to state — + // an em dash, the same as every other field with nothing to show. + const from = asDate(fromIso); + const to = asDate(toIso); + if (!from || !to) return "—"; + return formatDurationMs(Math.max(0, to.getTime() - from.getTime())); +}; From 7043032966a9f86778d6ae543463c17b72175208 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 13 Sep 2026 11:33:35 +0300 Subject: [PATCH 03/12] fix: catch two configs that saved fine and then never worked A scheduled job or aggregation sent with no Schedules field skipped the rule meant to catch it. A partnerless bus route running partner tokens now says so at save time, instead of failing later as "Invalid URI". Co-Authored-By: Claude Opus 5 (1M context) --- .../Resources/Subscriptions/Create.cs | 8 ++++++-- .../src/pages/bus-gateways/BusGatewayPage.tsx | 16 ++++++++++++++++ .../pages/bus-gateways/studio/Inspector.tsx | 19 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/SW.Bitween.Api/Resources/Subscriptions/Create.cs b/SW.Bitween.Api/Resources/Subscriptions/Create.cs index 53fcd3f2..a193525a 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/Create.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/Create.cs @@ -134,10 +134,14 @@ await AddMissing(context, adapterRequirements, // Schedules only mean anything on the two scheduled types, and an empty set is // what Subscription.SetSchedules rejects outright. + // + // The type is the whole condition. Guarding on `Schedules != null` as well let the + // rule be skipped by the one case it most needed to catch — omitting the field + // entirely — so a scheduled job or aggregation saved with no trigger at all and + // then simply never ran. RuleFor(i => i.Schedules) .NotEmpty() - .When(i => i.Schedules != null && - (i.Type == SubscriptionType.Receiving || i.Type == SubscriptionType.Aggregation)) + .When(i => i.Type == SubscriptionType.Receiving || i.Type == SubscriptionType.Aggregation) .WithMessage("Schedules cannot be empty for a scheduled subscription."); } diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx index 85d29188..4fd2a7f9 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx @@ -409,6 +409,21 @@ export function BusGatewayPage() { : OWNER[node] === "subscription" && !!edit && nodeDirty(node, edit.draft, edit.saved) : false; + /** + * Which of this subscription's adapter slots reference a partner value. Read off the + * draft rather than the saved record, so picking "No partner" and pasting a + * `{{partner.…}}` URL in the same sitting is still caught before the save. + */ + const partnerTokenSlots = ( + [ + ["receiver", edit?.draft.receiverProperties], + ["mapper", edit?.draft.mapperProperties], + ["handler", edit?.draft.handlerProperties], + ] as const + ) + .filter(([, props]) => Object.values(props ?? {}).some((v) => v.includes("{{partner."))) + .map(([slot]) => slot); + const renderNode = () => { if (!node) return null; if (node === "route") @@ -421,6 +436,7 @@ export function BusGatewayPage() { informationTypeId={g.informationTypeId} informationTypeCode={g.informationTypeCode} disabled={!canEdit} + partnerTokenSlots={partnerTokenSlots} onNewPartner={() => setPartnerDialog(null)} onEditPartner={(id) => setPartnerDialog(id)} onNewSubscription={() => { diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx index a6d02d01..162fafec 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx @@ -106,6 +106,7 @@ export function RouteBody({ informationTypeId, informationTypeCode, disabled, + partnerTokenSlots, onNewPartner, onEditPartner, onNewSubscription, @@ -117,6 +118,11 @@ export function RouteBody({ informationTypeId: number; informationTypeCode: string; disabled: boolean; + /** + * Adapter slots on the subscription this route runs whose properties contain a + * `{{partner.…}}` token — empty when none do. + */ + partnerTokenSlots: string[]; onNewPartner: () => void; /** Opens the chosen partner's values here, rather than sending you to its page. */ onEditPartner: (partnerId: number) => void; @@ -179,6 +185,19 @@ export function RouteBody({ onCreate={canCreatePartner && !disabled ? onNewPartner : undefined} createLabel="New partner" /> + {/* + Said here, at save time, because the alternative is finding out at run time: + with no partner the token is never substituted and reaches the adapter as the + literal text "{{partner.x}}", which surfaces as "UriFormatException: Invalid + URI" — an error naming nothing that appears on this screen. + */} + {draft.partner === "none" && partnerTokenSlots.length > 0 && ( +

+ The {partnerTokenSlots.join(" and ")} {partnerTokenSlots.length === 1 ? "uses" : "use"}{" "} + {"{{partner.…}}"}, which resolves to nothing without + a partner. Pick one, or take the token out of the adapter. +

+ )} Date: Sun, 13 Sep 2026 11:33:41 +0300 Subject: [PATCH 04/12] feat: say how to authenticate against an API gateway The partnerkey header appears nowhere but the C# that reads it, so the endpoint URLs alone were not enough to make a call. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/pages/api-gateways/ApiGatewayPage.tsx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx index 43324c82..2924ca7d 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx @@ -143,7 +143,7 @@ export function ApiGatewayPage() { {/* Endpoint above rather than beside: the attachments table below carries a column per configuration field and needs the full width to do it. */}
- +
+ + {/* + The URLs alone are not enough to make a call, and the header name appears + nowhere else in the product — it is only in the C# that reads it. Without + this, handing a partner the endpoint still leaves them guessing. + */} +
+

+ How a partner calls it +

+
+              {`POST /api/Gateway/${urlName}/sync\npartnerkey: \n\n`}
+            
+

+ The{" "} + partnerkey{" "} + header is what identifies the caller — it decides which attached partner the exchange + runs as, so each partner sends its own. Keys are issued on a{" "} + + partner's page + + , and only shown once when created. +

+
Date: Sun, 13 Sep 2026 12:07:02 +0300 Subject: [PATCH 05/12] fix: don't 500 the exchange list for an exchange with no result yet The file sizes added earlier this branch read non-nullable ints off the left-joined XchangeResult, so one still-running or early-failed exchange took the whole query down. Co-Authored-By: Claude Opus 5 (1M context) --- SW.Bitween.Api/Resources/Xchanges/Search.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/SW.Bitween.Api/Resources/Xchanges/Search.cs b/SW.Bitween.Api/Resources/Xchanges/Search.cs index f796ccef..44929300 100644 --- a/SW.Bitween.Api/Resources/Xchanges/Search.cs +++ b/SW.Bitween.Api/Resources/Xchanges/Search.cs @@ -81,9 +81,15 @@ from delayedRetry in drGroup.DefaultIfEmpty() ResponseFileName = result.ResponseName, // The same three counts the file keys above are already derived from. // Left unassigned, every stage reported its document as "0 b". + // + // The result-side two are guarded because the join to XchangeResult is + // a left one: an exchange still running, or one that failed before it + // produced a result, has no row there. Reading a non-nullable int off + // that null threw "Nullable object must have a value" out of the whole + // query — one such exchange 500'd the entire list. InputFileSize = xchange.InputSize, - OutputFileSize = result.OutputSize, - ResponseFileSize = result.ResponseSize, + OutputFileSize = result != null ? result.OutputSize : 0, + ResponseFileSize = result != null ? result.ResponseSize : 0, CorrelationId = xchange.CorrelationId, // xchange.PartnerId is the authoritative source (set at creation from the // gateway/bus-route partner, or the subscription's own PartnerId as a From 2ef3a48da858ed04a00b799da4f7ac4d84e9e3bb Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 13 Sep 2026 12:07:02 +0300 Subject: [PATCH 06/12] fix: promoted property paths are stored as validated, and name themselves when unreadable Paths were checked trimmed but stored raw, so a stray space 400'd every message on the information type. A leading '$' also skipped the shape check entirely, and an unparseable path reached the caller as a 400 naming nothing. Co-Authored-By: Claude Opus 5 (1M context) --- .../IReadOnlyDictionaryExtensions.cs | 5 ++++ .../Documents/PromotedPropertyValidation.cs | 16 +++++++++++- SW.Bitween.Api/Services/FilterService.cs | 25 ++++++++++++++++--- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/SW.Bitween.Api/Extensions/IReadOnlyDictionaryExtensions.cs b/SW.Bitween.Api/Extensions/IReadOnlyDictionaryExtensions.cs index f10670cb..9e06ffe4 100644 --- a/SW.Bitween.Api/Extensions/IReadOnlyDictionaryExtensions.cs +++ b/SW.Bitween.Api/Extensions/IReadOnlyDictionaryExtensions.cs @@ -16,6 +16,11 @@ public static Dictionary ToDictionary(this IReadOnly public static ICollection ToKeyAndValueCollection( this IReadOnlyDictionary dict) { + // A null column is "no entries", not a failure. Rows the domain creates always have + // one, but a row inserted any other way took down the whole list response rather + // than the single row that was missing it. + if (dict == null) return new List(); + return dict.Select(kvp => new KeyAndValue { Key = kvp.Key.ToString(), diff --git a/SW.Bitween.Api/Resources/Documents/PromotedPropertyValidation.cs b/SW.Bitween.Api/Resources/Documents/PromotedPropertyValidation.cs index 0be807c3..885a5b32 100644 --- a/SW.Bitween.Api/Resources/Documents/PromotedPropertyValidation.cs +++ b/SW.Bitween.Api/Resources/Documents/PromotedPropertyValidation.cs @@ -26,12 +26,26 @@ public static void Check(ICollection promotedProperties, DocumentFo throw new SWValidationException("INVALID_PROMOTED_PROPERTY_VALUE", $"Promoted property '{pp.Key}' must have a non-empty path value."); + // Trimmed here *and* written back. Validating the trimmed value while storing the + // raw one let " $.ref" pass every check below and then fail at read time, which + // 400s every message on this information type rather than this one save. var trimmed = pp.Value.Trim(); + pp.Value = trimmed; if (format == DocumentFormat.Json) { // Must be a JSONPath: starts with '$' or a simple dot-separated identifier path - if (!trimmed.StartsWith("$") && !Regex.IsMatch(trimmed, @"^[a-zA-Z_][a-zA-Z0-9_]*(?:(\.[a-zA-Z_][a-zA-Z0-9_]*)|(\[[0-9]+\]))*$")) + // + // The '$' branch is checked rather than trusted. Treating any leading '$' as + // proof of a JSONPath let "$", "$." and "$.." through, none of which select + // anything — they saved cleanly and failed only once a message arrived. A step + // is a '.'/'..' followed by a name, or a bracket; names stay deliberately + // permissive (anything but a delimiter) so paths that already work keep working. + var valid = trimmed.StartsWith("$") + ? Regex.IsMatch(trimmed, @"^\$(?:\.\.?[^.\[\]]+|\[[^\]]+\])+$") + : Regex.IsMatch(trimmed, @"^[a-zA-Z_][a-zA-Z0-9_]*(?:(\.[a-zA-Z_][a-zA-Z0-9_]*)|(\[[0-9]+\]))*$"); + + if (!valid) throw new SWValidationException("INVALID_PROMOTED_PROPERTY_PATH", $"Promoted property '{pp.Key}' has an invalid JSON path: '{pp.Value}'. Expected a JSONPath expression (e.g. '$.field.subField') or dot-notation path."); } diff --git a/SW.Bitween.Api/Services/FilterService.cs b/SW.Bitween.Api/Services/FilterService.cs index 9a43ebd7..942fe5cf 100644 --- a/SW.Bitween.Api/Services/FilterService.cs +++ b/SW.Bitween.Api/Services/FilterService.cs @@ -1,6 +1,8 @@ using System; using System.Linq; using System.Threading.Tasks; +using System.Xml.XPath; +using Newtonsoft.Json; using SW.PrimitiveTypes; using SW.Bitween.Model; @@ -29,10 +31,25 @@ public async Task Filter(int documentId, XchangeFile xchangeFile) foreach (var pp in doc.PromotedProperties) { - propReader.TryGetValue(pp.Value, out var ppValue); - //TODO check if we need to validate here - //if (ppValue is null) - // throw new SWValidationException("PROMOTED_PROPERTY_NOT_FOUND", $"The path {pp.Value} is null on the docuemnt"); + string ppValue; + try + { + propReader.TryGetValue(pp.Value, out ppValue); + } + catch (Exception ex) when (ex is JsonException || ex is XPathException) + { + // A path the reader cannot parse throws out of it rather than returning false, + // and unhandled it reached the caller as a bare 400 naming nothing — on every + // message of this information type, since the bad path is on the type itself. + // Which property is broken is the one thing needed to fix it. + throw new SWValidationException("INVALID_PROMOTED_PROPERTY_PATH", + $"Promoted property '{pp.Key}' on information type '{doc.Name}' has a path that cannot be read: '{pp.Value}'. {ex.Message}"); + } + + // A path that simply doesn't match this payload is not an error: an information + // type promotes what its documents *may* carry, and TryGetValue reports that by + // returning false, leaving the property null. Only an unreadable path throws. + // Stored as the payload sent it. It used to be lower-cased here, which was // only ever to pair with the lower-cased term in Xchanges/Search — nothing // matches on this dictionary (match expressions read the payload directly), From a553f3fce20784238a0bf33fdfdff0f66c8b1784 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 13 Sep 2026 12:07:02 +0300 Subject: [PATCH 07/12] fix: give a retry condition's value field room for its value Co-Authored-By: Claude Opus 5 (1M context) --- .../ClientApp/src/pages/retry-policies/GroupDialog.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/GroupDialog.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/GroupDialog.tsx index c3c6c05b..1cd334e5 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/GroupDialog.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/GroupDialog.tsx @@ -41,7 +41,15 @@ function MatcherRow({ onChange: (m: RetryMatcher) => void; onRemove: () => void; }) { - const num = "flex-1 min-w-24"; + /* + The free-text field in a condition row: an error fragment, an exception name, a + comparison value. The controls beside it are fixed-width pickers whose longest + option is known, so an equal share left this one the narrowest thing in the row + while being the only one holding arbitrary text — "INVALID_DECL" clipped mid-word. + Twice the share of what's left, and a floor wide enough for a real value; the row + already wraps, so below that it takes a line of its own rather than shrinking. + */ + const num = "flex-[2] min-w-48"; return (