From 92afbf1a2db99bf145dd7382a3dd0c5b71b649c0 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 24 Sep 2026 16:03:04 +0300 Subject: [PATCH 1/5] fix: stop showing a disconnected data source as healthy --- .../ClientApp/src/pages/data-sources/ConnectionBadge.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SW.Bitween.Web/ClientApp/src/pages/data-sources/ConnectionBadge.tsx b/SW.Bitween.Web/ClientApp/src/pages/data-sources/ConnectionBadge.tsx index 4e223a02..933fe016 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/data-sources/ConnectionBadge.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/data-sources/ConnectionBadge.tsx @@ -21,7 +21,8 @@ export function ConnectionBadge({ ); - const tone = /connected|idle|running/i.test(state) + // Whole words, or "Disconnected" would match "connected" and read as healthy. + const tone = /\b(connected|ready|idle|running)\b/i.test(state) ? "ok" : /starting|draining/i.test(state) ? "warn" From a3035e4dfefb582012324e751085c1f8e0012c9b Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 24 Sep 2026 16:03:30 +0300 Subject: [PATCH 2/5] feat: use the full width on a data source's page and merge its two status cards --- .../src/pages/data-sources/DataSourcePage.tsx | 788 +++++++++--------- .../src/pages/data-sources/LiveConnection.tsx | 25 +- 2 files changed, 412 insertions(+), 401 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/pages/data-sources/DataSourcePage.tsx b/SW.Bitween.Web/ClientApp/src/pages/data-sources/DataSourcePage.tsx index eb445469..ac803183 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/data-sources/DataSourcePage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/data-sources/DataSourcePage.tsx @@ -174,7 +174,7 @@ export function DataSourcePage() { }; return ( -
+
{removing && ( )} - {/* ——— what the last heartbeat said ——— */} -
-
-

Connection

- -
- -
- {d.ownedByNode ?? "Not running on any node"} - - {d.lastHeartbeatOn ? new Date(d.lastHeartbeatOn).toLocaleString() : "—"} - - {String(d.consecutiveFailures)} - - - {d.gatewayCount} - - - {d.lastException && ( -
-
Last error
-
{d.lastException}
+ {/* Settings on the left, everything about the running connection on the right. The status + column comes first in the markup so a narrow screen still opens on whether it works. */} +
+
+ {/* ——— what the last heartbeat said, and what it is doing right now ——— */} +
+
+

Connection

+
- )} -
- {/* The two placements are opposites, and the wrong explanation directly contradicts the - "Held by" value right above it — which is how someone concludes the page is broken. */} -

- {source.data?.kind === "Relational" ? ( - <> - A connection pool is held by every node that runs work, not leased to one: a node - without it could not run the exchanges that need it. Nothing here is exclusive, so - there is no fencing token and no ownership to move. - - ) : ( +

+ {d.ownedByNode ?? "Not running on any node"} + + {d.lastHeartbeatOn ? new Date(d.lastHeartbeatOn).toLocaleString() : "—"} + + {String(d.consecutiveFailures)} + + + {d.gatewayCount} + + + {d.lastException && ( +
+
Last error
+
{d.lastException}
+
+ )} +
+ + {/* The two placements are opposites, and the wrong explanation directly contradicts the + "Held by" value right above it — which is how someone concludes the page is broken. */} +

+ {source.data?.kind === "Relational" ? ( + <> + A connection pool is held by every node that runs work, not leased to one: a node + without it could not run the exchanges that need it. Nothing here is exclusive, so + there is no fencing token and no ownership to move. + + ) : ( + <> + A broker connection is exclusive, so exactly one node holds it. The term after the + node name is the fencing token — it increases every time ownership moves, and a node + whose term is no longer current stops immediately rather than carrying on consuming. + + )} +

+ + +
+ + {/* Only a relational source runs SQL, and the server refuses a statement on anything else — + so offering the panel on a broker would be offering a thing that cannot work. */} + {source.data?.kind === "Relational" && ( <> - A broker connection is exclusive, so exactly one node holds it. The term after the - node name is the fencing token — it increases every time ownership moves, and a node - whose term is no longer current stops immediately rather than carrying on consuming. +
+ + setSeed(null)} + /> + +
+ + {/* Below the statements it feeds, because that is the direction the work runs: find the + table, then write the statement. The browser is gated on data-sources.view like the + rest of this page; handing a draft to the form is gated separately, since writing + SQL is a different job from reading a catalog. */} +
+ { + setSeed(draft); + // The form is above the browser, and a draft appearing off-screen reads as + // a button that did nothing. + statementsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + : undefined + } + /> +
)} -

- - - {/* ——— what it is doing right now ——— */} - - - {/* Only a relational source runs SQL, and the server refuses a statement on anything else — - so offering the panel on a broker would be offering a thing that cannot work. */} - {source.data?.kind === "Relational" && ( - <> -
- - setSeed(null)} - /> - -
- {/* Below the statements it feeds, because that is the direction the work runs: find the - table, then write the statement. The browser is gated on data-sources.view like the - rest of this page; handing a draft to the form is gated separately, since writing - SQL is a different job from reading a catalog. */} -
- { - setSeed(draft); - // The form is above the browser, and a draft appearing off-screen reads as - // a button that did nothing. - statementsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); - } - : undefined - } - /> -
- - )} + {/* ——— what the live adapter says it can see ——— */} + {inspect && ( +
+
+

+ {inspect.command === "GetStats" + ? "Adapter statistics" + : source.data?.kind === "Relational" + ? "What is in the database" + : "What is on the broker"} +

+ +
- {/* ——— what the live adapter says it can see ——— */} - {inspect && ( -
-
-

- {inspect.command === "GetStats" - ? "Adapter statistics" - : source.data?.kind === "Relational" - ? "What is in the database" - : "What is on the broker"} -

- -
+ {inspect.ran ? ( + /* Capped and scrolled: a database's catalog runs to every table, view and routine the + role can see, and printed in full it buries the settings form under a page of JSON + with no way back but the scrollbar. */ +
+                  {inspect.result}
+                
+ ) : ( +

{inspect.error}

+ )} - {inspect.ran ? ( - /* Capped and scrolled: a database's catalog runs to every table, view and routine the - role can see, and printed in full it buries the settings form under a page of JSON - with no way back but the scrollbar. */ -
-              {inspect.result}
-            
- ) : ( -

{inspect.error}

+

+ Asked of the connection that is actually serving traffic, not a throwaway one — and + read-only:{" "} + {source.data?.kind === "Relational" + ? "it reads the catalog, and writes nothing." + : "nothing is consumed, acknowledged or published."} +

+
)} +
-

- Asked of the connection that is actually serving traffic, not a throwaway one — and - read-only:{" "} - {source.data?.kind === "Relational" - ? "it reads the catalog, and writes nothing." - : "nothing is consumed, acknowledged or published."} -

- - )} - - - {/* ——— settings ——— */} -
-

Settings

+ {/* ——— settings ——— */} +
+

Settings

+ +
+ + setDraft({ ...draft, name: e.target.value })} + /> + -
- - setDraft({ ...draft, name: e.target.value })} + onChange={(e) => setDraft({ ...draft, inactive: !e.target.checked })} + label="Active" + description="Turning this off stops the connection without losing its settings." /> - - - setDraft({ ...draft, inactive: !e.target.checked })} - label="Active" - description="Turning this off stops the connection without losing its settings." - /> - - - - setDraft({ ...draft, deduplicationWindowDays: Number(e.target.value) || 0 }) - } - /> - - -
-

Memory ceilings

-

- An adapter is a separate process holding this broker's connection. Without a ceiling - it is bounded by nothing but the host, so one runaway payload takes every other - integration on the node down with it. Leave both at 0 to use the host's own defaults. -

-
-
- - - setDraft({ ...draft, softMemoryLimitMb: Number(e.target.value) || 0 }) - } - /> - + + + setDraft({ ...draft, deduplicationWindowDays: Number(e.target.value) || 0 }) + } + /> + + +
+

Memory ceilings

+

+ An adapter is a separate process holding this broker's connection. Without a ceiling + it is bounded by nothing but the host, so one runaway payload takes every other + integration on the node down with it. Leave both at 0 to use the host's own defaults. +

+ +
+
+ + + setDraft({ ...draft, softMemoryLimitMb: Number(e.target.value) || 0 }) + } + /> + +
+
+ + + setDraft({ ...draft, hardMemoryLimitMb: Number(e.target.value) || 0 }) + } + /> + +
-
- - - setDraft({ ...draft, hardMemoryLimitMb: Number(e.target.value) || 0 }) + + {draft.softMemoryLimitMb > 0 && + draft.hardMemoryLimitMb > 0 && + draft.softMemoryLimitMb > draft.hardMemoryLimitMb && ( +

+ A soft limit above the hard one can never be reached — the runtime fails the + allocation first, so the recycle never happens. +

+ )} + +

+ Applied when the adapter process launches, so changing these restarts it. Nothing in + flight is lost: messages are only acknowledged once Bitween has persisted them. +

+
+ +
+

CPU ceiling

+

+ A share of the whole node, not of one core — one core pegged flat out + on a sixteen-core node reads about 6%, so “50%” would allow eight cores + rather than half of one. It trips only after several consecutive heartbeats above the + line, because an adapter draining a backlog is supposed to work hard. + Leave at 0 for the host default. +

+ +
+
+ 0 + ? `For scale: ${(draft.cpuPercentLimit / 100 * cores).toFixed(1)} core(s) on a ` + + `${cores}-core machine — this browser's core count, not the node's.` + : "Off — the host default applies." } - /> - + > + + setDraft({ ...draft, cpuPercentLimit: Number(e.target.value) || 0 }) + } + /> + +
+
+ + + setDraft({ ...draft, cpuLimitSamples: Number(e.target.value) || 0 }) + } + /> + +
-
- {draft.softMemoryLimitMb > 0 && - draft.hardMemoryLimitMb > 0 && - draft.softMemoryLimitMb > draft.hardMemoryLimitMb && ( + {draft.cpuPercentLimit > 100 && (

- A soft limit above the hard one can never be reached — the runtime fails the - allocation first, so the recycle never happens. + Above 100% can never be reached — the figure is a share of the whole node, so 100% + is every core at once.

)} -

- Applied when the adapter process launches, so changing these restarts it. Nothing in - flight is lost: messages are only acknowledged once Bitween has persisted them. -

-
- -
-

CPU ceiling

-

- A share of the whole node, not of one core — one core pegged flat out - on a sixteen-core node reads about 6%, so “50%” would allow eight cores - rather than half of one. It trips only after several consecutive heartbeats above the - line, because an adapter draining a backlog is supposed to work hard. - Leave at 0 for the host default. -

- -
-
- 0 - ? `For scale: ${(draft.cpuPercentLimit / 100 * cores).toFixed(1)} core(s) on a ` - + `${cores}-core machine — this browser's core count, not the node's.` - : "Off — the host default applies." - } - > - - setDraft({ ...draft, cpuPercentLimit: Number(e.target.value) || 0 }) - } - /> - -
-
- - - setDraft({ ...draft, cpuLimitSamples: Number(e.target.value) || 0 }) - } - /> - -
+

+ Crossing it asks the adapter to drain rather than killing it, so in-flight messages go + back to the broker instead of being lost. +

- {draft.cpuPercentLimit > 100 && ( -

- Above 100% can never be reached — the figure is a share of the whole node, so 100% - is every core at once. +

+

Connection settings

+

+ Handed straight to the adapter, which is also where this list comes from:{" "} + {provider ? provider.label : d.adapterId} declares what it accepts. Anything else it + understands can still be added by hand.

- )} -

- Crossing it asks the adapter to drain rather than killing it, so in-flight messages go - back to the broker instead of being lost. -

-
- -
-

Connection settings

-

- Handed straight to the adapter, which is also where this list comes from:{" "} - {provider ? provider.label : d.adapterId} declares what it accepts. Anything else it - understands can still be added by hand. -

- -
- {orderedSettingNames(provider, Object.keys(draft.properties)).map((key) => { - const declared = settingOf(provider, key); - const secret = isSecretName(key, d.secretProperties, declared); - const value = draft.properties[key]; - const stored = secret && value === SECRET_SENTINEL; - - return ( -
-
- - {declared?.allowedValues ? ( - setProperty(key, e.target.value)} + // An empty option only where empty is legal: a setting the adapter did + // not mark required can be left for the broker to decide. + options={[ + ...(declared.required ? [] : [{ value: "", label: "—" }]), + ...declared.allowedValues.map((v) => ({ value: v, label: v })), + ]} + /> + ) : secret ? ( + setProperty(key, e.target.value)} + /> + ) : ( + setProperty(key, e.target.value)} + /> + )} + +
+ {canEdit && ( + + )}
- {canEdit && ( - - )} + ); + })} +
+ + {canEdit && ( +
+
+ 0 + ? `${provider?.label ?? "This provider"} also accepts ${unused + .slice(0, 3) + .join(", ")}${unused.length > 3 ? ` and ${unused.length - 3} more` : ""}.` + : "A name that looks like a credential is masked automatically." + } + > + setNewKey(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && addProperty()} + /> + {/* Typing is still allowed: an adapter may read more than it declares. */} + + {unused.map((name) => ( + +
- ); - })} + +
+ )}
+ {error && {error}} + {canEdit && ( -
-
- 0 - ? `${provider?.label ?? "This provider"} also accepts ${unused - .slice(0, 3) - .join(", ")}${unused.length > 3 ? ` and ${unused.length - 3} more` : ""}.` - : "A name that looks like a credential is masked automatically." - } - > - setNewKey(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && addProperty()} - /> - {/* Typing is still allowed: an adapter may read more than it declares. */} - - {unused.map((name) => ( - - -
- + {dirty && ( + + )} + {!dirty && !save.isPending && No changes.}
)}
- - {error && {error}} - - {canEdit && ( -
- - {dirty && ( - - )} - {!dirty && !save.isPending && No changes.} -
- )} -
-
+
+
); } diff --git a/SW.Bitween.Web/ClientApp/src/pages/data-sources/LiveConnection.tsx b/SW.Bitween.Web/ClientApp/src/pages/data-sources/LiveConnection.tsx index 77ec9a07..f6c1c819 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/data-sources/LiveConnection.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/data-sources/LiveConnection.tsx @@ -1,9 +1,7 @@ import { useQuery } from "@tanstack/react-query"; -import { Activity } from "lucide-react"; import { api } from "../../api"; import { Badge, LoadingBlock } from "../../components/ui/basics"; import { keys } from "../../api/queryKeys"; -import { ConnectionBadge } from "./ConnectionBadge"; /** * What the connection is doing, as opposed to how it is configured. @@ -14,8 +12,17 @@ import { ConnectionBadge } from "./ConnectionBadge"; * cooperation from the adapter at all, so it still answers when the other half has gone quiet. * That distinction is the whole reason an operator can tell "the broker is idle" from "the adapter * is stuck", which no single health badge can say. + * + * Rendered inside the page's Connection card, which already shows the state badge and the last + * error — so neither is repeated here unless the live figure says something different. */ -export function LiveConnection({ dataSourceId }: { dataSourceId: number }) { +export function LiveConnection({ + dataSourceId, + knownError, +}: { + dataSourceId: number; + knownError?: string | null; +}) { const telemetry = useQuery({ queryKey: keys.dataSources.telemetry(dataSourceId), queryFn: () => api.getDataSourceTelemetry(dataSourceId), @@ -44,11 +51,9 @@ export function LiveConnection({ dataSourceId }: { dataSourceId: number }) { ); return ( -
+
- -

Live

- +

Live

{t.quarantined && ( Quarantined @@ -141,10 +146,12 @@ export function LiveConnection({ dataSourceId }: { dataSourceId: number }) { )} - {t.lastError &&

{t.lastError}

} + {t.lastError && t.lastError !== knownError && ( +

{t.lastError}

+ )}
)} -
+ ); } From 95e530808be1ef01ae98456bd66f49b4b6c03594 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 24 Sep 2026 16:03:39 +0300 Subject: [PATCH 3/5] feat: show what uses each data source, and whether it is a broker or a database --- SW.Bitween.Api/Resources/DataSources/Get.cs | 5 ++++- .../Resources/DataSources/Search.cs | 5 ++++- SW.Bitween.Sdk/Model/DataSource.cs | 3 +++ .../ClientApp/src/api/http/dataSources.ts | 2 ++ SW.Bitween.Web/ClientApp/src/api/types.ts | 2 ++ .../pages/data-sources/DataSourcesPage.tsx | 22 ++++++++++++++++--- 6 files changed, 34 insertions(+), 5 deletions(-) diff --git a/SW.Bitween.Api/Resources/DataSources/Get.cs b/SW.Bitween.Api/Resources/DataSources/Get.cs index f99b97ac..ad5f9321 100644 --- a/SW.Bitween.Api/Resources/DataSources/Get.cs +++ b/SW.Bitween.Api/Resources/DataSources/Get.cs @@ -1,6 +1,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; using SW.Bitween.Domain.DataSources; using SW.Bitween.Domain.Gateway; using SW.Bitween.Model; @@ -47,7 +48,9 @@ public async Task Handle(int key) OwnedByNode = dataSource.OwnedByNode, GatewayCount = await dbContext.Set() - .CountAsync(gateway => gateway.DataSourceId == key) + .CountAsync(gateway => gateway.DataSourceId == key), + SubscriptionCount = await dbContext.Set() + .CountAsync(subscription => subscription.DataSourceId == key) }; } } diff --git a/SW.Bitween.Api/Resources/DataSources/Search.cs b/SW.Bitween.Api/Resources/DataSources/Search.cs index 5ecb049c..d9ed2365 100644 --- a/SW.Bitween.Api/Resources/DataSources/Search.cs +++ b/SW.Bitween.Api/Resources/DataSources/Search.cs @@ -1,6 +1,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain; using SW.Bitween.Domain.DataSources; using SW.Bitween.Domain.Gateway; using SW.Bitween.Model; @@ -41,7 +42,9 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa // A correlated count, so the "used by" column costs one subquery per row rather // than the whole BusGateway table over the wire. GatewayCount = dbContext.Set() - .Count(gateway => gateway.DataSourceId == dataSource.Id) + .Count(gateway => gateway.DataSourceId == dataSource.Id), + SubscriptionCount = dbContext.Set() + .Count(subscription => subscription.DataSourceId == dataSource.Id) }; query = query.AsNoTracking(); diff --git a/SW.Bitween.Sdk/Model/DataSource.cs b/SW.Bitween.Sdk/Model/DataSource.cs index 9d752ee1..27c819e0 100644 --- a/SW.Bitween.Sdk/Model/DataSource.cs +++ b/SW.Bitween.Sdk/Model/DataSource.cs @@ -83,6 +83,9 @@ public class DataSourceRow : DataSourceUpdate /// How many bus gateways this data source feeds. Deleting is refused while any do. public int GatewayCount { get; set; } + /// How many subscriptions run on this data source's connection. + public int SubscriptionCount { get; set; } + // ------------------------------------------------------------------ health /// Null until the supervisor has reported on it once. diff --git a/SW.Bitween.Web/ClientApp/src/api/http/dataSources.ts b/SW.Bitween.Web/ClientApp/src/api/http/dataSources.ts index cf2208fb..640b2119 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/dataSources.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/dataSources.ts @@ -27,6 +27,7 @@ interface RawDataSource { cpuPercentLimit?: number | null; cpuLimitSamples?: number | null; gatewayCount: number; + subscriptionCount?: number | null; lastKnownState: string | null; lastHeartbeatOn: string | null; lastException: string | null; @@ -61,6 +62,7 @@ const toRow = (raw: RawDataSource): DataSourceRow => ({ cpuPercentLimit: raw.cpuPercentLimit ?? 0, cpuLimitSamples: raw.cpuLimitSamples ?? 0, gatewayCount: raw.gatewayCount, + subscriptionCount: raw.subscriptionCount ?? 0, lastKnownState: raw.lastKnownState, lastHeartbeatOn: raw.lastHeartbeatOn, lastException: raw.lastException, diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index a360b116..202f4f38 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -845,6 +845,8 @@ export interface DataSource { export interface DataSourceRow extends DataSource { /** How many bus gateways read from it. Deleting is refused while any do. */ gatewayCount: number; + /** How many subscriptions run on its connection. */ + subscriptionCount: number; } export interface DataSourceDetail extends DataSourceRow { diff --git a/SW.Bitween.Web/ClientApp/src/pages/data-sources/DataSourcesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/data-sources/DataSourcesPage.tsx index c6157da7..b8e04eaa 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/data-sources/DataSourcesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/data-sources/DataSourcesPage.tsx @@ -55,7 +55,7 @@ export function DataSourcesPage() {
{providerOf(providers.data, d.adapterId)?.label ?? d.adapterId} ), }, + { + header: "Kind", + headerTitle: "Broker (read by bus gateways) or database (used by subscriptions).", + cell: (d: DataSourceRow) => ( + {d.kind === "Relational" ? "Database" : d.kind} + ), + }, { header: "Connection", wrap: true, @@ -161,9 +168,18 @@ export function DataSourcesPage() { ), }, { - header: "Gateways", + // A broker feeds bus gateways; a database is used by subscriptions. Counting the + // wrong one would show every database as unused. + header: "Used by", + headerTitle: "Bus gateways reading a broker, or subscriptions running on a database. Deleting is refused while any remain.", align: "right", - cell: (d: DataSourceRow) => {d.gatewayCount}, + cell: (d: DataSourceRow) => ( + + {d.kind === "Relational" + ? `${d.subscriptionCount} subscription${d.subscriptionCount === 1 ? "" : "s"}` + : `${d.gatewayCount} gateway${d.gatewayCount === 1 ? "" : "s"}`} + + ), }, { header: "", From 8dc298e5be243ee09e5ce94449fe5a940106df82 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 24 Sep 2026 16:03:39 +0300 Subject: [PATCH 4/5] feat: tidy a data source's settings: database-only fields, one row of ceilings, readable labels, sticky save --- .../src/pages/data-sources/DataSourcePage.tsx | 303 +++++++++--------- .../src/pages/data-sources/providers.ts | 26 ++ 2 files changed, 175 insertions(+), 154 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/pages/data-sources/DataSourcePage.tsx b/SW.Bitween.Web/ClientApp/src/pages/data-sources/DataSourcePage.tsx index ac803183..91a9ed06 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/data-sources/DataSourcePage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/data-sources/DataSourcePage.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { Link, useNavigate, useParams } from "react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, Gauge, Plug, Plus, Telescope, Trash2, X } from "lucide-react"; +import { Check, CircleHelp, Gauge, Plug, Plus, Telescope, Trash2, X } from "lucide-react"; import { api, ApiRequestError, @@ -15,9 +15,17 @@ import { Badge, Button, FormError, LoadingBlock } from "../../components/ui/basi import { Checkbox, Field, PasswordInput, Select, TextInput } from "../../components/ui/forms"; import { ConfirmDialog } from "../../components/ui/overlays"; import { BackLink } from "../../components/ui/BackLink"; +import { UnsavedBar } from "../../components/ui/Panel"; import { keys } from "../../api/queryKeys"; import { ConnectionBadge } from "./ConnectionBadge"; -import { isSecretName, providerOf, settingOf, useDataSourceProviders, orderedSettingNames } from "./providers"; +import { + isSecretName, + providerOf, + settingLabel, + settingOf, + useDataSourceProviders, + orderedSettingNames, +} from "./providers"; import { LiveConnection } from "./LiveConnection"; import { Statements, type StatementSeed } from "./Statements"; import { SchemaBrowser } from "./SchemaBrowser"; @@ -150,6 +158,9 @@ export function DataSourcePage() { const cores = navigator.hardwareConcurrency || 8; const provider = providerOf(providers.data, d.adapterId); const dirty = JSON.stringify(draft) !== JSON.stringify(draftOf(d)); + // A database has no queue to redeliver from and no bus gateway to feed, so the broker-only + // parts of this page are left off it rather than shown empty. + const relational = d.kind === "Relational"; const setProperty = (key: string, value: string) => setDraft({ ...draft, properties: { ...draft.properties, [key]: value } }); @@ -174,7 +185,7 @@ export function DataSourcePage() { }; return ( -
+
{removing && ( 0 ? `${d.gatewayCount} bus gateway(s) still read from this connection, so deleting will be refused until they are moved off it.` - : "Its remembered deduplication keys go with it, so a message already processed could be handled again if it arrives later." + : d.subscriptionCount > 0 + ? `${d.subscriptionCount} subscription(s) still run on this connection, so deleting will be refused until they are moved off it.` + : relational + ? "Every node closes its connection to this database." + : "Its remembered deduplication keys go with it, so a message already processed could be handled again if it arrives later." } /> )} @@ -321,11 +336,15 @@ export function DataSourcePage() { {d.lastHeartbeatOn ? new Date(d.lastHeartbeatOn).toLocaleString() : "—"} {String(d.consecutiveFailures)} - - - {d.gatewayCount} - - + {relational ? ( + {String(d.subscriptionCount)} + ) : ( + + + {d.gatewayCount} + + + )} {d.lastException && (
Last error
@@ -455,68 +474,69 @@ export function DataSourcePage() { description="Turning this off stops the connection without losing its settings." /> - - - setDraft({ ...draft, deduplicationWindowDays: Number(e.target.value) || 0 }) - } - /> - + {!relational && ( + + + setDraft({ ...draft, deduplicationWindowDays: Number(e.target.value) || 0 }) + } + /> + + )} + {/* All four in one row. Each explanation sits on its label's help mark: as paragraphs + they were most of this card. */}
-

Memory ceilings

+

Resource ceilings

- An adapter is a separate process holding this broker's connection. Without a ceiling - it is bounded by nothing but the host, so one runaway payload takes every other - integration on the node down with it. Leave both at 0 to use the host's own defaults. + 0 uses the host default. Changing these restarts the adapter; nothing in flight is + lost, because messages are only acknowledged once Bitween has persisted them.

-
-
- - - setDraft({ ...draft, softMemoryLimitMb: Number(e.target.value) || 0 }) - } - /> - -
-
- - - setDraft({ ...draft, hardMemoryLimitMb: Number(e.target.value) || 0 }) - } - /> - -
+
+ setDraft({ ...draft, softMemoryLimitMb: v })} + /> + setDraft({ ...draft, hardMemoryLimitMb: v })} + /> + setDraft({ ...draft, cpuPercentLimit: v })} + /> + setDraft({ ...draft, cpuLimitSamples: v })} + />
{draft.softMemoryLimitMb > 0 && @@ -528,79 +548,19 @@ export function DataSourcePage() {

)} -

- Applied when the adapter process launches, so changing these restarts it. Nothing in - flight is lost: messages are only acknowledged once Bitween has persisted them. -

-
- -
-

CPU ceiling

-

- A share of the whole node, not of one core — one core pegged flat out - on a sixteen-core node reads about 6%, so “50%” would allow eight cores - rather than half of one. It trips only after several consecutive heartbeats above the - line, because an adapter draining a backlog is supposed to work hard. - Leave at 0 for the host default. -

- -
-
- 0 - ? `For scale: ${(draft.cpuPercentLimit / 100 * cores).toFixed(1)} core(s) on a ` - + `${cores}-core machine — this browser's core count, not the node's.` - : "Off — the host default applies." - } - > - - setDraft({ ...draft, cpuPercentLimit: Number(e.target.value) || 0 }) - } - /> - -
-
- - - setDraft({ ...draft, cpuLimitSamples: Number(e.target.value) || 0 }) - } - /> - -
-
- - {draft.cpuPercentLimit > 100 && ( + {draft.cpuPercentLimit > 100 ? (

Above 100% can never be reached — the figure is a share of the whole node, so 100% is every core at once.

+ ) : ( + draft.cpuPercentLimit > 0 && ( +

+ For scale: {((draft.cpuPercentLimit / 100) * cores).toFixed(1)} core(s) on a{" "} + {cores}-core machine — this browser's core count, not the node's. +

+ ) )} - -

- Crossing it asks the adapter to drain rather than killing it, so in-flight messages go - back to the broker instead of being lost. -

@@ -622,7 +582,7 @@ export function DataSourcePage() {
@@ -712,28 +672,63 @@ export function DataSourcePage() { )}
- {error && {error}} - - {canEdit && ( -
- - {dirty && ( - - )} - {!dirty && !save.isPending && No changes.} -
- )}
+ + {canEdit && dirty && ( + save.mutate(draft)} + onDiscard={() => { + setError(null); + setDraft(draftOf(d)); + }} + /> + )} +
+ ); +} + +/** A number box whose explanation sits on a help mark beside its label. */ +function CeilingInput({ + id, + label, + help, + value, + onChange, + disabled, + max, + step, +}: { + id: string; + label: string; + help: string; + value: number; + onChange: (value: number) => void; + disabled: boolean; + max?: number; + step?: number; +}) { + return ( +
+ + onChange(Number(e.target.value) || 0)} + />
); } diff --git a/SW.Bitween.Web/ClientApp/src/pages/data-sources/providers.ts b/SW.Bitween.Web/ClientApp/src/pages/data-sources/providers.ts index 9c0beb25..ba8b9268 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/data-sources/providers.ts +++ b/SW.Bitween.Web/ClientApp/src/pages/data-sources/providers.ts @@ -106,3 +106,29 @@ export function useBindsToDataSource() { return kind === "Broker" && slot !== "receiver"; }; } + +/** Words an adapter spells in PascalCase that read wrong once lower-cased. */ +const ACRONYMS: Record = { + ssl: "SSL", tls: "TLS", url: "URL", uri: "URI", id: "ID", sql: "SQL", ttl: "TTL", tcp: "TCP", + ms: "ms", +}; + +/** + * A setting's name as a label: "UserName" reads "User name", "UseSsl" reads "Use SSL". Only the + * label changes — the adapter still receives the setting under its own name. + */ +export const settingLabel = (name: string): string => { + const words = name + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .split(/[\s_.-]+/) + .filter(Boolean); + return words + .map((word, i) => { + const acronym = ACRONYMS[word.toLowerCase()]; + if (acronym) return acronym; + if (word.length > 1 && word === word.toUpperCase()) return word; + return i === 0 ? word[0].toUpperCase() + word.slice(1).toLowerCase() : word.toLowerCase(); + }) + .join(" "); +}; From ee1f9430250f17c1e06e504735a12cc8eb4ce379 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Thu, 24 Sep 2026 16:26:11 +0300 Subject: [PATCH 5/5] fix: describe a data source by its real placement, and make ceiling help reachable by keyboard --- .../ClientApp/src/api/http/dataSources.ts | 2 ++ SW.Bitween.Web/ClientApp/src/api/types.ts | 2 ++ .../src/pages/data-sources/DataSourcePage.tsx | 28 +++++++++++++------ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/api/http/dataSources.ts b/SW.Bitween.Web/ClientApp/src/api/http/dataSources.ts index 640b2119..7641c41b 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/dataSources.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/dataSources.ts @@ -20,6 +20,7 @@ interface RawDataSource { name: string; adapterId: string; kind: string; + placement?: string | null; inactive: boolean | null; deduplicationWindowDays: number; softMemoryLimitMb?: number | null; @@ -55,6 +56,7 @@ const toRow = (raw: RawDataSource): DataSourceRow => ({ name: raw.name, adapterId: raw.adapterId, kind: raw.kind, + placement: raw.placement ?? "Auto", inactive: raw.inactive ?? false, deduplicationWindowDays: raw.deduplicationWindowDays, softMemoryLimitMb: raw.softMemoryLimitMb ?? 0, diff --git a/SW.Bitween.Web/ClientApp/src/api/types.ts b/SW.Bitween.Web/ClientApp/src/api/types.ts index 202f4f38..36945b9a 100644 --- a/SW.Bitween.Web/ClientApp/src/api/types.ts +++ b/SW.Bitween.Web/ClientApp/src/api/types.ts @@ -813,6 +813,8 @@ export interface DataSource { /** The adapter that speaks this protocol, e.g. bitween.bus.rabbitmq. */ adapterId: string; kind: string; + /** Auto, Exclusive or PerNode. Auto means Exclusive for a broker and PerNode otherwise. */ + placement: string; inactive: boolean; /** * How long a message's deduplication key is remembered. Has to exceed the widest redelivery diff --git a/SW.Bitween.Web/ClientApp/src/pages/data-sources/DataSourcePage.tsx b/SW.Bitween.Web/ClientApp/src/pages/data-sources/DataSourcePage.tsx index 91a9ed06..10704bc3 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/data-sources/DataSourcePage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/data-sources/DataSourcePage.tsx @@ -161,6 +161,9 @@ export function DataSourcePage() { // A database has no queue to redeliver from and no bus gateway to feed, so the broker-only // parts of this page are left off it rather than shown empty. const relational = d.kind === "Relational"; + // Resolved the way the supervisor resolves it: Auto is exclusive for a broker only, and either + // kind can be set explicitly — a database can be pinned to one node too. + const exclusive = d.placement === "Exclusive" || (d.placement !== "PerNode" && !relational); const setProperty = (key: string, value: string) => setDraft({ ...draft, properties: { ...draft.properties, [key]: value } }); @@ -198,7 +201,9 @@ export function DataSourcePage() { : d.subscriptionCount > 0 ? `${d.subscriptionCount} subscription(s) still run on this connection, so deleting will be refused until they are moved off it.` : relational - ? "Every node closes its connection to this database." + ? exclusive + ? "The node holding it closes its connection to this database." + : "Every node closes its connection to this database." : "Its remembered deduplication keys go with it, so a message already processed could be handled again if it arrives later." } /> @@ -356,7 +361,7 @@ export function DataSourcePage() { {/* The two placements are opposites, and the wrong explanation directly contradicts the "Held by" value right above it — which is how someone concludes the page is broken. */}

- {source.data?.kind === "Relational" ? ( + {!exclusive ? ( <> A connection pool is held by every node that runs work, not leased to one: a node without it could not run the exchanges that need it. Nothing here is exclusive, so @@ -364,7 +369,7 @@ export function DataSourcePage() { ) : ( <> - A broker connection is exclusive, so exactly one node holds it. The term after the + This connection is exclusive, so exactly one node holds it. The term after the node name is the fencing token — it increases every time ownership moves, and a node whose term is no longer current stops immediately rather than carrying on consuming. @@ -713,14 +718,21 @@ function CeilingInput({ }) { return (

-