2026-04-13 04:52:01 -04:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect, useRef } from "react";
|
|
|
|
|
import PropTypes from "prop-types";
|
|
|
|
|
import { Badge, Toggle } from "@/shared/components";
|
|
|
|
|
import CooldownTimer from "./CooldownTimer";
|
|
|
|
|
|
|
|
|
|
export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete }) {
|
|
|
|
|
const [showProxyDropdown, setShowProxyDropdown] = useState(false);
|
|
|
|
|
const [updatingProxy, setUpdatingProxy] = useState(false);
|
|
|
|
|
const proxyDropdownRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool]));
|
|
|
|
|
const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null;
|
|
|
|
|
const boundProxyPool = boundProxyPoolId ? proxyPoolMap.get(boundProxyPoolId) : null;
|
|
|
|
|
const hasLegacyProxy = connection.providerSpecificData?.connectionProxyEnabled === true && !!connection.providerSpecificData?.connectionProxyUrl;
|
|
|
|
|
const hasAnyProxy = !!boundProxyPoolId || hasLegacyProxy;
|
|
|
|
|
const proxyDisplayText = boundProxyPool
|
|
|
|
|
? `Pool: ${boundProxyPool.name}`
|
|
|
|
|
: boundProxyPoolId
|
|
|
|
|
? `Pool: ${boundProxyPoolId} (inactive/missing)`
|
|
|
|
|
: hasLegacyProxy
|
|
|
|
|
? `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}`
|
|
|
|
|
: "";
|
|
|
|
|
|
|
|
|
|
let maskedProxyUrl = "";
|
|
|
|
|
if (boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl) {
|
|
|
|
|
const rawProxyUrl = boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl;
|
|
|
|
|
try {
|
|
|
|
|
const parsed = new URL(rawProxyUrl);
|
|
|
|
|
maskedProxyUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`;
|
|
|
|
|
} catch {
|
|
|
|
|
maskedProxyUrl = rawProxyUrl;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const noProxyText = boundProxyPool?.noProxy || connection.providerSpecificData?.connectionNoProxy || "";
|
|
|
|
|
|
|
|
|
|
let proxyBadgeVariant = "default";
|
|
|
|
|
if (boundProxyPool?.isActive === true) {
|
|
|
|
|
proxyBadgeVariant = "success";
|
|
|
|
|
} else if (boundProxyPoolId || hasLegacyProxy) {
|
|
|
|
|
proxyBadgeVariant = "error";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Close dropdown when clicking outside
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!showProxyDropdown) return;
|
|
|
|
|
const handler = (e) => {
|
|
|
|
|
if (proxyDropdownRef.current && !proxyDropdownRef.current.contains(e.target)) {
|
|
|
|
|
setShowProxyDropdown(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener("mousedown", handler);
|
|
|
|
|
return () => document.removeEventListener("mousedown", handler);
|
|
|
|
|
}, [showProxyDropdown]);
|
|
|
|
|
|
|
|
|
|
const handleSelectProxy = async (poolId) => {
|
|
|
|
|
setUpdatingProxy(true);
|
|
|
|
|
try {
|
|
|
|
|
await onUpdateProxy(poolId === "__none__" ? null : poolId);
|
|
|
|
|
} finally {
|
|
|
|
|
setUpdatingProxy(false);
|
|
|
|
|
setShowProxyDropdown(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const displayName = isOAuth
|
|
|
|
|
? connection.name || connection.email || connection.displayName || "OAuth Account"
|
|
|
|
|
: connection.name;
|
|
|
|
|
|
|
|
|
|
// Use useState + useEffect for impure Date.now() to avoid calling during render
|
|
|
|
|
const [isCooldown, setIsCooldown] = useState(false);
|
|
|
|
|
|
|
|
|
|
// Get earliest model lock timestamp (useEffect handles the Date.now() comparison)
|
|
|
|
|
const modelLockUntil = Object.entries(connection)
|
|
|
|
|
.filter(([k]) => k.startsWith("modelLock_"))
|
|
|
|
|
.map(([, v]) => v)
|
|
|
|
|
.filter(v => !!v)
|
|
|
|
|
.sort()[0] || null;
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const checkCooldown = () => {
|
|
|
|
|
const until = Object.entries(connection)
|
|
|
|
|
.filter(([k]) => k.startsWith("modelLock_"))
|
|
|
|
|
.map(([, v]) => v)
|
|
|
|
|
.filter(v => v && new Date(v).getTime() > Date.now())
|
|
|
|
|
.sort()[0] || null;
|
|
|
|
|
setIsCooldown(!!until);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
checkCooldown();
|
|
|
|
|
const interval = modelLockUntil ? setInterval(checkCooldown, 1000) : null;
|
|
|
|
|
return () => {
|
|
|
|
|
if (interval) clearInterval(interval);
|
|
|
|
|
};
|
|
|
|
|
}, [modelLockUntil]);
|
|
|
|
|
|
|
|
|
|
// Determine effective status (override unavailable if cooldown expired)
|
|
|
|
|
const effectiveStatus = (connection.testStatus === "unavailable" && !isCooldown)
|
|
|
|
|
? "active" // Cooldown expired u2192 treat as active
|
|
|
|
|
: connection.testStatus;
|
|
|
|
|
|
|
|
|
|
const getStatusVariant = () => {
|
|
|
|
|
if (connection.isActive === false) return "default";
|
|
|
|
|
if (effectiveStatus === "active" || effectiveStatus === "success") return "success";
|
|
|
|
|
if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error";
|
|
|
|
|
return "default";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className={`group flex min-w-0 flex-col gap-3 rounded-lg p-2 transition-colors hover:bg-black/[0.02] dark:hover:bg-white/[0.02] sm:flex-row sm:items-center sm:justify-between ${connection.isActive === false ? "opacity-60" : ""}`}>
|
|
|
|
|
<div className="flex min-w-0 flex-1 items-start gap-2 sm:items-center sm:gap-3">
|
2026-04-13 04:52:01 -04:00
|
|
|
{/* Priority arrows */}
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="flex shrink-0 flex-col">
|
2026-04-13 04:52:01 -04:00
|
|
|
<button
|
|
|
|
|
onClick={onMoveUp}
|
|
|
|
|
disabled={isFirst}
|
|
|
|
|
className={`p-0.5 rounded ${isFirst ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}
|
|
|
|
|
>
|
|
|
|
|
<span className="material-symbols-outlined text-sm">keyboard_arrow_up</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onMoveDown}
|
|
|
|
|
disabled={isLast}
|
|
|
|
|
className={`p-0.5 rounded ${isLast ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}
|
|
|
|
|
>
|
|
|
|
|
<span className="material-symbols-outlined text-sm">keyboard_arrow_down</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-05-01 05:34:07 -04:00
|
|
|
<span className="material-symbols-outlined shrink-0 text-base text-text-muted">
|
2026-04-13 04:52:01 -04:00
|
|
|
{isOAuth ? "lock" : "key"}
|
|
|
|
|
</span>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<p className="text-sm font-medium truncate">{displayName}</p>
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1.5 sm:gap-2">
|
2026-04-13 04:52:01 -04:00
|
|
|
<Badge variant={getStatusVariant()} size="sm" dot>
|
|
|
|
|
{connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")}
|
|
|
|
|
</Badge>
|
|
|
|
|
{hasAnyProxy && (
|
|
|
|
|
<Badge variant={proxyBadgeVariant} size="sm">
|
|
|
|
|
Proxy
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{isCooldown && connection.isActive !== false && <CooldownTimer until={modelLockUntil} />}
|
|
|
|
|
{connection.lastError && connection.isActive !== false && (
|
2026-05-01 05:34:07 -04:00
|
|
|
<span className="max-w-full truncate text-xs text-red-500 sm:max-w-[300px]" title={connection.lastError}>
|
2026-04-13 04:52:01 -04:00
|
|
|
{connection.lastError}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-xs text-text-muted">#{connection.priority}</span>
|
|
|
|
|
{connection.globalPriority && (
|
|
|
|
|
<span className="text-xs text-text-muted">Auto: {connection.globalPriority}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{hasAnyProxy && (
|
|
|
|
|
<div className="mt-1 flex items-center gap-2 flex-wrap">
|
2026-05-01 05:34:07 -04:00
|
|
|
<span className="max-w-full truncate text-[11px] text-text-muted sm:max-w-[420px]" title={proxyDisplayText}>
|
2026-04-13 04:52:01 -04:00
|
|
|
{proxyDisplayText}
|
|
|
|
|
</span>
|
|
|
|
|
{maskedProxyUrl && (
|
2026-05-01 05:34:07 -04:00
|
|
|
<code className="max-w-full truncate rounded bg-black/5 px-1 py-0.5 font-mono text-[10px] text-text-muted dark:bg-white/5 sm:max-w-[260px]">
|
2026-04-13 04:52:01 -04:00
|
|
|
{maskedProxyUrl}
|
|
|
|
|
</code>
|
|
|
|
|
)}
|
|
|
|
|
{noProxyText && (
|
2026-05-01 05:34:07 -04:00
|
|
|
<span className="max-w-full truncate text-[11px] text-text-muted sm:max-w-[320px]" title={noProxyText}>
|
2026-04-13 04:52:01 -04:00
|
|
|
no_proxy: {noProxyText}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-end">
|
|
|
|
|
<div className="grid flex-1 grid-cols-3 gap-1 sm:flex sm:flex-none">
|
2026-04-13 04:52:01 -04:00
|
|
|
{/* Proxy button with inline dropdown */}
|
|
|
|
|
{(proxyPools || []).length > 0 && (
|
|
|
|
|
<div className="relative" ref={proxyDropdownRef}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowProxyDropdown((v) => !v)}
|
2026-05-01 05:34:07 -04:00
|
|
|
className={`flex w-full flex-col items-center rounded px-2 py-1 transition-colors hover:bg-black/5 dark:hover:bg-white/5 ${hasAnyProxy ? "text-primary" : "text-text-muted hover:text-primary"}`}
|
2026-04-13 04:52:01 -04:00
|
|
|
disabled={updatingProxy}
|
|
|
|
|
>
|
|
|
|
|
<span className="material-symbols-outlined text-[18px]">
|
|
|
|
|
{updatingProxy ? "progress_activity" : "lan"}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-[10px] leading-tight">Proxy</span>
|
|
|
|
|
</button>
|
|
|
|
|
{showProxyDropdown && (
|
2026-05-01 05:34:07 -04:00
|
|
|
<div className="absolute right-0 top-full z-50 mt-1 max-w-[78vw] min-w-[160px] rounded-lg border border-border bg-bg py-1 shadow-lg">
|
2026-04-13 04:52:01 -04:00
|
|
|
<button
|
|
|
|
|
onClick={() => handleSelectProxy("__none__")}
|
|
|
|
|
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-black/5 dark:hover:bg-white/5 ${!boundProxyPoolId ? "text-primary font-medium" : "text-text-main"}`}
|
|
|
|
|
>
|
|
|
|
|
None
|
|
|
|
|
</button>
|
|
|
|
|
{(proxyPools || []).map((pool) => (
|
|
|
|
|
<button
|
|
|
|
|
key={pool.id}
|
|
|
|
|
onClick={() => handleSelectProxy(pool.id)}
|
|
|
|
|
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-black/5 dark:hover:bg-white/5 ${boundProxyPoolId === pool.id ? "text-primary font-medium" : "text-text-main"}`}
|
|
|
|
|
>
|
|
|
|
|
{pool.name}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-05-01 05:34:07 -04:00
|
|
|
<button onClick={onEdit} className="flex flex-col items-center rounded px-2 py-1 text-text-muted hover:bg-black/5 hover:text-primary dark:hover:bg-white/5">
|
2026-04-13 04:52:01 -04:00
|
|
|
<span className="material-symbols-outlined text-[18px]">edit</span>
|
|
|
|
|
<span className="text-[10px] leading-tight">Edit</span>
|
|
|
|
|
</button>
|
2026-05-01 05:34:07 -04:00
|
|
|
<button onClick={onDelete} className="flex flex-col items-center rounded px-2 py-1 text-red-500 hover:bg-red-500/10">
|
2026-04-13 04:52:01 -04:00
|
|
|
<span className="material-symbols-outlined text-[18px]">delete</span>
|
|
|
|
|
<span className="text-[10px] leading-tight">Delete</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<Toggle
|
|
|
|
|
size="sm"
|
|
|
|
|
checked={connection.isActive ?? true}
|
|
|
|
|
onChange={onToggleActive}
|
|
|
|
|
title={(connection.isActive ?? true) ? "Disable connection" : "Enable connection"}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ConnectionRow.propTypes = {
|
|
|
|
|
connection: PropTypes.shape({
|
|
|
|
|
id: PropTypes.string,
|
|
|
|
|
name: PropTypes.string,
|
|
|
|
|
email: PropTypes.string,
|
|
|
|
|
displayName: PropTypes.string,
|
|
|
|
|
modelLockUntil: PropTypes.string,
|
|
|
|
|
testStatus: PropTypes.string,
|
|
|
|
|
isActive: PropTypes.bool,
|
|
|
|
|
lastError: PropTypes.string,
|
|
|
|
|
priority: PropTypes.number,
|
|
|
|
|
globalPriority: PropTypes.number,
|
|
|
|
|
}).isRequired,
|
|
|
|
|
proxyPools: PropTypes.arrayOf(PropTypes.shape({
|
|
|
|
|
id: PropTypes.string,
|
|
|
|
|
name: PropTypes.string,
|
|
|
|
|
proxyUrl: PropTypes.string,
|
|
|
|
|
noProxy: PropTypes.string,
|
|
|
|
|
isActive: PropTypes.bool,
|
|
|
|
|
})),
|
|
|
|
|
isOAuth: PropTypes.bool.isRequired,
|
|
|
|
|
isFirst: PropTypes.bool.isRequired,
|
|
|
|
|
isLast: PropTypes.bool.isRequired,
|
|
|
|
|
onMoveUp: PropTypes.func.isRequired,
|
|
|
|
|
onMoveDown: PropTypes.func.isRequired,
|
|
|
|
|
onToggleActive: PropTypes.func.isRequired,
|
|
|
|
|
onUpdateProxy: PropTypes.func,
|
|
|
|
|
onEdit: PropTypes.func.isRequired,
|
|
|
|
|
onDelete: PropTypes.func.isRequired,
|
|
|
|
|
};
|
|
|
|
|
|