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.
This commit is contained in:
openhands
2026-09-03 09:38:05 +07:00
parent ac98dd9d32
commit d1d4e0f02b
3 changed files with 119 additions and 10 deletions

View File

@@ -25,6 +25,7 @@ import { useNotificationStore } from "@/store/notificationStore";
import { useHeaderSearchStore } from "@/store/headerSearchStore"; import { useHeaderSearchStore } from "@/store/headerSearchStore";
import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge"; import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
import AddCompatibleModal from "./components/AddCompatibleModal"; import AddCompatibleModal from "./components/AddCompatibleModal";
import { STATUS_FILTER_OPTIONS, matchesStatusFilter } from "./utils";
function getStatusDisplay(connected, error, errorCode) { function getStatusDisplay(connected, error, errorCode) {
const parts = []; const parts = [];
@@ -105,6 +106,7 @@ export default function ProvidersPage() {
useState(false); useState(false);
const [testingMode, setTestingMode] = useState(null); const [testingMode, setTestingMode] = useState(null);
const [testResults, setTestResults] = useState(null); const [testResults, setTestResults] = useState(null);
const [statusFilter, setStatusFilter] = useState("all");
const notify = useNotificationStore(); const notify = useNotificationStore();
const searchQuery = useHeaderSearchStore((s) => s.query); const searchQuery = useHeaderSearchStore((s) => s.query);
const registerSearch = useHeaderSearchStore((s) => s.register); const registerSearch = useHeaderSearchStore((s) => s.register);
@@ -212,6 +214,9 @@ export default function ProvidersPage() {
return { connected, error, total, errorCode, errorTime, allDisabled }; 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 // Toggle all connections for a provider on/off. authType may be a single
// string or an array (kiro counts oauth + api_key/apikey together). // string or an array (kiro counts oauth + api_key/apikey together).
const handleToggleProvider = async (providerId, authType, newActive) => { const handleToggleProvider = async (providerId, authType, newActive) => {
@@ -267,7 +272,9 @@ export default function ProvidersPage() {
textIcon: "OC", textIcon: "OC",
apiType: node.apiType, apiType: node.apiType,
})) }))
.filter((p) => matchSearch(p.name)); .filter(
(p) => matchSearch(p.name) && matchStatus(getProviderStats(p.id, "apikey")),
);
const anthropicCompatibleProviders = providerNodes const anthropicCompatibleProviders = providerNodes
.filter((node) => node.type === "anthropic-compatible") .filter((node) => node.type === "anthropic-compatible")
@@ -277,7 +284,9 @@ export default function ProvidersPage() {
color: "#D97757", color: "#D97757",
textIcon: "AC", 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" // Dual-auth providers (oauth + apikey) store API keys as authType "apikey"
// (and sometimes "api_key"). Card stats must count both so totals match detail. // (and sometimes "api_key"). Card stats must count both so totals match detail.
@@ -298,21 +307,32 @@ export default function ProvidersPage() {
}; };
const oauthEntries = sortByPriority( 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", "oauth",
); );
const freeEntries = Object.entries(FREE_PROVIDERS) 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)); .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 // 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 // dualAuthTypes per provider instead of a fixed "apikey" — otherwise oauth
// connections are invisible here (mismatch with the detail page). // connections are invisible here (mismatch with the detail page).
const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS) const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS)
.filter( .filter(
([, info]) => ([key, info]) =>
!info.hidden && !info.hidden &&
matchSearch(info.name) && 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]) => { .sort(([ka, a], [kb, b]) => {
const pa = a.priority ?? 999; const pa = a.priority ?? 999;
@@ -328,10 +348,11 @@ export default function ProvidersPage() {
// API Key: connected providers first, then alphabetical by name // API Key: connected providers first, then alphabetical by name
const apikeyEntries = Object.entries(APIKEY_PROVIDERS) const apikeyEntries = Object.entries(APIKEY_PROVIDERS)
.filter( .filter(
([, info]) => ([key, info]) =>
!info.hidden && !info.hidden &&
(info.serviceKinds ?? ["llm"]).includes("llm") && (info.serviceKinds ?? ["llm"]).includes("llm") &&
matchSearch(info.name), matchSearch(info.name) &&
matchStatus(getProviderStats(key, "apikey"), info.noAuth),
) )
.sort(([ka, a], [kb, b]) => { .sort(([ka, a], [kb, b]) => {
const ca = getProviderStats(ka, "apikey").total > 0 ? 0 : 1; const ca = getProviderStats(ka, "apikey").total > 0 ? 0 : 1;
@@ -339,7 +360,7 @@ export default function ProvidersPage() {
if (ca !== cb) return ca - cb; if (ca !== cb) return ca - cb;
return (a.name || "").localeCompare(b.name || ""); return (a.name || "").localeCompare(b.name || "");
}); });
const isApikeySearching = !!searchQuery.trim(); const isApikeySearching = !!searchQuery.trim() || statusFilter !== "all";
const visibleApikeyEntries = const visibleApikeyEntries =
isApikeySearching || showAllApikey isApikeySearching || showAllApikey
? apikeyEntries ? apikeyEntries
@@ -365,12 +386,29 @@ export default function ProvidersPage() {
return ( return (
<div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0"> <div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0">
<div className="flex items-center justify-end">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-8 rounded-lg border border-black/10 bg-black/[0.02] px-2 text-xs text-text-primary outline-none transition-colors hover:bg-black/5 dark:border-white/10 dark:bg-white/[0.03] dark:hover:bg-white/10"
aria-label="Filter providers by connection status"
>
{STATUS_FILTER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{!hasAnyResult && ( {!hasAnyResult && (
<div className="text-center py-8 border border-dashed border-border rounded-xl"> <div className="text-center py-8 border border-dashed border-border rounded-xl">
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2"> <span className="material-symbols-outlined text-[32px] text-text-muted mb-2">
search_off search_off
</span> </span>
<p className="text-text-muted text-sm">No providers match your search</p> <p className="text-text-muted text-sm">
No providers match your search or filters
</p>
</div> </div>
)} )}

View File

@@ -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;
}

View File

@@ -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);
});
});