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