mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
## Features - Add Cline & Kilo Code tool cards - Tailscale TUN mode for stable Funnel TLS - Sort APIKEY providers by usage, collapse to top 20 ## Improvements - Local Material Symbols font (no Google Fonts) - Docker base: Bun → Node 22-alpine - MITM reads aliases from JSON cache (no native sqlite) - Stream stall timeout (2 min) in open-sse ## Fixes - Fal.ai key test: use stable models endpoint
1302 lines
43 KiB
JavaScript
1302 lines
43 KiB
JavaScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import PropTypes from "prop-types";
|
|
import {
|
|
Card,
|
|
CardSkeleton,
|
|
Badge,
|
|
Button,
|
|
Input,
|
|
Modal,
|
|
Select,
|
|
Toggle,
|
|
} from "@/shared/components";
|
|
import ProviderIcon from "@/shared/components/ProviderIcon";
|
|
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
|
import {
|
|
FREE_PROVIDERS,
|
|
FREE_TIER_PROVIDERS,
|
|
WEB_COOKIE_PROVIDERS,
|
|
OPENAI_COMPATIBLE_PREFIX,
|
|
ANTHROPIC_COMPATIBLE_PREFIX,
|
|
} from "@/shared/constants/providers";
|
|
import Link from "next/link";
|
|
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
|
import { useNotificationStore } from "@/store/notificationStore";
|
|
import { useHeaderSearchStore } from "@/store/headerSearchStore";
|
|
import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
|
|
|
|
function getStatusDisplay(connected, error, errorCode) {
|
|
const parts = [];
|
|
if (connected > 0) {
|
|
parts.push(
|
|
<Badge key="connected" variant="success" size="sm" dot>
|
|
{connected} Connected
|
|
</Badge>,
|
|
);
|
|
}
|
|
if (error > 0) {
|
|
const errText = errorCode
|
|
? `${error} Error (${errorCode})`
|
|
: `${error} Error`;
|
|
parts.push(
|
|
<Badge key="error" variant="error" size="sm" dot>
|
|
{errText}
|
|
</Badge>,
|
|
);
|
|
}
|
|
if (parts.length === 0) {
|
|
return <span className="text-text-muted">No connections</span>;
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
function getConnectionErrorTag(connection) {
|
|
if (!connection) return null;
|
|
|
|
const explicitType = connection.lastErrorType;
|
|
if (explicitType === "runtime_error") return "RUNTIME";
|
|
if (
|
|
explicitType === "upstream_auth_error" ||
|
|
explicitType === "auth_missing" ||
|
|
explicitType === "token_refresh_failed" ||
|
|
explicitType === "token_expired"
|
|
)
|
|
return "AUTH";
|
|
if (explicitType === "upstream_rate_limited") return "429";
|
|
if (explicitType === "upstream_unavailable") return "5XX";
|
|
if (explicitType === "network_error") return "NET";
|
|
|
|
const numericCode = Number(connection.errorCode);
|
|
if (Number.isFinite(numericCode) && numericCode >= 400)
|
|
return String(numericCode);
|
|
|
|
const fromMessage = getErrorCode(connection.lastError);
|
|
if (fromMessage === "401" || fromMessage === "403") return "AUTH";
|
|
if (fromMessage && fromMessage !== "ERR") return fromMessage;
|
|
|
|
const msg = (connection.lastError || "").toLowerCase();
|
|
if (
|
|
msg.includes("runtime") ||
|
|
msg.includes("not runnable") ||
|
|
msg.includes("not installed")
|
|
)
|
|
return "RUNTIME";
|
|
if (
|
|
msg.includes("invalid api key") ||
|
|
msg.includes("token invalid") ||
|
|
msg.includes("revoked") ||
|
|
msg.includes("unauthorized")
|
|
)
|
|
return "AUTH";
|
|
|
|
return "ERR";
|
|
}
|
|
|
|
const APIKEY_INITIAL_VISIBLE = 20;
|
|
|
|
export default function ProvidersPage() {
|
|
const [connections, setConnections] = useState([]);
|
|
const [providerNodes, setProviderNodes] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showAllApikey, setShowAllApikey] = useState(false);
|
|
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
|
|
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] =
|
|
useState(false);
|
|
const [testingMode, setTestingMode] = useState(null);
|
|
const [testResults, setTestResults] = useState(null);
|
|
const notify = useNotificationStore();
|
|
const searchQuery = useHeaderSearchStore((s) => s.query);
|
|
const registerSearch = useHeaderSearchStore((s) => s.register);
|
|
const unregisterSearch = useHeaderSearchStore((s) => s.unregister);
|
|
|
|
useEffect(() => {
|
|
registerSearch("Search providers...");
|
|
return () => unregisterSearch();
|
|
}, [registerSearch, unregisterSearch]);
|
|
|
|
const matchSearch = (name) =>
|
|
!searchQuery.trim() ||
|
|
name.toLowerCase().includes(searchQuery.trim().toLowerCase());
|
|
|
|
const sortByConnections = (entries, authType) =>
|
|
[...entries].sort(
|
|
(a, b) =>
|
|
getProviderStats(b[0], authType).total -
|
|
getProviderStats(a[0], authType).total,
|
|
);
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
try {
|
|
const [connectionsRes, nodesRes] = await Promise.all([
|
|
fetch("/api/providers"),
|
|
fetch("/api/provider-nodes"),
|
|
]);
|
|
const connectionsData = await connectionsRes.json();
|
|
const nodesData = await nodesRes.json();
|
|
if (connectionsRes.ok)
|
|
setConnections(connectionsData.connections || []);
|
|
if (nodesRes.ok) setProviderNodes(nodesData.nodes || []);
|
|
} catch (error) {
|
|
console.log("Error fetching data:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
fetchData();
|
|
}, []);
|
|
|
|
const getProviderStats = (providerId, authType) => {
|
|
const providerConnections = connections.filter(
|
|
(c) => c.provider === providerId && c.authType === authType,
|
|
);
|
|
|
|
const getEffectiveStatus = (conn) => {
|
|
const isCooldown = Object.entries(conn).some(
|
|
([k, v]) =>
|
|
k.startsWith("modelLock_") && v && new Date(v).getTime() > Date.now(),
|
|
);
|
|
return conn.testStatus === "unavailable" && !isCooldown
|
|
? "active"
|
|
: conn.testStatus;
|
|
};
|
|
|
|
const connected = providerConnections.filter((c) => {
|
|
const status = getEffectiveStatus(c);
|
|
return status === "active" || status === "success";
|
|
}).length;
|
|
|
|
const errorConns = providerConnections.filter((c) => {
|
|
const status = getEffectiveStatus(c);
|
|
return (
|
|
status === "error" || status === "expired" || status === "unavailable"
|
|
);
|
|
});
|
|
|
|
const error = errorConns.length;
|
|
const total = providerConnections.length;
|
|
const allDisabled =
|
|
total > 0 && providerConnections.every((c) => c.isActive === false);
|
|
|
|
const latestError = errorConns.sort(
|
|
(a, b) => new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0),
|
|
)[0];
|
|
const errorCode = latestError ? getConnectionErrorTag(latestError) : null;
|
|
const errorTime = latestError?.lastErrorAt
|
|
? getRelativeTime(latestError.lastErrorAt)
|
|
: null;
|
|
|
|
return { connected, error, total, errorCode, errorTime, allDisabled };
|
|
};
|
|
|
|
// Toggle all connections for a provider on/off
|
|
const handleToggleProvider = async (providerId, authType, newActive) => {
|
|
const providerConns = connections.filter(
|
|
(c) => c.provider === providerId && c.authType === authType,
|
|
);
|
|
setConnections((prev) =>
|
|
prev.map((c) =>
|
|
c.provider === providerId && c.authType === authType
|
|
? { ...c, isActive: newActive }
|
|
: c,
|
|
),
|
|
);
|
|
await Promise.allSettled(
|
|
providerConns.map((c) =>
|
|
fetch(`/api/providers/${c.id}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ isActive: newActive }),
|
|
}),
|
|
),
|
|
);
|
|
};
|
|
|
|
const handleBatchTest = async (mode, providerId = null) => {
|
|
if (testingMode) return;
|
|
setTestingMode(mode === "provider" ? providerId : mode);
|
|
setTestResults(null);
|
|
try {
|
|
const res = await fetch("/api/providers/test-batch", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ mode, providerId }),
|
|
});
|
|
const data = await res.json();
|
|
setTestResults(data);
|
|
if (data.summary) {
|
|
const { passed, failed, total } = data.summary;
|
|
if (failed === 0) notify.success(`All ${total} tests passed`);
|
|
else notify.warning(`${passed}/${total} passed, ${failed} failed`);
|
|
}
|
|
} catch (error) {
|
|
setTestResults({ error: "Test request failed" });
|
|
notify.error("Provider test failed");
|
|
} finally {
|
|
setTestingMode(null);
|
|
}
|
|
};
|
|
|
|
const compatibleProviders = providerNodes
|
|
.filter((node) => node.type === "openai-compatible")
|
|
.map((node) => ({
|
|
id: node.id,
|
|
name: node.name || "OpenAI Compatible",
|
|
color: "#10A37F",
|
|
textIcon: "OC",
|
|
apiType: node.apiType,
|
|
}))
|
|
.filter((p) => matchSearch(p.name));
|
|
|
|
const anthropicCompatibleProviders = providerNodes
|
|
.filter((node) => node.type === "anthropic-compatible")
|
|
.map((node) => ({
|
|
id: node.id,
|
|
name: node.name || "Anthropic Compatible",
|
|
color: "#D97757",
|
|
textIcon: "AC",
|
|
}))
|
|
.filter((p) => matchSearch(p.name));
|
|
|
|
const oauthEntries = Object.entries(OAUTH_PROVIDERS).filter(([, info]) =>
|
|
matchSearch(info.name),
|
|
);
|
|
const freeEntries = Object.entries(FREE_PROVIDERS).filter(([, info]) =>
|
|
matchSearch(info.name),
|
|
);
|
|
const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS).filter(
|
|
([, info]) => matchSearch(info.name),
|
|
);
|
|
const apikeyEntries = sortByConnections(
|
|
Object.entries(APIKEY_PROVIDERS).filter(
|
|
([, info]) =>
|
|
(info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name),
|
|
),
|
|
"apikey",
|
|
);
|
|
const isApikeySearching = !!searchQuery.trim();
|
|
const visibleApikeyEntries =
|
|
isApikeySearching || showAllApikey
|
|
? apikeyEntries
|
|
: apikeyEntries.slice(0, APIKEY_INITIAL_VISIBLE);
|
|
const hiddenApikeyCount = apikeyEntries.length - APIKEY_INITIAL_VISIBLE;
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex flex-col gap-8">
|
|
<CardSkeleton />
|
|
<CardSkeleton />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const hasAnyResult =
|
|
oauthEntries.length > 0 ||
|
|
freeEntries.length > 0 ||
|
|
freeTierEntries.length > 0 ||
|
|
apikeyEntries.length > 0 ||
|
|
compatibleProviders.length > 0 ||
|
|
anthropicCompatibleProviders.length > 0;
|
|
|
|
return (
|
|
<div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0">
|
|
{!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>
|
|
</div>
|
|
)}
|
|
|
|
{/* Custom Providers (OpenAI/Anthropic Compatible) — dynamic */}
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<h2 className="text-lg sm:text-xl font-semibold flex items-center gap-2 leading-tight">
|
|
Custom Providers (OpenAI/Anthropic Compatible){" "}
|
|
</h2>
|
|
<div className="grid grid-cols-1 gap-2 sm:flex sm:w-auto">
|
|
<Button
|
|
size="sm"
|
|
icon="add"
|
|
onClick={() => setShowAddAnthropicCompatibleModal(true)}
|
|
className="w-full sm:w-auto"
|
|
>
|
|
Add Anthropic Compatible
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
icon="add"
|
|
onClick={() => setShowAddCompatibleModal(true)}
|
|
className="w-full !bg-white !text-black hover:!bg-gray-100 sm:w-auto"
|
|
>
|
|
Add OpenAI Compatible
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{compatibleProviders.length === 0 &&
|
|
anthropicCompatibleProviders.length === 0 ? (
|
|
<div className="flex items-center justify-center gap-2 py-2 border border-dashed border-border rounded-xl text-text-muted text-sm">
|
|
<span className="material-symbols-outlined text-[18px]">extension</span>
|
|
<span>No custom providers — use buttons above to add OpenAI/Anthropic compatible endpoints</span>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
|
{[...compatibleProviders, ...anthropicCompatibleProviders].map(
|
|
(info) => (
|
|
<ApiKeyProviderCard
|
|
key={info.id}
|
|
providerId={info.id}
|
|
provider={info}
|
|
stats={getProviderStats(info.id, "apikey")}
|
|
authType="compatible"
|
|
onToggle={(active) =>
|
|
handleToggleProvider(info.id, "apikey", active)
|
|
}
|
|
/>
|
|
),
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* OAuth Providers */}
|
|
{oauthEntries.length > 0 && (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<h2 className="text-lg sm:text-xl font-semibold flex items-center gap-2 leading-tight">
|
|
OAuth Providers
|
|
</h2>
|
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
|
<ModelAvailabilityBadge />
|
|
<button
|
|
onClick={() => handleBatchTest("oauth")}
|
|
disabled={!!testingMode}
|
|
className={`flex w-full items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-medium transition-colors sm:w-auto sm:py-1.5 ${
|
|
testingMode === "oauth"
|
|
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
|
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
|
|
}`}
|
|
title="Test all OAuth connections"
|
|
aria-label="Test all OAuth connections"
|
|
>
|
|
<span
|
|
className={`material-symbols-outlined text-[14px]${testingMode === "oauth" ? " animate-spin" : ""}`}
|
|
>
|
|
play_arrow
|
|
</span>
|
|
{testingMode === "oauth" ? "Testing..." : "Test All"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
|
{oauthEntries.map(([key, info]) => (
|
|
<ProviderCard
|
|
key={key}
|
|
providerId={key}
|
|
provider={info}
|
|
stats={getProviderStats(key, "oauth")}
|
|
authType="oauth"
|
|
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Free Tier Providers */}
|
|
{(freeEntries.length > 0 || freeTierEntries.length > 0) && (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<h2 className="text-lg sm:text-xl font-semibold flex items-center gap-2 leading-tight">
|
|
Free Tier Providers
|
|
</h2>
|
|
<button
|
|
onClick={() => handleBatchTest("free")}
|
|
disabled={!!testingMode}
|
|
className={`flex w-full items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-medium transition-colors sm:w-auto sm:py-1.5 ${
|
|
testingMode === "free"
|
|
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
|
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
|
|
}`}
|
|
title="Test all Free connections"
|
|
aria-label="Test all Free provider connections"
|
|
>
|
|
<span
|
|
className={`material-symbols-outlined text-[14px]${testingMode === "free" ? " animate-spin" : ""}`}
|
|
>
|
|
play_arrow
|
|
</span>
|
|
{testingMode === "free" ? "Testing..." : "Test All"}
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
|
{freeEntries.map(([key, info]) => (
|
|
<ProviderCard
|
|
key={key}
|
|
providerId={key}
|
|
provider={info}
|
|
stats={getProviderStats(key, "oauth")}
|
|
authType="free"
|
|
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
|
|
/>
|
|
))}
|
|
{freeTierEntries.map(([key, info]) => (
|
|
<ApiKeyProviderCard
|
|
key={key}
|
|
providerId={key}
|
|
provider={info}
|
|
stats={getProviderStats(key, "apikey")}
|
|
authType="apikey"
|
|
onToggle={(active) => handleToggleProvider(key, "apikey", active)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* API Key Providers — fixed list */}
|
|
{apikeyEntries.length > 0 && (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<h2 className="text-lg sm:text-xl font-semibold flex items-center gap-2 leading-tight">
|
|
API Key Providers{" "}
|
|
</h2>
|
|
<button
|
|
onClick={() => handleBatchTest("apikey")}
|
|
disabled={!!testingMode}
|
|
className={`flex w-full items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-medium transition-colors sm:w-auto sm:py-1.5 ${
|
|
testingMode === "apikey"
|
|
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
|
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
|
|
}`}
|
|
title="Test all API Key connections"
|
|
aria-label="Test all API Key connections"
|
|
>
|
|
<span
|
|
className={`material-symbols-outlined text-[14px]${testingMode === "apikey" ? " animate-spin" : ""}`}
|
|
>
|
|
play_arrow
|
|
</span>
|
|
{testingMode === "apikey" ? "Testing..." : "Test All"}
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
|
{visibleApikeyEntries.map(([key, info]) => (
|
|
<ApiKeyProviderCard
|
|
key={key}
|
|
providerId={key}
|
|
provider={info}
|
|
stats={getProviderStats(key, "apikey")}
|
|
authType="apikey"
|
|
onToggle={(active) => handleToggleProvider(key, "apikey", active)}
|
|
/>
|
|
))}
|
|
</div>
|
|
{!isApikeySearching && !showAllApikey && hiddenApikeyCount > 0 && (
|
|
<button
|
|
onClick={() => setShowAllApikey(true)}
|
|
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-primary/40 px-3 py-2.5 text-sm font-medium text-primary transition-colors hover:border-primary hover:bg-primary/5"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">expand_more</span>
|
|
Show all {apikeyEntries.length} providers
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Web Cookie Providers — use browser subscription cookie instead of API key */}
|
|
{/* <div className="flex flex-col gap-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-xl font-semibold flex items-center gap-2">
|
|
Web Cookie Providers{" "}
|
|
</h2>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{Object.entries(WEB_COOKIE_PROVIDERS).map(([key, info]) => (
|
|
<ApiKeyProviderCard
|
|
key={key}
|
|
providerId={key}
|
|
provider={info}
|
|
stats={getProviderStats(key, "apikey")}
|
|
authType="apikey"
|
|
onToggle={(active) => handleToggleProvider(key, "apikey", active)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div> */}
|
|
|
|
<AddOpenAICompatibleModal
|
|
isOpen={showAddCompatibleModal}
|
|
onClose={() => setShowAddCompatibleModal(false)}
|
|
onCreated={(node) => {
|
|
setProviderNodes((prev) => [...prev, node]);
|
|
setShowAddCompatibleModal(false);
|
|
}}
|
|
/>
|
|
<AddAnthropicCompatibleModal
|
|
isOpen={showAddAnthropicCompatibleModal}
|
|
onClose={() => setShowAddAnthropicCompatibleModal(false)}
|
|
onCreated={(node) => {
|
|
setProviderNodes((prev) => [...prev, node]);
|
|
setShowAddAnthropicCompatibleModal(false);
|
|
}}
|
|
/>
|
|
|
|
{/* Test Results Modal */}
|
|
{testResults && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-start justify-center px-3 pt-[6vh] sm:pt-[10vh]"
|
|
onClick={() => setTestResults(null)}
|
|
>
|
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
|
<div
|
|
className="relative bg-surface border border-border rounded-xl w-full max-w-[600px] max-h-[86vh] sm:max-h-[80vh] overflow-y-auto shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="sticky top-0 z-10 flex items-center justify-between px-5 py-3 border-b border-border bg-surface/95 backdrop-blur-sm rounded-t-xl">
|
|
<h3 className="font-semibold">Test Results</h3>
|
|
<button
|
|
onClick={() => setTestResults(null)}
|
|
className="p-1 rounded-lg hover:bg-bg text-text-muted hover:text-text-main transition-colors"
|
|
aria-label="Close test results"
|
|
>
|
|
<span className="material-symbols-outlined text-lg">close</span>
|
|
</button>
|
|
</div>
|
|
<div className="p-5">
|
|
<ProviderTestResultsView results={testResults} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
|
const { connected, error, errorCode, errorTime, allDisabled } = stats;
|
|
const isNoAuth = !!provider.noAuth;
|
|
|
|
const dotColors = {
|
|
free: "bg-green-500",
|
|
oauth: "bg-blue-500",
|
|
apikey: "bg-amber-500",
|
|
compatible: "bg-orange-500",
|
|
};
|
|
const dotLabels = {
|
|
free: "Free",
|
|
oauth: "OAuth",
|
|
apikey: "API Key",
|
|
compatible: "Compatible",
|
|
};
|
|
|
|
return (
|
|
<Link href={`/dashboard/providers/${providerId}`} className="group min-w-0">
|
|
<Card
|
|
padding="xs"
|
|
className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${allDisabled ? "opacity-50" : ""}`}
|
|
>
|
|
<div className="flex min-w-0 items-center justify-between gap-3">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<div
|
|
className="size-8 shrink-0 rounded-lg flex items-center justify-center"
|
|
style={{
|
|
backgroundColor: `${provider.color?.length > 7 ? provider.color : provider.color + "15"}`,
|
|
}}
|
|
>
|
|
<ProviderIcon
|
|
src={`/providers/${provider.id}.png`}
|
|
alt={provider.name}
|
|
size={30}
|
|
className="object-contain rounded-lg max-w-[32px] max-h-[32px]"
|
|
fallbackText={
|
|
provider.textIcon || provider.id.slice(0, 2).toUpperCase()
|
|
}
|
|
fallbackColor={provider.color}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 className="truncate font-semibold">{provider.name}</h3>
|
|
<div className="flex min-w-0 items-center gap-1.5 text-xs flex-wrap">
|
|
{allDisabled ? (
|
|
<Badge variant="default" size="sm">
|
|
<span className="flex items-center gap-1">
|
|
<span className="material-symbols-outlined text-[12px]">
|
|
pause_circle
|
|
</span>
|
|
Disabled
|
|
</span>
|
|
</Badge>
|
|
) : isNoAuth ? (
|
|
<Badge variant="success" size="sm" dot>Ready</Badge>
|
|
) : (
|
|
<>
|
|
{getStatusDisplay(connected, error, errorCode)}
|
|
{errorTime && (
|
|
<span className="text-text-muted">{errorTime}</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{stats.total > 0 && (
|
|
<div
|
|
className="opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onToggle(!allDisabled ? false : true);
|
|
}}
|
|
>
|
|
<Toggle
|
|
size="sm"
|
|
checked={!allDisabled}
|
|
onChange={() => {}}
|
|
title={allDisabled ? "Enable provider" : "Disable provider"}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
ProviderCard.propTypes = {
|
|
providerId: PropTypes.string.isRequired,
|
|
provider: PropTypes.shape({
|
|
id: PropTypes.string.isRequired,
|
|
name: PropTypes.string.isRequired,
|
|
color: PropTypes.string,
|
|
textIcon: PropTypes.string,
|
|
}).isRequired,
|
|
stats: PropTypes.shape({
|
|
connected: PropTypes.number,
|
|
error: PropTypes.number,
|
|
errorCode: PropTypes.string,
|
|
errorTime: PropTypes.string,
|
|
}).isRequired,
|
|
authType: PropTypes.string,
|
|
onToggle: PropTypes.func,
|
|
};
|
|
|
|
function ApiKeyProviderCard({
|
|
providerId,
|
|
provider,
|
|
stats,
|
|
authType,
|
|
onToggle,
|
|
}) {
|
|
const { connected, error, errorCode, errorTime, allDisabled } = stats;
|
|
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
|
|
const isAnthropicCompatible = providerId.startsWith(
|
|
ANTHROPIC_COMPATIBLE_PREFIX,
|
|
);
|
|
|
|
const dotColors = {
|
|
free: "bg-green-500",
|
|
oauth: "bg-blue-500",
|
|
apikey: "bg-amber-500",
|
|
compatible: "bg-orange-500",
|
|
};
|
|
const dotLabels = {
|
|
free: "Free",
|
|
oauth: "OAuth",
|
|
apikey: "API Key",
|
|
compatible: "Compatible",
|
|
};
|
|
|
|
const getIconPath = () => {
|
|
if (isCompatible)
|
|
return provider.apiType === "responses"
|
|
? "/providers/oai-r.png"
|
|
: "/providers/oai-cc.png";
|
|
if (isAnthropicCompatible) return "/providers/anthropic-m.png";
|
|
return `/providers/${provider.id}.png`;
|
|
};
|
|
|
|
return (
|
|
<Link href={`/dashboard/providers/${providerId}`} className="group min-w-0">
|
|
<Card
|
|
padding="xs"
|
|
className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${allDisabled ? "opacity-50" : ""}`}
|
|
>
|
|
<div className="flex min-w-0 items-center justify-between gap-3">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<div
|
|
className="size-8 shrink-0 rounded-lg flex items-center justify-center"
|
|
style={{
|
|
backgroundColor: `${provider.color?.length > 7 ? provider.color : provider.color + "15"}`,
|
|
}}
|
|
>
|
|
<ProviderIcon
|
|
src={getIconPath()}
|
|
alt={provider.name}
|
|
size={30}
|
|
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
|
|
fallbackText={
|
|
provider.textIcon || provider.id.slice(0, 2).toUpperCase()
|
|
}
|
|
fallbackColor={provider.color}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 className="truncate font-semibold">{provider.name}</h3>
|
|
<div className="flex min-w-0 items-center gap-1.5 text-xs flex-wrap">
|
|
{allDisabled ? (
|
|
<Badge variant="default" size="sm">
|
|
<span className="flex items-center gap-1">
|
|
<span className="material-symbols-outlined text-[12px]">
|
|
pause_circle
|
|
</span>
|
|
Disabled
|
|
</span>
|
|
</Badge>
|
|
) : (
|
|
<>
|
|
{getStatusDisplay(connected, error, errorCode)}
|
|
{isCompatible && (
|
|
<Badge variant="default" size="sm">
|
|
{provider.apiType === "responses"
|
|
? "Responses"
|
|
: "Chat"}
|
|
</Badge>
|
|
)}
|
|
{isAnthropicCompatible && (
|
|
<Badge variant="default" size="sm">
|
|
Messages
|
|
</Badge>
|
|
)}
|
|
{errorTime && (
|
|
<span className="text-text-muted">{errorTime}</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{stats.total > 0 && (
|
|
<div
|
|
className="opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onToggle(!allDisabled ? false : true);
|
|
}}
|
|
>
|
|
<Toggle
|
|
size="sm"
|
|
checked={!allDisabled}
|
|
onChange={() => {}}
|
|
title={allDisabled ? "Enable provider" : "Disable provider"}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
ApiKeyProviderCard.propTypes = {
|
|
providerId: PropTypes.string.isRequired,
|
|
provider: PropTypes.shape({
|
|
id: PropTypes.string.isRequired,
|
|
name: PropTypes.string.isRequired,
|
|
color: PropTypes.string,
|
|
textIcon: PropTypes.string,
|
|
apiType: PropTypes.string,
|
|
}).isRequired,
|
|
stats: PropTypes.shape({
|
|
connected: PropTypes.number,
|
|
error: PropTypes.number,
|
|
errorCode: PropTypes.string,
|
|
errorTime: PropTypes.string,
|
|
}).isRequired,
|
|
authType: PropTypes.string,
|
|
onToggle: PropTypes.func,
|
|
};
|
|
|
|
function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
|
const [formData, setFormData] = useState({
|
|
name: "",
|
|
prefix: "",
|
|
apiType: "chat",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
});
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [checkKey, setCheckKey] = useState("");
|
|
const [checkModelId, setCheckModelId] = useState("");
|
|
const [validating, setValidating] = useState(false);
|
|
const [validationResult, setValidationResult] = useState(null);
|
|
|
|
const apiTypeOptions = [
|
|
{ value: "chat", label: "Chat Completions" },
|
|
{ value: "responses", label: "Responses API" },
|
|
];
|
|
|
|
useEffect(() => {
|
|
const defaultBaseUrl = "https://api.openai.com/v1";
|
|
setFormData((prev) => ({ ...prev, baseUrl: defaultBaseUrl }));
|
|
}, [formData.apiType]);
|
|
|
|
const handleSubmit = async () => {
|
|
if (
|
|
!formData.name.trim() ||
|
|
!formData.prefix.trim() ||
|
|
!formData.baseUrl.trim()
|
|
)
|
|
return;
|
|
setSubmitting(true);
|
|
try {
|
|
const res = await fetch("/api/provider-nodes", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
name: formData.name,
|
|
prefix: formData.prefix,
|
|
apiType: formData.apiType,
|
|
baseUrl: formData.baseUrl,
|
|
type: "openai-compatible",
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
onCreated(data.node);
|
|
setFormData({
|
|
name: "",
|
|
prefix: "",
|
|
apiType: "chat",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
});
|
|
setCheckKey("");
|
|
setValidationResult(null);
|
|
}
|
|
} catch (error) {
|
|
console.log("Error creating OpenAI Compatible node:", error);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleValidate = async () => {
|
|
setValidating(true);
|
|
try {
|
|
const res = await fetch("/api/provider-nodes/validate", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
baseUrl: formData.baseUrl,
|
|
apiKey: checkKey,
|
|
type: "openai-compatible",
|
|
modelId: checkModelId.trim() || undefined,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
setValidationResult(data);
|
|
} catch {
|
|
setValidationResult({ valid: false, error: "Network error" });
|
|
} finally {
|
|
setValidating(false);
|
|
}
|
|
};
|
|
|
|
// Helper to render validation result
|
|
const renderValidationResult = () => {
|
|
if (!validationResult) return null;
|
|
const { valid, error, method } = validationResult;
|
|
|
|
if (valid) {
|
|
return (
|
|
<>
|
|
<Badge variant="success">Valid</Badge>
|
|
{method === "chat" && (
|
|
<span className="text-sm text-text-muted">
|
|
(via inference test)
|
|
</span>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<Badge variant="error">Invalid</Badge>
|
|
{error && <span className="text-sm text-red-500">{error}</span>}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Modal isOpen={isOpen} title="Add OpenAI Compatible" onClose={onClose}>
|
|
<div className="flex flex-col gap-4">
|
|
<Input
|
|
label="Name"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
placeholder="OpenAI Compatible (Prod)"
|
|
hint="Required. A friendly label for this node."
|
|
/>
|
|
<Input
|
|
label="Prefix"
|
|
value={formData.prefix}
|
|
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
|
|
placeholder="oc-prod"
|
|
hint="Required. Used as the provider prefix for model IDs."
|
|
/>
|
|
<Select
|
|
label="API Type"
|
|
options={apiTypeOptions}
|
|
value={formData.apiType}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, apiType: e.target.value })
|
|
}
|
|
/>
|
|
<Input
|
|
label="Base URL"
|
|
value={formData.baseUrl}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, baseUrl: e.target.value })
|
|
}
|
|
placeholder="https://api.openai.com/v1"
|
|
hint="Use the base URL (ending in /v1) for your OpenAI-compatible API."
|
|
/>
|
|
<Input
|
|
label="API Key (for Check)"
|
|
type="password"
|
|
value={checkKey}
|
|
onChange={(e) => setCheckKey(e.target.value)}
|
|
/>
|
|
<Input
|
|
label="Model ID (optional)"
|
|
value={checkModelId}
|
|
onChange={(e) => setCheckModelId(e.target.value)}
|
|
placeholder="e.g. gpt-4, claude-3-opus"
|
|
hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead."
|
|
/>
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
<Button
|
|
onClick={handleValidate}
|
|
disabled={!checkKey || validating || !formData.baseUrl.trim()}
|
|
variant="secondary"
|
|
className="w-full sm:w-auto"
|
|
>
|
|
{validating ? "Checking..." : "Check"}
|
|
</Button>
|
|
{renderValidationResult()}
|
|
</div>
|
|
<div className="flex flex-col gap-2 sm:flex-row">
|
|
<Button
|
|
onClick={handleSubmit}
|
|
fullWidth
|
|
disabled={
|
|
!formData.name.trim() ||
|
|
!formData.prefix.trim() ||
|
|
!formData.baseUrl.trim() ||
|
|
submitting
|
|
}
|
|
>
|
|
{submitting ? "Creating..." : "Create"}
|
|
</Button>
|
|
<Button onClick={onClose} variant="ghost" fullWidth>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
AddOpenAICompatibleModal.propTypes = {
|
|
isOpen: PropTypes.bool.isRequired,
|
|
onClose: PropTypes.func.isRequired,
|
|
onCreated: PropTypes.func.isRequired,
|
|
};
|
|
|
|
function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
|
const [formData, setFormData] = useState({
|
|
name: "",
|
|
prefix: "",
|
|
baseUrl: "https://api.anthropic.com/v1",
|
|
});
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [checkKey, setCheckKey] = useState("");
|
|
const [checkModelId, setCheckModelId] = useState("");
|
|
const [validating, setValidating] = useState(false);
|
|
const [validationResult, setValidationResult] = useState(null); // { valid, error, method }
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setValidationResult(null);
|
|
setCheckKey("");
|
|
setCheckModelId("");
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const handleSubmit = async () => {
|
|
if (
|
|
!formData.name.trim() ||
|
|
!formData.prefix.trim() ||
|
|
!formData.baseUrl.trim()
|
|
)
|
|
return;
|
|
setSubmitting(true);
|
|
try {
|
|
const res = await fetch("/api/provider-nodes", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
name: formData.name,
|
|
prefix: formData.prefix,
|
|
baseUrl: formData.baseUrl,
|
|
type: "anthropic-compatible",
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
onCreated(data.node);
|
|
setFormData({
|
|
name: "",
|
|
prefix: "",
|
|
baseUrl: "https://api.anthropic.com/v1",
|
|
});
|
|
setCheckKey("");
|
|
setValidationResult(null);
|
|
}
|
|
} catch (error) {
|
|
console.log("Error creating Anthropic Compatible node:", error);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleValidate = async () => {
|
|
setValidating(true);
|
|
try {
|
|
const res = await fetch("/api/provider-nodes/validate", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
baseUrl: formData.baseUrl,
|
|
apiKey: checkKey,
|
|
type: "anthropic-compatible",
|
|
modelId: checkModelId.trim() || undefined,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
setValidationResult(data);
|
|
} catch {
|
|
setValidationResult({ valid: false, error: "Network error" });
|
|
} finally {
|
|
setValidating(false);
|
|
}
|
|
};
|
|
|
|
// Helper to render validation result
|
|
const renderValidationResult = () => {
|
|
if (!validationResult) return null;
|
|
const { valid, error, method } = validationResult;
|
|
|
|
if (valid) {
|
|
return (
|
|
<>
|
|
<Badge variant="success">Valid</Badge>
|
|
{method === "chat" && (
|
|
<span className="text-sm text-text-muted">
|
|
(via inference test)
|
|
</span>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<Badge variant="error">Invalid</Badge>
|
|
{error && <span className="text-sm text-red-500">{error}</span>}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Modal isOpen={isOpen} title="Add Anthropic Compatible" onClose={onClose}>
|
|
<div className="flex flex-col gap-4">
|
|
<Input
|
|
label="Name"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
placeholder="Anthropic Compatible (Prod)"
|
|
hint="Required. A friendly label for this node."
|
|
/>
|
|
<Input
|
|
label="Prefix"
|
|
value={formData.prefix}
|
|
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
|
|
placeholder="ac-prod"
|
|
hint="Required. Used as the provider prefix for model IDs."
|
|
/>
|
|
<Input
|
|
label="Base URL"
|
|
value={formData.baseUrl}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, baseUrl: e.target.value })
|
|
}
|
|
placeholder="https://api.anthropic.com/v1"
|
|
hint="Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages."
|
|
/>
|
|
<Input
|
|
label="API Key (for Check)"
|
|
type="password"
|
|
value={checkKey}
|
|
onChange={(e) => setCheckKey(e.target.value)}
|
|
/>
|
|
<Input
|
|
label="Model ID (optional)"
|
|
value={checkModelId}
|
|
onChange={(e) => setCheckModelId(e.target.value)}
|
|
placeholder="e.g. claude-3-opus"
|
|
hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead."
|
|
/>
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
<Button
|
|
onClick={handleValidate}
|
|
disabled={!checkKey || validating || !formData.baseUrl.trim()}
|
|
variant="secondary"
|
|
className="w-full sm:w-auto"
|
|
>
|
|
{validating ? "Checking..." : "Check"}
|
|
</Button>
|
|
{renderValidationResult()}
|
|
</div>
|
|
<div className="flex flex-col gap-2 sm:flex-row">
|
|
<Button
|
|
onClick={handleSubmit}
|
|
fullWidth
|
|
disabled={
|
|
!formData.name.trim() ||
|
|
!formData.prefix.trim() ||
|
|
!formData.baseUrl.trim() ||
|
|
submitting
|
|
}
|
|
>
|
|
{submitting ? "Creating..." : "Create"}
|
|
</Button>
|
|
<Button onClick={onClose} variant="ghost" fullWidth>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
AddAnthropicCompatibleModal.propTypes = {
|
|
isOpen: PropTypes.bool.isRequired,
|
|
onClose: PropTypes.func.isRequired,
|
|
onCreated: PropTypes.func.isRequired,
|
|
};
|
|
|
|
function ProviderTestResultsView({ results }) {
|
|
if (results.error && !results.results) {
|
|
return (
|
|
<div className="text-center py-6">
|
|
<span className="material-symbols-outlined text-red-500 text-[32px] mb-2 block">
|
|
error
|
|
</span>
|
|
<p className="text-sm text-red-400">{results.error}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { summary, mode } = results;
|
|
const items = results.results || [];
|
|
const modeLabel =
|
|
{
|
|
oauth: "OAuth",
|
|
free: "Free",
|
|
apikey: "API Key",
|
|
provider: "Provider",
|
|
all: "All",
|
|
}[mode] || mode;
|
|
|
|
return (
|
|
<div className="flex min-w-0 flex-col gap-3">
|
|
{summary && (
|
|
<div className="flex flex-wrap items-center gap-2 text-xs mb-1 sm:gap-3">
|
|
<span className="text-text-muted">{modeLabel} Test</span>
|
|
<span className="px-2 py-0.5 rounded bg-emerald-500/15 text-emerald-400 font-medium">
|
|
{summary.passed} passed
|
|
</span>
|
|
{summary.failed > 0 && (
|
|
<span className="px-2 py-0.5 rounded bg-red-500/15 text-red-400 font-medium">
|
|
{summary.failed} failed
|
|
</span>
|
|
)}
|
|
<span className="text-text-muted sm:ml-auto">
|
|
{summary.total} tested
|
|
</span>
|
|
</div>
|
|
)}
|
|
{items.map((r, i) => (
|
|
<div
|
|
key={r.connectionId || i}
|
|
className="flex min-w-0 flex-wrap items-center gap-2 rounded-lg bg-black/[0.03] px-3 py-2 text-xs dark:bg-white/[0.03] sm:flex-nowrap"
|
|
>
|
|
<span
|
|
className={`material-symbols-outlined text-[16px] ${r.valid ? "text-emerald-500" : "text-red-500"}`}
|
|
>
|
|
{r.valid ? "check_circle" : "error"}
|
|
</span>
|
|
<div className="min-w-0 flex-[1_1_160px]">
|
|
<span className="block truncate font-medium sm:inline">
|
|
{r.connectionName}
|
|
</span>
|
|
<span className="block truncate text-text-muted sm:ml-1.5 sm:inline">
|
|
({r.provider})
|
|
</span>
|
|
</div>
|
|
{r.latencyMs !== undefined && (
|
|
<span className="shrink-0 text-text-muted font-mono tabular-nums">
|
|
{r.latencyMs}ms
|
|
</span>
|
|
)}
|
|
<span
|
|
className={`shrink-0 text-[10px] uppercase font-bold px-1.5 py-0.5 rounded ${
|
|
r.valid
|
|
? "bg-emerald-500/15 text-emerald-400"
|
|
: "bg-red-500/15 text-red-400"
|
|
}`}
|
|
>
|
|
{r.valid ? "OK" : r.diagnosis?.type || "ERROR"}
|
|
</span>
|
|
</div>
|
|
))}
|
|
{items.length === 0 && (
|
|
<div className="text-center py-4 text-text-muted text-sm">
|
|
No active connections found for this group.
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ProviderTestResultsView.propTypes = {
|
|
results: PropTypes.shape({
|
|
mode: PropTypes.string,
|
|
results: PropTypes.array,
|
|
summary: PropTypes.shape({
|
|
total: PropTypes.number,
|
|
passed: PropTypes.number,
|
|
failed: PropTypes.number,
|
|
}),
|
|
error: PropTypes.string,
|
|
}).isRequired,
|
|
};
|