mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
fix(ui): restore provider assets and model availability endpoint (#367)
* fix(usage): track lifetime request total beyond history cap * fix(ui): restore provider assets and model availability endpoint
This commit is contained in:
BIN
public/providers/assemblyai.png
Normal file
BIN
public/providers/assemblyai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/providers/deepgram.png
Normal file
BIN
public/providers/deepgram.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/providers/hyperbolic.png
Normal file
BIN
public/providers/hyperbolic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
BIN
public/providers/nanobanana.png
Normal file
BIN
public/providers/nanobanana.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
@@ -1,11 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import PropTypes from "prop-types";
|
||||
import { Card, CardSkeleton, Badge, Button, Input, Modal, Select, Toggle } from "@/shared/components";
|
||||
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, OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX } from "@/shared/constants/providers";
|
||||
import {
|
||||
FREE_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";
|
||||
@@ -17,15 +30,17 @@ function getStatusDisplay(connected, error, errorCode) {
|
||||
parts.push(
|
||||
<Badge key="connected" variant="success" size="sm" dot>
|
||||
{connected} Connected
|
||||
</Badge>
|
||||
</Badge>,
|
||||
);
|
||||
}
|
||||
if (error > 0) {
|
||||
const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`;
|
||||
const errText = errorCode
|
||||
? `${error} Error (${errorCode})`
|
||||
: `${error} Error`;
|
||||
parts.push(
|
||||
<Badge key="error" variant="error" size="sm" dot>
|
||||
{errText}
|
||||
</Badge>
|
||||
</Badge>,
|
||||
);
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
@@ -44,21 +59,34 @@ function getConnectionErrorTag(connection) {
|
||||
explicitType === "auth_missing" ||
|
||||
explicitType === "token_refresh_failed" ||
|
||||
explicitType === "token_expired"
|
||||
) return "AUTH";
|
||||
)
|
||||
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);
|
||||
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";
|
||||
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";
|
||||
}
|
||||
@@ -68,7 +96,8 @@ export default function ProvidersPage() {
|
||||
const [providerNodes, setProviderNodes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
|
||||
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false);
|
||||
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] =
|
||||
useState(false);
|
||||
const [testingMode, setTestingMode] = useState(null);
|
||||
const [testResults, setTestResults] = useState(null);
|
||||
const notify = useNotificationStore();
|
||||
@@ -82,7 +111,8 @@ export default function ProvidersPage() {
|
||||
]);
|
||||
const connectionsData = await connectionsRes.json();
|
||||
const nodesData = await nodesRes.json();
|
||||
if (connectionsRes.ok) setConnections(connectionsData.connections || []);
|
||||
if (connectionsRes.ok)
|
||||
setConnections(connectionsData.connections || []);
|
||||
if (nodesRes.ok) setProviderNodes(nodesData.nodes || []);
|
||||
} catch (error) {
|
||||
console.log("Error fetching data:", error);
|
||||
@@ -95,13 +125,17 @@ export default function ProvidersPage() {
|
||||
|
||||
const getProviderStats = (providerId, authType) => {
|
||||
const providerConnections = connections.filter(
|
||||
(c) => c.provider === providerId && c.authType === authType
|
||||
(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 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) => {
|
||||
@@ -111,18 +145,23 @@ export default function ProvidersPage() {
|
||||
|
||||
const errorConns = providerConnections.filter((c) => {
|
||||
const status = getEffectiveStatus(c);
|
||||
return status === "error" || status === "expired" || status === "unavailable";
|
||||
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 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)
|
||||
(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;
|
||||
const errorTime = latestError?.lastErrorAt
|
||||
? getRelativeTime(latestError.lastErrorAt)
|
||||
: null;
|
||||
|
||||
return { connected, error, total, errorCode, errorTime, allDisabled };
|
||||
};
|
||||
@@ -130,12 +169,14 @@ export default function ProvidersPage() {
|
||||
// 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
|
||||
(c) => c.provider === providerId && c.authType === authType,
|
||||
);
|
||||
setConnections((prev) =>
|
||||
prev.map((c) =>
|
||||
c.provider === providerId && c.authType === authType ? { ...c, isActive: newActive } : c
|
||||
)
|
||||
c.provider === providerId && c.authType === authType
|
||||
? { ...c, isActive: newActive }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
await Promise.allSettled(
|
||||
providerConns.map((c) =>
|
||||
@@ -143,8 +184,8 @@ export default function ProvidersPage() {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isActive: newActive }),
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -214,14 +255,17 @@ export default function ProvidersPage() {
|
||||
<button
|
||||
onClick={() => handleBatchTest("oauth")}
|
||||
disabled={!!testingMode}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${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"
|
||||
}`}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
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" : ""}`}>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px]${testingMode === "oauth" ? " animate-spin" : ""}`}
|
||||
>
|
||||
{testingMode === "oauth" ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{testingMode === "oauth" ? "Testing..." : "Test All"}
|
||||
@@ -251,14 +295,17 @@ export default function ProvidersPage() {
|
||||
<button
|
||||
onClick={() => handleBatchTest("free")}
|
||||
disabled={!!testingMode}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${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"
|
||||
}`}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
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" : ""}`}>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px]${testingMode === "free" ? " animate-spin" : ""}`}
|
||||
>
|
||||
{testingMode === "free" ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{testingMode === "free" ? "Testing..." : "Test All"}
|
||||
@@ -287,14 +334,17 @@ export default function ProvidersPage() {
|
||||
<button
|
||||
onClick={() => handleBatchTest("apikey")}
|
||||
disabled={!!testingMode}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${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"
|
||||
}`}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
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" : ""}`}>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px]${testingMode === "apikey" ? " animate-spin" : ""}`}
|
||||
>
|
||||
{testingMode === "apikey" ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{testingMode === "apikey" ? "Testing..." : "Test All"}
|
||||
@@ -337,7 +387,11 @@ export default function ProvidersPage() {
|
||||
{testingMode === "compatible" ? "Testing..." : "Test All"}
|
||||
</button>
|
||||
)} */}
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddAnthropicCompatibleModal(true)}>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="add"
|
||||
onClick={() => setShowAddAnthropicCompatibleModal(true)}
|
||||
>
|
||||
Add Anthropic Compatible
|
||||
</Button>
|
||||
<Button
|
||||
@@ -351,26 +405,36 @@ export default function ProvidersPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{compatibleProviders.length === 0 && anthropicCompatibleProviders.length === 0 ? (
|
||||
{compatibleProviders.length === 0 &&
|
||||
anthropicCompatibleProviders.length === 0 ? (
|
||||
<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">extension</span>
|
||||
<p className="text-text-muted text-sm">No compatible providers added yet</p>
|
||||
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2">
|
||||
extension
|
||||
</span>
|
||||
<p className="text-text-muted text-sm">
|
||||
No compatible providers added yet
|
||||
</p>
|
||||
<p className="text-text-muted text-xs mt-1">
|
||||
Use the buttons above to add OpenAI or Anthropic compatible endpoints
|
||||
Use the buttons above to add OpenAI or Anthropic compatible
|
||||
endpoints
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-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)}
|
||||
/>
|
||||
))}
|
||||
{[...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>
|
||||
@@ -425,7 +489,6 @@ export default function ProvidersPage() {
|
||||
|
||||
function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
const { connected, error, errorCode, errorTime, allDisabled } = stats;
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const dotColors = {
|
||||
free: "bg-green-500",
|
||||
@@ -433,7 +496,12 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
apikey: "bg-amber-500",
|
||||
compatible: "bg-orange-500",
|
||||
};
|
||||
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
|
||||
const dotLabels = {
|
||||
free: "Free",
|
||||
oauth: "OAuth",
|
||||
apikey: "API Key",
|
||||
compatible: "Compatible",
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/providers/${providerId}`} className="group">
|
||||
@@ -445,40 +513,39 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="size-8 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${provider.color?.length > 7 ? provider.color : provider.color + "15"}` }}
|
||||
style={{
|
||||
backgroundColor: `${provider.color?.length > 7 ? provider.color : provider.color + "15"}`,
|
||||
}}
|
||||
>
|
||||
{imgError ? (
|
||||
<span className="text-xs font-bold" style={{ color: provider.color }}>
|
||||
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={`/providers/${provider.id}.png`}
|
||||
alt={provider.name}
|
||||
width={30}
|
||||
height={30}
|
||||
className="object-contain rounded-lg max-w-[32px] max-h-[32px]"
|
||||
sizes="32px"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
<h3 className="font-semibold">
|
||||
{provider.name}
|
||||
</h3>
|
||||
<h3 className="font-semibold">{provider.name}</h3>
|
||||
<div className="flex items-center gap-2 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>
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
pause_circle
|
||||
</span>
|
||||
Disabled
|
||||
</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
{getStatusDisplay(connected, error, errorCode)}
|
||||
{errorTime && <span className="text-text-muted">{errorTime}</span>}
|
||||
{errorTime && (
|
||||
<span className="text-text-muted">{errorTime}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -497,7 +564,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={!allDisabled}
|
||||
onChange={() => { }}
|
||||
onChange={() => {}}
|
||||
title={allDisabled ? "Enable provider" : "Disable provider"}
|
||||
/>
|
||||
</div>
|
||||
@@ -527,11 +594,18 @@ ProviderCard.propTypes = {
|
||||
onToggle: PropTypes.func,
|
||||
};
|
||||
|
||||
function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
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 [imgError, setImgError] = useState(false);
|
||||
const isAnthropicCompatible = providerId.startsWith(
|
||||
ANTHROPIC_COMPATIBLE_PREFIX,
|
||||
);
|
||||
|
||||
const dotColors = {
|
||||
free: "bg-green-500",
|
||||
@@ -539,10 +613,18 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
apikey: "bg-amber-500",
|
||||
compatible: "bg-orange-500",
|
||||
};
|
||||
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
|
||||
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 (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`;
|
||||
};
|
||||
@@ -557,33 +639,30 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="size-8 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${provider.color?.length > 7 ? provider.color : provider.color + "15"}` }}
|
||||
style={{
|
||||
backgroundColor: `${provider.color?.length > 7 ? provider.color : provider.color + "15"}`,
|
||||
}}
|
||||
>
|
||||
{imgError ? (
|
||||
<span className="text-xs font-bold" style={{ color: provider.color }}>
|
||||
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={getIconPath()}
|
||||
alt={provider.name}
|
||||
width={30}
|
||||
height={30}
|
||||
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
|
||||
sizes="30px"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
<h3 className="font-semibold">
|
||||
{provider.name}
|
||||
</h3>
|
||||
<h3 className="font-semibold">{provider.name}</h3>
|
||||
<div className="flex items-center gap-2 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>
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
pause_circle
|
||||
</span>
|
||||
Disabled
|
||||
</span>
|
||||
</Badge>
|
||||
@@ -592,13 +671,19 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
{getStatusDisplay(connected, error, errorCode)}
|
||||
{isCompatible && (
|
||||
<Badge variant="default" size="sm">
|
||||
{provider.apiType === "responses" ? "Responses" : "Chat"}
|
||||
{provider.apiType === "responses"
|
||||
? "Responses"
|
||||
: "Chat"}
|
||||
</Badge>
|
||||
)}
|
||||
{isAnthropicCompatible && (
|
||||
<Badge variant="default" size="sm">Messages</Badge>
|
||||
<Badge variant="default" size="sm">
|
||||
Messages
|
||||
</Badge>
|
||||
)}
|
||||
{errorTime && (
|
||||
<span className="text-text-muted">{errorTime}</span>
|
||||
)}
|
||||
{errorTime && <span className="text-text-muted">{errorTime}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -617,7 +702,7 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={!allDisabled}
|
||||
onChange={() => { }}
|
||||
onChange={() => {}}
|
||||
title={allDisabled ? "Enable provider" : "Disable provider"}
|
||||
/>
|
||||
</div>
|
||||
@@ -672,7 +757,12 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
}, [formData.apiType]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return;
|
||||
if (
|
||||
!formData.name.trim() ||
|
||||
!formData.prefix.trim() ||
|
||||
!formData.baseUrl.trim()
|
||||
)
|
||||
return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/provider-nodes", {
|
||||
@@ -689,7 +779,12 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
onCreated(data.node);
|
||||
setFormData({ name: "", prefix: "", apiType: "chat", baseUrl: "https://api.openai.com/v1" });
|
||||
setFormData({
|
||||
name: "",
|
||||
prefix: "",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
});
|
||||
setCheckKey("");
|
||||
setValidationResult(null);
|
||||
}
|
||||
@@ -710,7 +805,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
baseUrl: formData.baseUrl,
|
||||
apiKey: checkKey,
|
||||
type: "openai-compatible",
|
||||
modelId: checkModelId.trim() || undefined
|
||||
modelId: checkModelId.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -731,7 +826,11 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
return (
|
||||
<>
|
||||
<Badge variant="success">Valid</Badge>
|
||||
{method === "chat" && <span className="text-sm text-text-muted">(via inference test)</span>}
|
||||
{method === "chat" && (
|
||||
<span className="text-sm text-text-muted">
|
||||
(via inference test)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -764,12 +863,16 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
label="API Type"
|
||||
options={apiTypeOptions}
|
||||
value={formData.apiType}
|
||||
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, apiType: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="Base URL"
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
|
||||
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."
|
||||
/>
|
||||
@@ -787,16 +890,31 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead."
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={handleValidate} disabled={!checkKey || validating || !formData.baseUrl.trim()} variant="secondary">
|
||||
<Button
|
||||
onClick={handleValidate}
|
||||
disabled={!checkKey || validating || !formData.baseUrl.trim()}
|
||||
variant="secondary"
|
||||
>
|
||||
{validating ? "Checking..." : "Check"}
|
||||
</Button>
|
||||
{renderValidationResult()}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSubmit} fullWidth disabled={!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim() || submitting}>
|
||||
<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>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -830,7 +948,12 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return;
|
||||
if (
|
||||
!formData.name.trim() ||
|
||||
!formData.prefix.trim() ||
|
||||
!formData.baseUrl.trim()
|
||||
)
|
||||
return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/provider-nodes", {
|
||||
@@ -846,7 +969,11 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
onCreated(data.node);
|
||||
setFormData({ name: "", prefix: "", baseUrl: "https://api.anthropic.com/v1" });
|
||||
setFormData({
|
||||
name: "",
|
||||
prefix: "",
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
});
|
||||
setCheckKey("");
|
||||
setValidationResult(null);
|
||||
}
|
||||
@@ -867,7 +994,7 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
baseUrl: formData.baseUrl,
|
||||
apiKey: checkKey,
|
||||
type: "anthropic-compatible",
|
||||
modelId: checkModelId.trim() || undefined
|
||||
modelId: checkModelId.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -888,7 +1015,11 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
return (
|
||||
<>
|
||||
<Badge variant="success">Valid</Badge>
|
||||
{method === "chat" && <span className="text-sm text-text-muted">(via inference test)</span>}
|
||||
{method === "chat" && (
|
||||
<span className="text-sm text-text-muted">
|
||||
(via inference test)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -920,7 +1051,9 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
<Input
|
||||
label="Base URL"
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
|
||||
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."
|
||||
/>
|
||||
@@ -938,16 +1071,31 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead."
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={handleValidate} disabled={!checkKey || validating || !formData.baseUrl.trim()} variant="secondary">
|
||||
<Button
|
||||
onClick={handleValidate}
|
||||
disabled={!checkKey || validating || !formData.baseUrl.trim()}
|
||||
variant="secondary"
|
||||
>
|
||||
{validating ? "Checking..." : "Check"}
|
||||
</Button>
|
||||
{renderValidationResult()}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSubmit} fullWidth disabled={!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim() || submitting}>
|
||||
<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>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -964,7 +1112,9 @@ 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>
|
||||
<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>
|
||||
);
|
||||
@@ -972,7 +1122,14 @@ function ProviderTestResultsView({ results }) {
|
||||
|
||||
const { summary, mode } = results;
|
||||
const items = results.results || [];
|
||||
const modeLabel = { oauth: "OAuth", free: "Free", apikey: "API Key", provider: "Provider", all: "All" }[mode] || mode;
|
||||
const modeLabel =
|
||||
{
|
||||
oauth: "OAuth",
|
||||
free: "Free",
|
||||
apikey: "API Key",
|
||||
provider: "Provider",
|
||||
all: "All",
|
||||
}[mode] || mode;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -987,7 +1144,9 @@ function ProviderTestResultsView({ results }) {
|
||||
{summary.failed} failed
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-muted ml-auto">{summary.total} tested</span>
|
||||
<span className="text-text-muted ml-auto">
|
||||
{summary.total} tested
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{items.map((r, i) => (
|
||||
@@ -995,7 +1154,9 @@ function ProviderTestResultsView({ results }) {
|
||||
key={r.connectionId || i}
|
||||
className="flex items-center gap-2 text-xs px-3 py-2 rounded-lg bg-black/[0.03] dark:bg-white/[0.03]"
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[16px] ${r.valid ? "text-emerald-500" : "text-red-500"}`}>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] ${r.valid ? "text-emerald-500" : "text-red-500"}`}
|
||||
>
|
||||
{r.valid ? "check_circle" : "error"}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -1003,11 +1164,16 @@ function ProviderTestResultsView({ results }) {
|
||||
<span className="text-text-muted ml-1.5">({r.provider})</span>
|
||||
</div>
|
||||
{r.latencyMs !== undefined && (
|
||||
<span className="text-text-muted font-mono tabular-nums">{r.latencyMs}ms</span>
|
||||
<span className="text-text-muted font-mono tabular-nums">
|
||||
{r.latencyMs}ms
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`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"
|
||||
}`}
|
||||
className={`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>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Card from "@/shared/components/Card";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import Badge from "@/shared/components/Badge";
|
||||
import QuotaProgressBar from "./QuotaProgressBar";
|
||||
import { calculatePercentage } from "./utils";
|
||||
@@ -25,11 +25,10 @@ export default function ProviderLimitCard({
|
||||
onRefresh,
|
||||
}) {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!onRefresh || refreshing) return;
|
||||
|
||||
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
@@ -63,28 +62,20 @@ export default function ProviderLimitCard({
|
||||
className="size-10 rounded-lg flex items-center justify-center p-1.5"
|
||||
style={{ backgroundColor: `${providerColor}15` }}
|
||||
>
|
||||
{imgError ? (
|
||||
<span
|
||||
className="text-sm font-bold"
|
||||
style={{ color: providerColor }}
|
||||
>
|
||||
{provider?.slice(0, 2).toUpperCase() || "PR"}
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={`/providers/${provider}.png`}
|
||||
alt={provider || "Provider"}
|
||||
width={40}
|
||||
height={40}
|
||||
className="object-contain rounded-lg"
|
||||
sizes="40px"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
<ProviderIcon
|
||||
src={`/providers/${provider}.png`}
|
||||
alt={provider || "Provider"}
|
||||
size={40}
|
||||
className="object-contain rounded-lg"
|
||||
fallbackText={provider?.slice(0, 2).toUpperCase() || "PR"}
|
||||
fallbackColor={providerColor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary">{name || provider}</h3>
|
||||
<h3 className="font-semibold text-text-primary">
|
||||
{name || provider}
|
||||
</h3>
|
||||
{plan && (
|
||||
<Badge
|
||||
variant={planVariants[plan?.toLowerCase()] || "default"}
|
||||
@@ -146,7 +137,9 @@ export default function ProviderLimitCard({
|
||||
<span className="material-symbols-outlined text-blue-500 text-[20px]">
|
||||
info
|
||||
</span>
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">{message}</p>
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -156,11 +149,12 @@ export default function ProviderLimitCard({
|
||||
<div className="space-y-4">
|
||||
{quotas.map((quota, index) => {
|
||||
// For Antigravity, use remainingPercentage if available, otherwise calculate
|
||||
const percentage = quota.remainingPercentage !== undefined
|
||||
? Math.round((quota.total - quota.used) / quota.total * 100)
|
||||
: calculatePercentage(quota.used, quota.total);
|
||||
const percentage =
|
||||
quota.remainingPercentage !== undefined
|
||||
? Math.round(((quota.total - quota.used) / quota.total) * 100)
|
||||
: calculatePercentage(quota.used, quota.total);
|
||||
const unlimited = quota.total === 0 || quota.total === null;
|
||||
|
||||
|
||||
return (
|
||||
<QuotaProgressBar
|
||||
key={`${quota.name}-${index}`}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import ProviderLimitCard from "./ProviderLimitCard";
|
||||
import QuotaTable from "./QuotaTable";
|
||||
import { parseQuotaData, calculatePercentage } from "./utils";
|
||||
@@ -30,7 +30,7 @@ export default function ProviderLimits() {
|
||||
try {
|
||||
const response = await fetch("/api/providers/client");
|
||||
if (!response.ok) throw new Error("Failed to fetch connections");
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
const connectionList = data.connections || [];
|
||||
setConnections(connectionList);
|
||||
@@ -48,23 +48,30 @@ export default function ProviderLimits() {
|
||||
setErrors((prev) => ({ ...prev, [connectionId]: null }));
|
||||
|
||||
try {
|
||||
console.log(`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`);
|
||||
console.log(
|
||||
`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`,
|
||||
);
|
||||
const response = await fetch(`/api/usage/${connectionId}`);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorMsg = errorData.error || response.statusText;
|
||||
|
||||
|
||||
// Handle different error types gracefully
|
||||
if (response.status === 404) {
|
||||
// Connection not found - skip silently
|
||||
console.warn(`[ProviderLimits] Connection not found for ${provider}, skipping`);
|
||||
console.warn(
|
||||
`[ProviderLimits] Connection not found for ${provider}, skipping`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (response.status === 401) {
|
||||
// Auth error - show message instead of throwing
|
||||
console.warn(`[ProviderLimits] Auth error for ${provider}:`, errorMsg);
|
||||
console.warn(
|
||||
`[ProviderLimits] Auth error for ${provider}:`,
|
||||
errorMsg,
|
||||
);
|
||||
setQuotaData((prev) => ({
|
||||
...prev,
|
||||
[connectionId]: {
|
||||
@@ -74,16 +81,16 @@ export default function ProviderLimits() {
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
throw new Error(`HTTP ${response.status}: ${errorMsg}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(`[ProviderLimits] Got quota for ${provider}:`, data);
|
||||
|
||||
|
||||
// Parse quota data using provider-specific parser
|
||||
const parsedQuotas = parseQuotaData(provider, data);
|
||||
|
||||
|
||||
setQuotaData((prev) => ({
|
||||
...prev,
|
||||
[connectionId]: {
|
||||
@@ -94,7 +101,10 @@ export default function ProviderLimits() {
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`[ProviderLimits] Error fetching quota for ${provider} (${connectionId}):`, error);
|
||||
console.error(
|
||||
`[ProviderLimits] Error fetching quota for ${provider} (${connectionId}):`,
|
||||
error,
|
||||
);
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
[connectionId]: error.message || "Failed to fetch quota",
|
||||
@@ -110,7 +120,7 @@ export default function ProviderLimits() {
|
||||
await fetchQuota(connectionId, provider);
|
||||
setLastUpdated(new Date());
|
||||
},
|
||||
[fetchQuota]
|
||||
[fetchQuota],
|
||||
);
|
||||
|
||||
// Refresh all providers
|
||||
@@ -122,15 +132,17 @@ export default function ProviderLimits() {
|
||||
|
||||
try {
|
||||
const conns = await fetchConnections();
|
||||
|
||||
|
||||
// Filter only supported OAuth providers
|
||||
const oauthConnections = conns.filter(
|
||||
(conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
|
||||
(conn) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
|
||||
conn.authType === "oauth",
|
||||
);
|
||||
|
||||
|
||||
// Fetch quota for supported OAuth connections only
|
||||
await Promise.all(
|
||||
oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider))
|
||||
oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)),
|
||||
);
|
||||
|
||||
setLastUpdated(new Date());
|
||||
@@ -149,16 +161,20 @@ export default function ProviderLimits() {
|
||||
setConnectionsLoading(false);
|
||||
|
||||
const oauthConnections = conns.filter(
|
||||
(conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
|
||||
(conn) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
|
||||
conn.authType === "oauth",
|
||||
);
|
||||
|
||||
// Mark all as loading before fetching
|
||||
const loadingState = {};
|
||||
oauthConnections.forEach((conn) => { loadingState[conn.id] = true; });
|
||||
oauthConnections.forEach((conn) => {
|
||||
loadingState[conn.id] = true;
|
||||
});
|
||||
setLoading(loadingState);
|
||||
|
||||
await Promise.all(
|
||||
oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider))
|
||||
oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)),
|
||||
);
|
||||
setLastUpdated(new Date());
|
||||
};
|
||||
@@ -243,8 +259,10 @@ export default function ProviderLimits() {
|
||||
}, [lastUpdated]);
|
||||
|
||||
// Filter only supported providers
|
||||
const filteredConnections = connections.filter((conn) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
|
||||
const filteredConnections = connections.filter(
|
||||
(conn) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
|
||||
conn.authType === "oauth",
|
||||
);
|
||||
|
||||
// Sort providers by USAGE_SUPPORTED_PROVIDERS order, then alphabetically
|
||||
@@ -258,18 +276,18 @@ export default function ProviderLimits() {
|
||||
// Calculate summary stats
|
||||
const totalProviders = sortedConnections.length;
|
||||
const activeWithLimits = Object.values(quotaData).filter(
|
||||
(data) => data?.quotas?.length > 0
|
||||
(data) => data?.quotas?.length > 0,
|
||||
).length;
|
||||
|
||||
|
||||
// Count low quotas (remaining < 30%)
|
||||
const lowQuotasCount = Object.values(quotaData).reduce((count, data) => {
|
||||
if (!data?.quotas) return count;
|
||||
|
||||
|
||||
const hasLowQuota = data.quotas.some((quota) => {
|
||||
const percentage = calculatePercentage(quota.used, quota.total);
|
||||
return percentage < 30 && quota.total > 0;
|
||||
});
|
||||
|
||||
|
||||
return count + (hasLowQuota ? 1 : 0);
|
||||
}, 0);
|
||||
|
||||
@@ -285,7 +303,8 @@ export default function ProviderLimits() {
|
||||
No Providers Connected
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-text-muted max-w-md mx-auto">
|
||||
Connect to providers with OAuth to track your API quota limits and usage.
|
||||
Connect to providers with OAuth to track your API quota limits and
|
||||
usage.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -353,13 +372,14 @@ export default function ProviderLimits() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
<ProviderIcon
|
||||
src={`/providers/${conn.provider}.png`}
|
||||
alt={conn.provider}
|
||||
width={40}
|
||||
height={40}
|
||||
size={40}
|
||||
className="object-contain"
|
||||
sizes="40px"
|
||||
fallbackText={
|
||||
conn.provider?.slice(0, 2).toUpperCase() || "PR"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -371,14 +391,16 @@ export default function ProviderLimits() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
onClick={() => refreshProvider(conn.id, conn.provider)}
|
||||
disabled={isLoading}
|
||||
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
|
||||
title="Refresh quota"
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[20px] text-text-muted ${isLoading ? "animate-spin" : ""}`}>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[20px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
|
||||
>
|
||||
refresh
|
||||
</span>
|
||||
</button>
|
||||
|
||||
103
src/app/api/models/availability/route.js
Normal file
103
src/app/api/models/availability/route.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getProviderConnections,
|
||||
updateProviderConnection,
|
||||
} from "@/lib/localDb";
|
||||
|
||||
const MODEL_LOCK_PREFIX = "modelLock_";
|
||||
|
||||
function getActiveModelLocks(connection) {
|
||||
const now = Date.now();
|
||||
return Object.entries(connection)
|
||||
.filter(([key, value]) => key.startsWith(MODEL_LOCK_PREFIX) && value)
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
model: key.slice(MODEL_LOCK_PREFIX.length) || "__all",
|
||||
until: value,
|
||||
active: new Date(value).getTime() > now,
|
||||
}))
|
||||
.filter((lock) => lock.active);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const connections = await getProviderConnections();
|
||||
const models = [];
|
||||
|
||||
for (const connection of connections) {
|
||||
const locks = getActiveModelLocks(connection);
|
||||
for (const lock of locks) {
|
||||
models.push({
|
||||
provider: connection.provider,
|
||||
model: lock.model,
|
||||
status: "cooldown",
|
||||
until: lock.until,
|
||||
connectionId: connection.id,
|
||||
connectionName: connection.name || connection.email || connection.id,
|
||||
lastError: connection.lastError || null,
|
||||
});
|
||||
}
|
||||
|
||||
if (locks.length === 0 && connection.testStatus === "unavailable") {
|
||||
models.push({
|
||||
provider: connection.provider,
|
||||
model: "__all",
|
||||
status: "unavailable",
|
||||
connectionId: connection.id,
|
||||
connectionName: connection.name || connection.email || connection.id,
|
||||
lastError: connection.lastError || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
models,
|
||||
unavailableCount: models.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API] Failed to get model availability:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch model availability" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { action, provider, model } = await request.json();
|
||||
|
||||
if (action !== "clearCooldown" || !provider || !model) {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const connections = await getProviderConnections({ provider });
|
||||
const lockKey = `${MODEL_LOCK_PREFIX}${model}`;
|
||||
|
||||
await Promise.all(
|
||||
connections
|
||||
.filter((connection) => connection[lockKey])
|
||||
.map((connection) =>
|
||||
updateProviderConnection(connection.id, {
|
||||
[lockKey]: null,
|
||||
...(connection.testStatus === "unavailable"
|
||||
? {
|
||||
testStatus: "active",
|
||||
lastError: null,
|
||||
lastErrorAt: null,
|
||||
backoffLevel: 0,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error("[API] Failed to clear model cooldown:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to clear cooldown" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
|
||||
const CLI_TOOLS = [
|
||||
{ id: "claude", name: "Claude Code", image: "/providers/claude.png" },
|
||||
@@ -10,10 +10,30 @@ const CLI_TOOLS = [
|
||||
];
|
||||
|
||||
const PROVIDERS = [
|
||||
{ id: "openai", name: "OpenAI", color: "bg-emerald-500", textColor: "text-white" },
|
||||
{ id: "anthropic", name: "Anthropic", color: "bg-orange-400", textColor: "text-white" },
|
||||
{ id: "gemini", name: "Gemini", color: "bg-blue-500", textColor: "text-white" },
|
||||
{ id: "github", name: "GitHub Copilot", color: "bg-gray-700", textColor: "text-white" },
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
color: "bg-emerald-500",
|
||||
textColor: "text-white",
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
color: "bg-orange-400",
|
||||
textColor: "text-white",
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
name: "Gemini",
|
||||
color: "bg-blue-500",
|
||||
textColor: "text-white",
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub Copilot",
|
||||
color: "bg-gray-700",
|
||||
textColor: "text-white",
|
||||
},
|
||||
];
|
||||
|
||||
export default function FlowAnimation() {
|
||||
@@ -30,26 +50,29 @@ export default function FlowAnimation() {
|
||||
<div className="mt-16 w-full max-w-4xl relative h-[360px] hidden md:flex items-center justify-center animate-[float_6s_ease-in-out_infinite]">
|
||||
{/* 9Router Hub - Center */}
|
||||
<div className="relative z-20 w-32 h-32 rounded-full bg-[#23180f] border-2 border-[#f97815] shadow-[0_0_40px_rgba(249,120,21,0.3)] flex flex-col items-center justify-center gap-1 group cursor-pointer hover:scale-105 transition-transform duration-500">
|
||||
<span className="material-symbols-outlined text-4xl text-[#f97815]">hub</span>
|
||||
<span className="text-xs font-bold text-white tracking-widest uppercase">9Router</span>
|
||||
<span className="material-symbols-outlined text-4xl text-[#f97815]">
|
||||
hub
|
||||
</span>
|
||||
<span className="text-xs font-bold text-white tracking-widest uppercase">
|
||||
9Router
|
||||
</span>
|
||||
<div className="absolute inset-0 rounded-full border border-[#f97815]/30 animate-ping opacity-20"></div>
|
||||
</div>
|
||||
|
||||
{/* CLI Tools - Left side */}
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 flex flex-col gap-7">
|
||||
{CLI_TOOLS.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center gap-3 opacity-70 hover:opacity-100 transition-opacity group"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-2xl bg-[#23180f] border border-[#3a2f27] flex items-center justify-center overflow-hidden p-2 hover:border-[#f97815]/50 transition-all hover:scale-105">
|
||||
<Image
|
||||
src={tool.image}
|
||||
<ProviderIcon
|
||||
src={tool.image}
|
||||
alt={tool.name}
|
||||
width={48}
|
||||
height={48}
|
||||
size={48}
|
||||
className="object-contain rounded-xl max-w-[48px] max-h-[48px]"
|
||||
sizes="48px"
|
||||
fallbackText={tool.name.slice(0, 2).toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,40 +80,70 @@ export default function FlowAnimation() {
|
||||
</div>
|
||||
|
||||
{/* SVG Lines from CLI to 9Router */}
|
||||
<svg className="absolute inset-0 w-full h-full z-10 pointer-events-none stroke-yellow-700" xmlns="http://www.w3.org/2000/svg">
|
||||
<path className="animate-[dash_2s_linear_infinite]" d="M 60 50 C 250 70, 250 180, 360 180" fill="none" strokeDasharray="5,5" strokeWidth="2"></path>
|
||||
<path className="animate-[dash_2s_linear_infinite]" d="M 60 140 C 250 140, 250 180, 360 180" fill="none" strokeDasharray="5,5" strokeWidth="2"></path>
|
||||
<path className="animate-[dash_2s_linear_infinite]" d="M 60 210 C 250 210, 250 180, 360 180" fill="none" strokeDasharray="5,5" strokeWidth="2"></path>
|
||||
<path className="animate-[dash_2s_linear_infinite]" d="M 60 300 C 250 280, 250 180, 360 180" fill="none" strokeDasharray="5,5" strokeWidth="2"></path>
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full z-10 pointer-events-none stroke-yellow-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
className="animate-[dash_2s_linear_infinite]"
|
||||
d="M 60 50 C 250 70, 250 180, 360 180"
|
||||
fill="none"
|
||||
strokeDasharray="5,5"
|
||||
strokeWidth="2"
|
||||
></path>
|
||||
<path
|
||||
className="animate-[dash_2s_linear_infinite]"
|
||||
d="M 60 140 C 250 140, 250 180, 360 180"
|
||||
fill="none"
|
||||
strokeDasharray="5,5"
|
||||
strokeWidth="2"
|
||||
></path>
|
||||
<path
|
||||
className="animate-[dash_2s_linear_infinite]"
|
||||
d="M 60 210 C 250 210, 250 180, 360 180"
|
||||
fill="none"
|
||||
strokeDasharray="5,5"
|
||||
strokeWidth="2"
|
||||
></path>
|
||||
<path
|
||||
className="animate-[dash_2s_linear_infinite]"
|
||||
d="M 60 300 C 250 280, 250 180, 360 180"
|
||||
fill="none"
|
||||
strokeDasharray="5,5"
|
||||
strokeWidth="2"
|
||||
></path>
|
||||
</svg>
|
||||
|
||||
{/* SVG Lines from 9Router to Providers */}
|
||||
<svg className="absolute inset-0 w-full h-full z-10 pointer-events-none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M 440 180 C 550 180, 550 50, 740 50"
|
||||
fill="none"
|
||||
stroke={activeFlow === 0 ? "#f97815" : "rgb(75, 85, 99)"}
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full z-10 pointer-events-none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M 440 180 C 550 180, 550 50, 740 50"
|
||||
fill="none"
|
||||
stroke={activeFlow === 0 ? "#f97815" : "rgb(75, 85, 99)"}
|
||||
strokeWidth={activeFlow === 0 ? "3" : "2"}
|
||||
className={activeFlow === 0 ? "animate-pulse" : ""}
|
||||
></path>
|
||||
<path
|
||||
d="M 440 180 C 550 180, 550 130, 740 130"
|
||||
fill="none"
|
||||
stroke={activeFlow === 1 ? "#f97815" : "rgb(75, 85, 99)"}
|
||||
<path
|
||||
d="M 440 180 C 550 180, 550 130, 740 130"
|
||||
fill="none"
|
||||
stroke={activeFlow === 1 ? "#f97815" : "rgb(75, 85, 99)"}
|
||||
strokeWidth={activeFlow === 1 ? "3" : "2"}
|
||||
className={activeFlow === 1 ? "animate-pulse" : ""}
|
||||
></path>
|
||||
<path
|
||||
d="M 440 180 C 550 180, 550 230, 740 230"
|
||||
fill="none"
|
||||
stroke={activeFlow === 2 ? "#f97815" : "rgb(75, 85, 99)"}
|
||||
<path
|
||||
d="M 440 180 C 550 180, 550 230, 740 230"
|
||||
fill="none"
|
||||
stroke={activeFlow === 2 ? "#f97815" : "rgb(75, 85, 99)"}
|
||||
strokeWidth={activeFlow === 2 ? "3" : "2"}
|
||||
className={activeFlow === 2 ? "animate-pulse" : ""}
|
||||
></path>
|
||||
<path
|
||||
d="M 440 180 C 550 180, 550 310, 740 310"
|
||||
fill="none"
|
||||
stroke={activeFlow === 3 ? "#f97815" : "rgb(75, 85, 99)"}
|
||||
<path
|
||||
d="M 440 180 C 550 180, 550 310, 740 310"
|
||||
fill="none"
|
||||
stroke={activeFlow === 3 ? "#f97815" : "rgb(75, 85, 99)"}
|
||||
strokeWidth={activeFlow === 3 ? "3" : "2"}
|
||||
className={activeFlow === 3 ? "animate-pulse" : ""}
|
||||
></path>
|
||||
@@ -99,7 +152,7 @@ export default function FlowAnimation() {
|
||||
{/* AI Providers - Right side */}
|
||||
<div className="absolute right-0 top-0 bottom-0 flex flex-col justify-between py-6">
|
||||
{PROVIDERS.map((provider, idx) => (
|
||||
<div
|
||||
<div
|
||||
key={provider.id}
|
||||
className={`px-4 py-2 rounded-lg ${provider.color} ${provider.textColor} flex items-center justify-center font-bold text-xs shadow-lg hover:scale-110 transition-all cursor-help min-w-[140px] ${
|
||||
activeFlow === idx ? "ring-4 ring-[#f97815]/50 scale-110" : ""
|
||||
@@ -113,9 +166,10 @@ export default function FlowAnimation() {
|
||||
|
||||
{/* Mobile fallback */}
|
||||
<div className="md:hidden mt-8 w-full p-4 rounded-lg bg-[#23180f] border border-[#3a2f27]">
|
||||
<p className="text-sm text-center text-gray-400">Interactive diagram visible on desktop</p>
|
||||
<p className="text-sm text-center text-gray-400">
|
||||
Interactive diagram visible on desktop
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,49 +3,104 @@
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import PropTypes from "prop-types";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { ThemeToggle, LanguageSwitcher } from "@/shared/components";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import { translate } from "@/i18n/runtime";
|
||||
|
||||
const getPageInfo = (pathname) => {
|
||||
if (!pathname) return { title: "", description: "", breadcrumbs: [] };
|
||||
|
||||
|
||||
// Provider detail page: /dashboard/providers/[id]
|
||||
const providerMatch = pathname.match(/\/providers\/([^/]+)$/);
|
||||
if (providerMatch) {
|
||||
const providerId = providerMatch[1];
|
||||
const providerInfo = OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId];
|
||||
const providerInfo =
|
||||
OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId];
|
||||
if (providerInfo) {
|
||||
return {
|
||||
title: providerInfo.name,
|
||||
description: "",
|
||||
breadcrumbs: [
|
||||
{ label: "Providers", href: "/dashboard/providers" },
|
||||
{ label: providerInfo.name, image: `/providers/${providerInfo.id}.png` }
|
||||
]
|
||||
{
|
||||
label: providerInfo.name,
|
||||
image: `/providers/${providerInfo.id}.png`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname.includes("/providers")) return { title: "Providers", description: "Manage your AI provider connections", breadcrumbs: [] };
|
||||
if (pathname.includes("/combos")) return { title: "Combos", description: "Model combos with fallback", breadcrumbs: [] };
|
||||
if (pathname.includes("/usage")) return { title: "Usage & Analytics", description: "Monitor your API usage, token consumption, and request logs", breadcrumbs: [] };
|
||||
if (pathname.includes("/mitm")) return { title: "MITM Proxy", description: "Intercept CLI tool traffic and route through 9Router", breadcrumbs: [] };
|
||||
if (pathname.includes("/cli-tools")) return { title: "CLI Tools", description: "Configure CLI tools", breadcrumbs: [] };
|
||||
if (pathname.includes("/endpoint")) return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] };
|
||||
if (pathname.includes("/profile")) return { title: "Settings", description: "Manage your preferences", breadcrumbs: [] };
|
||||
if (pathname.includes("/translator")) return { title: "Translator", description: "Debug translation flow between formats", breadcrumbs: [] };
|
||||
if (pathname.includes("/console-log")) return { title: "Console Log", description: "Live server console output", breadcrumbs: [] };
|
||||
if (pathname === "/dashboard") return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] };
|
||||
|
||||
if (pathname.includes("/providers"))
|
||||
return {
|
||||
title: "Providers",
|
||||
description: "Manage your AI provider connections",
|
||||
breadcrumbs: [],
|
||||
};
|
||||
if (pathname.includes("/combos"))
|
||||
return {
|
||||
title: "Combos",
|
||||
description: "Model combos with fallback",
|
||||
breadcrumbs: [],
|
||||
};
|
||||
if (pathname.includes("/usage"))
|
||||
return {
|
||||
title: "Usage & Analytics",
|
||||
description:
|
||||
"Monitor your API usage, token consumption, and request logs",
|
||||
breadcrumbs: [],
|
||||
};
|
||||
if (pathname.includes("/mitm"))
|
||||
return {
|
||||
title: "MITM Proxy",
|
||||
description: "Intercept CLI tool traffic and route through 9Router",
|
||||
breadcrumbs: [],
|
||||
};
|
||||
if (pathname.includes("/cli-tools"))
|
||||
return {
|
||||
title: "CLI Tools",
|
||||
description: "Configure CLI tools",
|
||||
breadcrumbs: [],
|
||||
};
|
||||
if (pathname.includes("/endpoint"))
|
||||
return {
|
||||
title: "Endpoint",
|
||||
description: "API endpoint configuration",
|
||||
breadcrumbs: [],
|
||||
};
|
||||
if (pathname.includes("/profile"))
|
||||
return {
|
||||
title: "Settings",
|
||||
description: "Manage your preferences",
|
||||
breadcrumbs: [],
|
||||
};
|
||||
if (pathname.includes("/translator"))
|
||||
return {
|
||||
title: "Translator",
|
||||
description: "Debug translation flow between formats",
|
||||
breadcrumbs: [],
|
||||
};
|
||||
if (pathname.includes("/console-log"))
|
||||
return {
|
||||
title: "Console Log",
|
||||
description: "Live server console output",
|
||||
breadcrumbs: [],
|
||||
};
|
||||
if (pathname === "/dashboard")
|
||||
return {
|
||||
title: "Endpoint",
|
||||
description: "API endpoint configuration",
|
||||
breadcrumbs: [],
|
||||
};
|
||||
return { title: "", description: "", breadcrumbs: [] };
|
||||
};
|
||||
|
||||
export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
// Memoize page info to prevent unnecessary recalculations
|
||||
const pageInfo = useMemo(() => getPageInfo(pathname), [pathname]);
|
||||
const { title, description, breadcrumbs } = pageInfo;
|
||||
@@ -81,7 +136,10 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
{breadcrumbs.length > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<div key={`${crumb.label}-${crumb.href || "current"}`} className="flex items-center gap-2">
|
||||
<div
|
||||
key={`${crumb.label}-${crumb.href || "current"}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{index > 0 && (
|
||||
<span className="material-symbols-outlined text-text-muted text-base">
|
||||
chevron_right
|
||||
@@ -97,14 +155,12 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{crumb.image && (
|
||||
<Image
|
||||
<ProviderIcon
|
||||
src={crumb.image}
|
||||
alt={crumb.label}
|
||||
width={28}
|
||||
height={28}
|
||||
size={28}
|
||||
className="object-contain rounded max-w-[28px] max-h-[28px]"
|
||||
sizes="28px"
|
||||
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
||||
fallbackText={crumb.label.slice(0, 2).toUpperCase()}
|
||||
/>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold text-text-main tracking-tight">
|
||||
@@ -117,9 +173,13 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
</div>
|
||||
) : title ? (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-text-main tracking-tight">{translate(title)}</h1>
|
||||
<h1 className="text-2xl font-semibold text-text-main tracking-tight">
|
||||
{translate(title)}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="text-sm text-text-muted">{translate(description)}</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
{translate(description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -150,4 +210,3 @@ Header.propTypes = {
|
||||
onMenuClick: PropTypes.func,
|
||||
showMenuButton: PropTypes.bool,
|
||||
};
|
||||
|
||||
|
||||
51
src/shared/components/ProviderIcon.js
Normal file
51
src/shared/components/ProviderIcon.js
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export default function ProviderIcon({
|
||||
src,
|
||||
alt,
|
||||
size = 32,
|
||||
className = "",
|
||||
fallbackText = "?",
|
||||
fallbackColor,
|
||||
}) {
|
||||
const [errored, setErrored] = useState(false);
|
||||
|
||||
if (!src || errored) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center font-bold rounded-lg ${className}`.trim()}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
color: fallbackColor,
|
||||
fontSize: Math.max(10, Math.floor(size * 0.38)),
|
||||
}}
|
||||
>
|
||||
{fallbackText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
onError={() => setErrored(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ProviderIcon.propTypes = {
|
||||
src: PropTypes.string,
|
||||
alt: PropTypes.string,
|
||||
size: PropTypes.number,
|
||||
className: PropTypes.string,
|
||||
fallbackText: PropTypes.string,
|
||||
fallbackColor: PropTypes.string,
|
||||
};
|
||||
Reference in New Issue
Block a user