9router/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
Zhen 0da61d8f7b
Improve dashboard responsive layouts (#805)
* Improve dashboard responsive layouts

* Improve proxy pools mobile layout

* Improve CLI tool card input responsiveness

---------

Co-authored-by: Delynn Assistant <zhen@dkzhen.org>
2026-05-01 16:34:07 +07:00

261 lines
11 KiB
JavaScript

"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 (
<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">
{/* Priority arrows */}
<div className="flex shrink-0 flex-col">
<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>
<span className="material-symbols-outlined shrink-0 text-base text-text-muted">
{isOAuth ? "lock" : "key"}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{displayName}</p>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1.5 sm:gap-2">
<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 && (
<span className="max-w-full truncate text-xs text-red-500 sm:max-w-[300px]" title={connection.lastError}>
{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">
<span className="max-w-full truncate text-[11px] text-text-muted sm:max-w-[420px]" title={proxyDisplayText}>
{proxyDisplayText}
</span>
{maskedProxyUrl && (
<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]">
{maskedProxyUrl}
</code>
)}
{noProxyText && (
<span className="max-w-full truncate text-[11px] text-text-muted sm:max-w-[320px]" title={noProxyText}>
no_proxy: {noProxyText}
</span>
)}
</div>
)}
</div>
</div>
<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">
{/* Proxy button with inline dropdown */}
{(proxyPools || []).length > 0 && (
<div className="relative" ref={proxyDropdownRef}>
<button
onClick={() => setShowProxyDropdown((v) => !v)}
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"}`}
disabled={updatingProxy}
>
<span className="material-symbols-outlined text-[18px]">
{updatingProxy ? "progress_activity" : "lan"}
</span>
<span className="text-[10px] leading-tight">Proxy</span>
</button>
{showProxyDropdown && (
<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">
<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>
)}
<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">
<span className="material-symbols-outlined text-[18px]">edit</span>
<span className="text-[10px] leading-tight">Edit</span>
</button>
<button onClick={onDelete} className="flex flex-col items-center rounded px-2 py-1 text-red-500 hover:bg-red-500/10">
<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,
};