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..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; @@ -27,6 +28,7 @@ interface RawDataSource { cpuPercentLimit?: number | null; cpuLimitSamples?: number | null; gatewayCount: number; + subscriptionCount?: number | null; lastKnownState: string | null; lastHeartbeatOn: string | null; lastException: string | null; @@ -54,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, @@ -61,6 +64,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..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 @@ -845,6 +847,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/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" 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..10704bc3 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,12 @@ 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"; + // 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 } }); @@ -174,7 +188,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 + ? 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." } /> )} @@ -304,432 +324,423 @@ export function DataSourcePage() { )} - {/* ——— 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)} + {relational ? ( + {String(d.subscriptionCount)} + ) : ( + + + {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. */} +

+ {!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 + there is no fencing token and no ownership to move. + + ) : ( + <> + 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. + + )} +

+ + +
+ + {/* 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, hardMemoryLimitMb: 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. */} +
+

Resource ceilings

+

+ 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: v })} + /> + setDraft({ ...draft, hardMemoryLimitMb: v })} + /> + setDraft({ ...draft, cpuPercentLimit: v })} + /> + setDraft({ ...draft, cpuLimitSamples: v })} + />
-
- {draft.softMemoryLimitMb > 0 && - draft.hardMemoryLimitMb > 0 && - draft.softMemoryLimitMb > draft.hardMemoryLimitMb && ( + {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. +

+ )} + + {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.

+ ) : ( + 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. +

+ ) )} - -

- 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 && ( -

- 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) => ( + +
- ); - })} + +
+ )}
- {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) => ( - - -
- -
- )}
+
+
+ + {canEdit && dirty && ( + save.mutate(draft)} + onDiscard={() => { + setError(null); + setDraft(draftOf(d)); + }} + /> + )} + + ); +} - {error && {error}} - - {canEdit && ( -
- - {dirty && ( - - )} - {!dirty && !save.isPending && No changes.} -
- )} - - +/** 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 ( +
+
+ + {/* Focusable so a keyboard reaches it too; the text itself is tied to the input below. */} + +
+ + {help} + + onChange(Number(e.target.value) || 0)} + />
); } 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: "", 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}

+ )}
)} -
+
); } 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(" "); +};