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:
@@ -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 (
|
||||
<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 && (
|
||||
<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">
|
||||
search_off
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
19
src/app/(dashboard)/dashboard/providers/utils.js
Normal file
19
src/app/(dashboard)/dashboard/providers/utils.js
Normal 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;
|
||||
}
|
||||
52
tests/unit/providers-status-filter.test.js
Normal file
52
tests/unit/providers-status-filter.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user