mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-03 17:06:36 +08:00
feat: show current best exit state
This commit is contained in:
@@ -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>
|
||||
|
||||
{/* 流量配置 */}
|
||||
|
||||
Reference in New Issue
Block a user