From d1d4e0f02b49f2fc167824abc26614e22f315c97 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 3 Sep 2026 09:38:05 +0700 Subject: [PATCH] feat(providers): add status filter to providers dashboard Adds a client-side status filter (All / Active / Inactive / No connection) to the Providers page, applied over the already-fetched provider + connection list. Status derives from getProviderStats (total, allDisabled); noAuth providers count as Active. Filter composes with the existing search across all provider sections. Part of #3699. --- .../(dashboard)/dashboard/providers/page.js | 58 +++++++++++++++---- .../(dashboard)/dashboard/providers/utils.js | 19 ++++++ tests/unit/providers-status-filter.test.js | 52 +++++++++++++++++ 3 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/providers/utils.js create mode 100644 tests/unit/providers-status-filter.test.js diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index f9a46154..a0eb586c 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -25,6 +25,7 @@ import { useNotificationStore } from "@/store/notificationStore"; import { useHeaderSearchStore } from "@/store/headerSearchStore"; import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge"; import AddCompatibleModal from "./components/AddCompatibleModal"; +import { STATUS_FILTER_OPTIONS, matchesStatusFilter } from "./utils"; function getStatusDisplay(connected, error, errorCode) { const parts = []; @@ -105,6 +106,7 @@ export default function ProvidersPage() { useState(false); const [testingMode, setTestingMode] = useState(null); const [testResults, setTestResults] = useState(null); + const [statusFilter, setStatusFilter] = useState("all"); const notify = useNotificationStore(); const searchQuery = useHeaderSearchStore((s) => s.query); const registerSearch = useHeaderSearchStore((s) => s.register); @@ -212,6 +214,9 @@ export default function ProvidersPage() { return { connected, error, total, errorCode, errorTime, allDisabled }; }; + const matchStatus = (stats, isNoAuth) => + matchesStatusFilter(statusFilter, stats, isNoAuth); + // Toggle all connections for a provider on/off. authType may be a single // string or an array (kiro counts oauth + api_key/apikey together). const handleToggleProvider = async (providerId, authType, newActive) => { @@ -267,7 +272,9 @@ export default function ProvidersPage() { textIcon: "OC", apiType: node.apiType, })) - .filter((p) => matchSearch(p.name)); + .filter( + (p) => matchSearch(p.name) && matchStatus(getProviderStats(p.id, "apikey")), + ); const anthropicCompatibleProviders = providerNodes .filter((node) => node.type === "anthropic-compatible") @@ -277,7 +284,9 @@ export default function ProvidersPage() { color: "#D97757", textIcon: "AC", })) - .filter((p) => matchSearch(p.name)); + .filter( + (p) => matchSearch(p.name) && matchStatus(getProviderStats(p.id, "apikey")), + ); // Dual-auth providers (oauth + apikey) store API keys as authType "apikey" // (and sometimes "api_key"). Card stats must count both so totals match detail. @@ -298,21 +307,32 @@ export default function ProvidersPage() { }; const oauthEntries = sortByPriority( - Object.entries(OAUTH_PROVIDERS).filter(([, info]) => !info.hidden && matchSearch(info.name)), + Object.entries(OAUTH_PROVIDERS).filter( + ([key, info]) => + !info.hidden && + matchSearch(info.name) && + matchStatus(getProviderStats(key, dualAuthTypes(info, key)), info.noAuth), + ), "oauth", ); const freeEntries = Object.entries(FREE_PROVIDERS) - .filter(([, info]) => !info.hidden && matchSearch(info.name)) + .filter( + ([key, info]) => + !info.hidden && + matchSearch(info.name) && + matchStatus(getProviderStats(key, dualAuthTypes(info, key)), info.noAuth), + ) .sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0)); // Free Tier cards may be oauth-only (e.g. kimchi) or dual-auth, so count via // dualAuthTypes per provider instead of a fixed "apikey" — otherwise oauth // connections are invisible here (mismatch with the detail page). const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS) .filter( - ([, info]) => + ([key, info]) => !info.hidden && matchSearch(info.name) && - (info.serviceKinds ?? ["llm"]).includes("llm"), + (info.serviceKinds ?? ["llm"]).includes("llm") && + matchStatus(getProviderStats(key, dualAuthTypes(info, key)), info.noAuth), ) .sort(([ka, a], [kb, b]) => { const pa = a.priority ?? 999; @@ -328,10 +348,11 @@ export default function ProvidersPage() { // API Key: connected providers first, then alphabetical by name const apikeyEntries = Object.entries(APIKEY_PROVIDERS) .filter( - ([, info]) => + ([key, info]) => !info.hidden && (info.serviceKinds ?? ["llm"]).includes("llm") && - matchSearch(info.name), + matchSearch(info.name) && + matchStatus(getProviderStats(key, "apikey"), info.noAuth), ) .sort(([ka, a], [kb, b]) => { const ca = getProviderStats(ka, "apikey").total > 0 ? 0 : 1; @@ -339,7 +360,7 @@ export default function ProvidersPage() { if (ca !== cb) return ca - cb; return (a.name || "").localeCompare(b.name || ""); }); - const isApikeySearching = !!searchQuery.trim(); + const isApikeySearching = !!searchQuery.trim() || statusFilter !== "all"; const visibleApikeyEntries = isApikeySearching || showAllApikey ? apikeyEntries @@ -365,12 +386,29 @@ export default function ProvidersPage() { return (
+
+ +
+ {!hasAnyResult && (
search_off -

No providers match your search

+

+ No providers match your search or filters +

)} diff --git a/src/app/(dashboard)/dashboard/providers/utils.js b/src/app/(dashboard)/dashboard/providers/utils.js new file mode 100644 index 00000000..fa114325 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/utils.js @@ -0,0 +1,19 @@ +export const STATUS_FILTER_OPTIONS = [ + { value: "all", label: "All" }, + { value: "active", label: "Active" }, + { value: "inactive", label: "Inactive" }, + { value: "none", label: "No connection" }, +]; + +// noAuth providers (e.g. free proxies) are always usable even though they +// never have a stored connection record, so they never fall into "none". +export function getConnectionStatus(stats, isNoAuth = false) { + if (isNoAuth) return "active"; + if (!stats || stats.total === 0) return "none"; + return stats.allDisabled ? "inactive" : "active"; +} + +export function matchesStatusFilter(statusFilter, stats, isNoAuth = false) { + if (statusFilter === "all") return true; + return getConnectionStatus(stats, isNoAuth) === statusFilter; +} diff --git a/tests/unit/providers-status-filter.test.js b/tests/unit/providers-status-filter.test.js new file mode 100644 index 00000000..fdcfd835 --- /dev/null +++ b/tests/unit/providers-status-filter.test.js @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + STATUS_FILTER_OPTIONS, + getConnectionStatus, + matchesStatusFilter, +} from "@/app/(dashboard)/dashboard/providers/utils.js"; + +describe("providers status filter", () => { + it("exposes all/active/inactive/none options", () => { + expect(STATUS_FILTER_OPTIONS.map((o) => o.value)).toEqual([ + "all", + "active", + "inactive", + "none", + ]); + }); + + it("classifies a provider with no connections as none", () => { + expect(getConnectionStatus({ total: 0, allDisabled: false })).toBe("none"); + }); + + it("classifies a provider whose only connections are disabled as inactive", () => { + expect(getConnectionStatus({ total: 2, allDisabled: true })).toBe( + "inactive", + ); + }); + + it("classifies a provider with at least one enabled connection as active", () => { + expect(getConnectionStatus({ total: 1, allDisabled: false })).toBe( + "active", + ); + }); + + it("treats noAuth providers as active even with no stored connection", () => { + expect(getConnectionStatus({ total: 0, allDisabled: false }, true)).toBe( + "active", + ); + }); + + it("matchesStatusFilter always passes for 'all'", () => { + expect(matchesStatusFilter("all", { total: 0, allDisabled: false })).toBe( + true, + ); + }); + + it("matchesStatusFilter compares against the derived status", () => { + const disabledStats = { total: 3, allDisabled: true }; + expect(matchesStatusFilter("inactive", disabledStats)).toBe(true); + expect(matchesStatusFilter("active", disabledStats)).toBe(false); + expect(matchesStatusFilter("none", disabledStats)).toBe(false); + }); +});