From c46d35bcb48a52af0d4b679c182cdc6ab8282ebf Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:39:18 -0300 Subject: [PATCH] fix(dashboard): hide disabled provider connections from combo builder (#6984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): hide disabled provider connections from combo builder The combos page's fetchData() only filtered available connections by testStatus ("active"/"success"), so a connection the user had explicitly disabled (isActive: false) could still show up in the combo builder if it carried a stale testStatus from before it was disabled. Add filterActiveConnections() in src/shared/utils/connectionStatus.ts and apply it ahead of the existing testStatus filter. Co-authored-by: itolstov Inspired-by: https://github.com/decolua/9router/pull/2526 * chore(changelog): fragment for #6984 * fix(combos): keep combos page within frozen size cap Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(combos): extract filterUsableConnections to shrink the combos god-file The combos page only filtered provider connections on testStatus, so a connection the user had explicitly disabled survived with a stale "active"/"success" status. The isActive + testStatus gate now lives in the shared connectionStatus util as filterUsableConnections(), which the page calls in a single line. This keeps src/app/(dashboard)/dashboard/combos/page.tsx BELOW its frozen file-size cap (4653 vs 4655 congelado — the file shrinks by 2 lines vs the release tip) without touching config/quality/file-size-baseline.json, as the gate asks ("modularize/extraia (DRY) para encolher"). The regression test now exercises filterUsableConnections directly instead of hand-mirroring the page's filter chain, so it guards the real code path. Co-authored-by: diegosouzapw * fix(combos): drop nullish entries in filterActiveConnections `connection?.isActive !== false` evaluated to true for null/undefined entries, so nullish elements survived the filter. Callers read properties off the result — filterUsableConnections() reads `connection.testStatus` — which would throw "TypeError: Cannot read properties of null". Guard with an explicit truthiness check. Covered by a test that fails against the previous predicate. Reported-by: gemini-code-assist Co-authored-by: diegosouzapw --------- Co-authored-by: itolstov --- .../6984-hide-disabled-connections-combos.md | 1 + src/app/(dashboard)/dashboard/combos/page.tsx | 6 +- src/shared/utils/connectionStatus.ts | 41 ++++++++++++ ...nnection-status-filter-active-2526.test.ts | 62 +++++++++++++++++++ 4 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fixes/6984-hide-disabled-connections-combos.md create mode 100644 src/shared/utils/connectionStatus.ts create mode 100644 tests/unit/connection-status-filter-active-2526.test.ts diff --git a/changelog.d/fixes/6984-hide-disabled-connections-combos.md b/changelog.d/fixes/6984-hide-disabled-connections-combos.md new file mode 100644 index 0000000000..48d637549b --- /dev/null +++ b/changelog.d/fixes/6984-hide-disabled-connections-combos.md @@ -0,0 +1 @@ +- **fix(dashboard):** the combos builder now hides provider connections the user has explicitly disabled, instead of relying only on stale test-status (#6984 — thanks @attid). diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 6857fbd79a..9125fcea08 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -13,6 +13,7 @@ import Modal from "@/shared/components/Modal"; import Toggle from "@/shared/components/Toggle"; import Tooltip from "@/shared/components/Tooltip"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { filterUsableConnections } from "@/shared/utils/connectionStatus"; import { FieldLabelWithHelp, WeightTotalBar } from "./parts"; import { useComboProxyAssignments } from "./useComboProxyAssignments"; import { ResponseValidationEditor, type ResponseValidationValue } from "./ResponseValidationEditor"; @@ -770,10 +771,7 @@ export default function CombosPage() { if (combosRes.ok) setCombos((combosData.combos || []).filter((c) => !c.isHidden)); if (providersRes.ok) { - const active = (providersData.connections || []).filter( - (c) => c.testStatus === "active" || c.testStatus === "success" - ); - setActiveProviders(active); + setActiveProviders(filterUsableConnections(providersData.connections || [])); } if (metricsRes.ok) setMetrics(metricsData.metrics || {}); setProviderNodes(nodesData.nodes || []); diff --git a/src/shared/utils/connectionStatus.ts b/src/shared/utils/connectionStatus.ts new file mode 100644 index 0000000000..4a6baaf4de --- /dev/null +++ b/src/shared/utils/connectionStatus.ts @@ -0,0 +1,41 @@ +/** + * Shared helpers for filtering/classifying provider connections by their + * active/disabled state, independent of their last test result. + * + * A connection can have `isActive: false` (explicitly disabled by the user) + * while still carrying a stale `testStatus` of "active"/"success" from + * before it was disabled — callers that only filter on `testStatus` will + * incorrectly keep serving disabled connections. + */ + +export interface ConnectionActiveFlag { + isActive?: boolean; + [key: string]: unknown; +} + +/** + * Filters out connections that have been explicitly disabled + * (`isActive === false`). Connections without an `isActive` field are + * treated as active for backward compatibility. Nullish entries are + * dropped so callers can safely read properties off the result. + */ +export function filterActiveConnections( + connections: T[] | null | undefined +): T[] { + if (!Array.isArray(connections)) return []; + return connections.filter((connection) => !!connection && connection.isActive !== false); +} + +/** + * Filters connections down to the ones a builder UI can actually route to: + * enabled (`isActive !== false`) AND last tested healthy ("active"/"success"). + * Both gates must be applied together — filtering on `testStatus` alone keeps + * disabled connections that carry a stale healthy status. + */ +export function filterUsableConnections( + connections: T[] | null | undefined +): T[] { + return filterActiveConnections(connections).filter( + (connection) => connection.testStatus === "active" || connection.testStatus === "success" + ); +} diff --git a/tests/unit/connection-status-filter-active-2526.test.ts b/tests/unit/connection-status-filter-active-2526.test.ts new file mode 100644 index 0000000000..9e60485fc3 --- /dev/null +++ b/tests/unit/connection-status-filter-active-2526.test.ts @@ -0,0 +1,62 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { filterActiveConnections, filterUsableConnections } from "@/shared/utils/connectionStatus"; + +// Ported from decolua/9router#2526 — the combos builder listed provider +// connections the user had explicitly disabled, because the page only +// filtered on the connection's last `testStatus` and ignored `isActive`. +// A disabled connection can still carry a stale "active"/"success" +// testStatus from before it was disabled. + +test("filterActiveConnections excludes explicitly disabled connections", () => { + const active = { id: "active", isActive: true }; + const legacyActive = { id: "legacy" }; // no isActive field -> treated as active + const disabled = { id: "disabled", isActive: false }; + + assert.deepEqual(filterActiveConnections([active, disabled, legacyActive]), [ + active, + legacyActive, + ]); +}); + +test("filterActiveConnections returns an empty list for invalid input", () => { + assert.deepEqual(filterActiveConnections(undefined), []); + assert.deepEqual(filterActiveConnections(null), []); +}); + +test("filterActiveConnections drops nullish entries instead of passing them through", () => { + // A nullish element must not survive: callers read properties off the + // result (e.g. `connection.testStatus`) and would throw a TypeError. + const active = { id: "active", isActive: true }; + + assert.deepEqual(filterActiveConnections([null, active, undefined]), [active]); + assert.doesNotThrow(() => filterUsableConnections([null, undefined])); + assert.deepEqual(filterUsableConnections([null, { id: "ok", testStatus: "active" }]), [ + { id: "ok", testStatus: "active" }, + ]); +}); + +test("filterUsableConnections applies the isActive gate before the testStatus gate", () => { + // Regression for the exact bug: a disabled connection with a stale + // "active" testStatus must NOT survive the combined filter that + // src/app/(dashboard)/dashboard/combos/page.tsx fetchData() calls. + const connections = [ + { id: "healthy", isActive: true, testStatus: "active" }, + { id: "healthy-success", isActive: true, testStatus: "success" }, + { id: "disabled-but-stale-status", isActive: false, testStatus: "active" }, + { id: "disabled-success-status", isActive: false, testStatus: "success" }, + { id: "enabled-not-tested", isActive: true, testStatus: "untested" }, + { id: "legacy-no-isActive", testStatus: "active" }, + ]; + + assert.deepEqual( + filterUsableConnections(connections).map((c) => c.id), + ["healthy", "healthy-success", "legacy-no-isActive"] + ); +}); + +test("filterUsableConnections returns an empty list for invalid input", () => { + assert.deepEqual(filterUsableConnections(undefined), []); + assert.deepEqual(filterUsableConnections(null), []); +});