mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
* Improve dashboard responsive layouts * Improve proxy pools mobile layout * Improve CLI tool card input responsiveness --------- Co-authored-by: Delynn Assistant <zhen@dkzhen.org>
267 lines
9.1 KiB
JavaScript
267 lines
9.1 KiB
JavaScript
"use client";
|
|
|
|
import { useMemo, useState, useCallback, useRef } from "react";
|
|
import PropTypes from "prop-types";
|
|
import {
|
|
ReactFlow,
|
|
Handle,
|
|
Position,
|
|
} from "@xyflow/react";
|
|
import "@xyflow/react/dist/style.css";
|
|
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
|
|
|
function getProviderConfig(providerId) {
|
|
return AI_PROVIDERS[providerId] || { color: "#6b7280", name: providerId };
|
|
}
|
|
|
|
// Use local provider images from /public/providers/
|
|
function getProviderImageUrl(providerId) {
|
|
return `/providers/${providerId}.png`;
|
|
}
|
|
|
|
// Custom provider node - rectangle with image + name
|
|
function ProviderNode({ data }) {
|
|
const { label, color, imageUrl, textIcon, active } = data;
|
|
const [imgError, setImgError] = useState(false);
|
|
return (
|
|
<div
|
|
className="flex items-center gap-2.5 px-4 py-2.5 rounded-lg border-2 transition-all duration-300 bg-bg"
|
|
style={{
|
|
borderColor: active ? color : "var(--color-border)",
|
|
boxShadow: active ? `0 0 16px ${color}40` : "none",
|
|
minWidth: "150px",
|
|
}}
|
|
>
|
|
<Handle type="target" position={Position.Top} id="top" className="!bg-transparent !border-0 !w-0 !h-0" />
|
|
<Handle type="target" position={Position.Bottom} id="bottom" className="!bg-transparent !border-0 !w-0 !h-0" />
|
|
<Handle type="target" position={Position.Left} id="left" className="!bg-transparent !border-0 !w-0 !h-0" />
|
|
<Handle type="target" position={Position.Right} id="right" className="!bg-transparent !border-0 !w-0 !h-0" />
|
|
|
|
{/* Provider icon */}
|
|
<div
|
|
className="w-8 h-8 rounded-md flex items-center justify-center shrink-0"
|
|
style={{ backgroundColor: `${color}15` }}
|
|
>
|
|
{!imgError ? (
|
|
<img src={imageUrl} alt={label} className="w-6 h-6 rounded-sm object-contain" onError={() => setImgError(true)} />
|
|
) : (
|
|
<span className="text-sm font-bold" style={{ color }}>{textIcon}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Provider name */}
|
|
<span
|
|
className="text-base font-medium truncate"
|
|
style={{ color: active ? color : "var(--color-text)" }}
|
|
>
|
|
{label}
|
|
</span>
|
|
|
|
{/* Active indicator */}
|
|
{active && (
|
|
<span className="relative flex h-2 w-2 shrink-0">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75" style={{ backgroundColor: color }} />
|
|
<span className="relative inline-flex rounded-full h-2 w-2" style={{ backgroundColor: color }} />
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ProviderNode.propTypes = {
|
|
data: PropTypes.object.isRequired,
|
|
};
|
|
|
|
// Center 9Router node
|
|
function RouterNode({ data }) {
|
|
return (
|
|
<div className="flex items-center justify-center px-5 py-3 rounded-xl border-2 border-primary bg-primary/5 shadow-md min-w-[130px]">
|
|
<Handle type="source" position={Position.Top} id="top" className="!bg-transparent !border-0 !w-0 !h-0" />
|
|
<Handle type="source" position={Position.Bottom} id="bottom" className="!bg-transparent !border-0 !w-0 !h-0" />
|
|
<Handle type="source" position={Position.Left} id="left" className="!bg-transparent !border-0 !w-0 !h-0" />
|
|
<Handle type="source" position={Position.Right} id="right" className="!bg-transparent !border-0 !w-0 !h-0" />
|
|
|
|
<img src="/favicon.svg" alt="9Router" className="w-6 h-6 mr-2" />
|
|
<span className="text-sm font-bold text-primary">9Router</span>
|
|
{data.activeCount > 0 && (
|
|
<span className="ml-2 px-1.5 py-0.5 rounded-full bg-primary text-white text-xs font-bold">
|
|
{data.activeCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
RouterNode.propTypes = {
|
|
data: PropTypes.object.isRequired,
|
|
};
|
|
|
|
const nodeTypes = { provider: ProviderNode, router: RouterNode };
|
|
|
|
// Place N nodes evenly along an ellipse around the router center.
|
|
function buildLayout(providers, activeSet, lastSet, errorSet) {
|
|
const nodeW = 180;
|
|
const nodeH = 30;
|
|
const routerW = 120;
|
|
const routerH = 44;
|
|
const nodeGap = 24;
|
|
|
|
const count = providers.length;
|
|
|
|
// Compute rx so arc spacing between nodes >= nodeW + nodeGap
|
|
const minRx = ((nodeW + nodeGap) * count) / (2 * Math.PI);
|
|
const rx = Math.max(320, minRx);
|
|
const ry = Math.max(200, rx * 0.55); // ellipse ratio ~0.55
|
|
if (count === 0) {
|
|
return {
|
|
nodes: [{ id: "router", type: "router", position: { x: 0, y: 0 }, data: { activeCount: 0 }, draggable: false }],
|
|
edges: [],
|
|
};
|
|
}
|
|
|
|
const nodes = [];
|
|
const edges = [];
|
|
|
|
nodes.push({
|
|
id: "router",
|
|
type: "router",
|
|
position: { x: -routerW / 2, y: -routerH / 2 },
|
|
data: { activeCount: activeSet.size },
|
|
draggable: false,
|
|
});
|
|
|
|
const edgeStyle = (active, last, error, color) => {
|
|
if (error) return { stroke: "#ef4444", strokeWidth: 2.5, opacity: 0.9 };
|
|
if (active) return { stroke: "#22c55e", strokeWidth: 2.5, opacity: 0.9 };
|
|
if (last) return { stroke: "#f59e0b", strokeWidth: 2, opacity: 0.7 };
|
|
return { stroke: "var(--color-border)", strokeWidth: 1, opacity: 0.3 };
|
|
};
|
|
|
|
providers.forEach((p, i) => {
|
|
const config = getProviderConfig(p.provider);
|
|
const active = activeSet.has(p.provider?.toLowerCase());
|
|
const last = !active && lastSet.has(p.provider?.toLowerCase());
|
|
const error = !active && errorSet.has(p.provider?.toLowerCase());
|
|
const nodeId = `provider-${p.provider}`;
|
|
const data = {
|
|
label: (config.name !== p.provider ? config.name : null) || p.name || p.provider,
|
|
color: config.color || "#6b7280",
|
|
imageUrl: getProviderImageUrl(p.provider),
|
|
textIcon: config.textIcon || (p.provider || "?").slice(0, 2).toUpperCase(),
|
|
active,
|
|
};
|
|
|
|
// Distribute evenly starting from top (−π/2), clockwise
|
|
const angle = -Math.PI / 2 + (2 * Math.PI * i) / count;
|
|
const cx = rx * Math.cos(angle);
|
|
const cy = ry * Math.sin(angle);
|
|
|
|
// Pick router handle closest to the node direction
|
|
let sourceHandle, targetHandle;
|
|
if (Math.abs(angle + Math.PI / 2) < Math.PI / 4 || Math.abs(angle - 3 * Math.PI / 2) < Math.PI / 4) {
|
|
sourceHandle = "top"; targetHandle = "bottom";
|
|
} else if (Math.abs(angle - Math.PI / 2) < Math.PI / 4) {
|
|
sourceHandle = "bottom"; targetHandle = "top";
|
|
} else if (cx > 0) {
|
|
sourceHandle = "right"; targetHandle = "left";
|
|
} else {
|
|
sourceHandle = "left"; targetHandle = "right";
|
|
}
|
|
|
|
nodes.push({
|
|
id: nodeId,
|
|
type: "provider",
|
|
position: { x: cx - nodeW / 2, y: cy - nodeH / 2 },
|
|
data,
|
|
draggable: false,
|
|
});
|
|
|
|
edges.push({
|
|
id: `e-${nodeId}`,
|
|
source: "router",
|
|
sourceHandle,
|
|
target: nodeId,
|
|
targetHandle,
|
|
animated: active,
|
|
style: edgeStyle(active, last, error, config.color),
|
|
});
|
|
});
|
|
|
|
return { nodes, edges };
|
|
}
|
|
|
|
export default function ProviderTopology({ providers = [], activeRequests = [], lastProvider = "", errorProvider = "" }) {
|
|
// Serialize to stable string keys so useMemo only re-runs when values actually change
|
|
const activeKey = useMemo(
|
|
() => activeRequests.map((r) => r.provider?.toLowerCase()).filter(Boolean).sort().join(","),
|
|
[activeRequests]
|
|
);
|
|
const lastKey = lastProvider?.toLowerCase() || "";
|
|
const errorKey = errorProvider?.toLowerCase() || "";
|
|
|
|
const activeSet = useMemo(() => new Set(activeKey ? activeKey.split(",") : []), [activeKey]);
|
|
const lastSet = useMemo(() => new Set(lastKey ? [lastKey] : []), [lastKey]);
|
|
const errorSet = useMemo(() => new Set(errorKey ? [errorKey] : []), [errorKey]);
|
|
|
|
const { nodes, edges } = useMemo(
|
|
() => buildLayout(providers, activeSet, lastSet, errorSet),
|
|
[providers, activeKey, lastKey, errorKey]
|
|
);
|
|
|
|
// Stable key — only remount when provider list changes
|
|
const providersKey = useMemo(
|
|
() => providers.map((p) => p.provider).sort().join(","),
|
|
[providers]
|
|
);
|
|
|
|
const rfInstance = useRef(null);
|
|
const onInit = useCallback((instance) => {
|
|
rfInstance.current = instance;
|
|
setTimeout(() => instance.fitView({ padding: 0.3 }), 50);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="h-[320px] w-full min-w-0 rounded-lg border border-border bg-bg-subtle/30 sm:h-[480px]">
|
|
{providers.length === 0 ? (
|
|
<div className="h-full flex items-center justify-center text-text-muted text-sm">
|
|
No providers connected
|
|
</div>
|
|
) : (
|
|
<ReactFlow
|
|
key={providersKey}
|
|
nodes={nodes}
|
|
edges={edges}
|
|
nodeTypes={nodeTypes}
|
|
fitView
|
|
fitViewOptions={{ padding: 0.3 }}
|
|
onInit={onInit}
|
|
proOptions={{ hideAttribution: true }}
|
|
panOnDrag={false}
|
|
zoomOnScroll={false}
|
|
zoomOnPinch={false}
|
|
zoomOnDoubleClick={false}
|
|
preventScrolling={false}
|
|
nodesDraggable={false}
|
|
nodesConnectable={false}
|
|
elementsSelectable={false}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ProviderTopology.propTypes = {
|
|
providers: PropTypes.arrayOf(PropTypes.shape({
|
|
id: PropTypes.string,
|
|
provider: PropTypes.string,
|
|
name: PropTypes.string,
|
|
})),
|
|
activeRequests: PropTypes.arrayOf(PropTypes.shape({
|
|
provider: PropTypes.string,
|
|
model: PropTypes.string,
|
|
account: PropTypes.string,
|
|
})),
|
|
lastProvider: PropTypes.string,
|
|
errorProvider: PropTypes.string,
|
|
};
|