feat: show current best exit state

This commit is contained in:
sagitchu
2026-05-01 13:30:46 +08:00
parent d98377a297
commit e5acc49186
5 changed files with 806 additions and 30 deletions
+173 -30
View File
@@ -97,6 +97,25 @@ interface ChainTunnel {
connectIp?: string; // 连接IP(多IP节点指定连接地址)
}
interface BestExitStateItem {
ownerNodeId: number;
ownerNodeName: string;
ownerRole: "entry" | "chain" | string;
exitNodeId?: number;
exitNodeName: string;
updatedAt?: number;
reason?: string;
}
interface BestExitState {
enabled: boolean;
summary: string;
status: "applied" | "waiting" | string;
updatedAt?: number;
reason?: string;
items: BestExitStateItem[];
}
interface Tunnel {
id: number;
inx?: number;
@@ -111,6 +130,7 @@ interface Tunnel {
flow: number; // 1: 单向, 2: 双向
trafficRatio: number;
ipPreference?: string;
bestExitState?: BestExitState;
status: number;
createdTime: string;
}
@@ -165,19 +185,128 @@ const DEFAULT_TUNNEL_DELETE_ACTION: TunnelDeleteAction = "replace";
const TUNNEL_ORDER_KEY = "tunnel-order";
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
const toSafeString = (value: unknown): string => {
if (typeof value === "string") return value;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return "";
};
const toSafeNumber = (value: unknown): number | undefined => {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value !== "string" || !value.trim()) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
const normalizeBestExitStateItem = (
value: unknown,
): BestExitStateItem | undefined => {
if (!isObjectRecord(value)) return undefined;
const ownerNodeId = toSafeNumber(value.ownerNodeId);
if (ownerNodeId === undefined) return undefined;
const exitNodeId = toSafeNumber(value.exitNodeId);
const updatedAt = toSafeNumber(value.updatedAt);
const reason = toSafeString(value.reason);
return {
ownerNodeId,
ownerNodeName: toSafeString(value.ownerNodeName),
ownerRole: toSafeString(value.ownerRole),
...(exitNodeId !== undefined ? { exitNodeId } : {}),
exitNodeName: toSafeString(value.exitNodeName),
...(updatedAt !== undefined ? { updatedAt } : {}),
...(reason ? { reason } : {}),
};
};
const normalizeBestExitState = (value: unknown): BestExitState | undefined => {
if (!isObjectRecord(value) || value.enabled !== true) return undefined;
const updatedAt = toSafeNumber(value.updatedAt);
const reason = toSafeString(value.reason);
const items = Array.isArray(value.items)
? value.items.flatMap((item) => {
const normalized = normalizeBestExitStateItem(item);
return normalized ? [normalized] : [];
})
: [];
return {
enabled: true,
summary: toSafeString(value.summary),
status: toSafeString(value.status),
...(updatedAt !== undefined ? { updatedAt } : {}),
...(reason ? { reason } : {}),
items,
};
};
const bestExitOwnerRoleText = (role?: string) => {
if (role === "chain") return "中转";
return "入口";
};
const bestExitDetailTitle = (state?: BestExitState) => {
if (!state?.items?.length) return undefined;
return state.items
.map(
(item) =>
`${bestExitOwnerRoleText(item.ownerRole)} ${item.ownerNodeName || item.ownerNodeId} -> ${item.exitNodeName || "等待探测"}`,
)
.join("\n");
};
const renderBestExitState = (state?: BestExitState) => {
if (!state?.enabled) return null;
const isWaiting = state.status === "waiting";
const summaryText = state.summary || "等待探测";
const displaySummary =
isWaiting && !summaryText.includes("等待")
? `等待探测 · ${summaryText}`
: summaryText;
const className = isWaiting
? "border-warning-200/70 bg-warning-50/50 text-warning-700 dark:border-warning-300/20 dark:bg-warning-900/20 dark:text-warning-300"
: "border-success-200/60 bg-success-50/40 text-success-700 dark:border-success-300/20 dark:bg-success-900/20 dark:text-success-300";
return (
<span
className={`inline-flex max-w-full items-center rounded border px-1.5 py-0.5 text-[11px] leading-4 ${className}`}
title={bestExitDetailTitle(state)}
>
<span className="min-w-0 truncate">最优出口:{displaySummary}</span>
</span>
);
};
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
return (items || []).map((tunnel) => ({
...tunnel,
inx: tunnel.inx ?? 0,
inNodeId: Array.isArray(tunnel.inNodeId) ? tunnel.inNodeId : [],
outNodeId: Array.isArray(tunnel.outNodeId) ? tunnel.outNodeId : [],
chainNodes: Array.isArray(tunnel.chainNodes) ? tunnel.chainNodes : [],
inIp: tunnel.inIp || "",
flow: tunnel.flow ?? 1,
trafficRatio: tunnel.trafficRatio ?? 1,
status: typeof tunnel.status === "number" ? tunnel.status : 0,
createdTime: tunnel.createdTime || "",
}));
return (items || []).map((tunnel) => {
const { bestExitState: rawBestExitState, ...tunnelFields } = tunnel;
const bestExitState = normalizeBestExitState(rawBestExitState);
return {
...tunnelFields,
...(bestExitState ? { bestExitState } : {}),
inx: tunnel.inx ?? 0,
inNodeId: Array.isArray(tunnel.inNodeId) ? tunnel.inNodeId : [],
outNodeId: Array.isArray(tunnel.outNodeId) ? tunnel.outNodeId : [],
chainNodes: Array.isArray(tunnel.chainNodes) ? tunnel.chainNodes : [],
inIp: tunnel.inIp || "",
flow: tunnel.flow ?? 1,
trafficRatio: tunnel.trafficRatio ?? 1,
status: typeof tunnel.status === "number" ? tunnel.status : 0,
createdTime: tunnel.createdTime || "",
};
});
};
export default function TunnelPage() {
@@ -1636,6 +1765,9 @@ export default function TunnelPage() {
tunnel.type === 1
? "text-[10px] h-5 bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700"
: "text-[10px] h-5 bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700";
const bestExitStateContent = renderBestExitState(
tunnel.bestExitState,
);
return (
<TableRow key={tunnel.id}>
@@ -1672,24 +1804,27 @@ export default function TunnelPage() {
</Chip>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5 text-xs">
<span className="font-semibold text-primary-700 dark:text-primary-400">
{tunnel.inNodeId?.length || 0}入口
</span>
<span className="text-default-400">→</span>
<span className="font-semibold text-secondary-700 dark:text-secondary-400">
{tunnel.type === 2
? tunnel.chainNodes?.length || 0
: 0}
跳
</span>
<span className="text-default-400">→</span>
<span className="font-semibold text-success-700 dark:text-success-400">
{tunnel.type === 2
? tunnel.outNodeId?.length || 0
: tunnel.inNodeId?.length || 0}
出口
</span>
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex items-center gap-1.5 text-xs">
<span className="font-semibold text-primary-700 dark:text-primary-400">
{tunnel.inNodeId?.length || 0}入口
</span>
<span className="text-default-400">→</span>
<span className="font-semibold text-secondary-700 dark:text-secondary-400">
{tunnel.type === 2
? tunnel.chainNodes?.length || 0
: 0}
跳
</span>
<span className="text-default-400">→</span>
<span className="font-semibold text-success-700 dark:text-success-400">
{tunnel.type === 2
? tunnel.outNodeId?.length || 0
: tunnel.inNodeId?.length || 0}
出口
</span>
</div>
{bestExitStateContent}
</div>
</TableCell>
<TableCell>
@@ -1758,6 +1893,9 @@ export default function TunnelPage() {
tunnel.type === 1
? "text-xs bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700"
: "text-xs bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700";
const bestExitStateContent = renderBestExitState(
tunnel.bestExitState,
);
return (
<SortableItem key={tunnel.id} id={tunnel.id}>
@@ -1908,6 +2046,11 @@ export default function TunnelPage() {
</span>
</div>
</div>
{bestExitStateContent && (
<div className="mt-2 flex min-w-0 justify-center">
{bestExitStateContent}
</div>
)}
</div>
{/* 流量配置 */}