mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-12 04:06:37 +08:00
feat: add tunnel probe target UI
This commit is contained in:
@@ -525,6 +525,8 @@ export interface TunnelQualityApiItem {
|
|||||||
exitToBingLatency: number;
|
exitToBingLatency: number;
|
||||||
entryToExitLoss: number;
|
entryToExitLoss: number;
|
||||||
exitToBingLoss: number;
|
exitToBingLoss: number;
|
||||||
|
probeTargetHost?: string;
|
||||||
|
probeTargetPort?: number;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY =
|
|||||||
"monitor_tunnel_quality_enabled";
|
"monitor_tunnel_quality_enabled";
|
||||||
const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT =
|
const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT =
|
||||||
"monitorTunnelQualityEnabledChanged";
|
"monitorTunnelQualityEnabledChanged";
|
||||||
|
const DEFAULT_PROBE_TARGET_LABEL = "www.bing.com:443";
|
||||||
|
|
||||||
|
const probeTargetLabel = (quality?: TunnelQualityApiItem | null) => {
|
||||||
|
if (!quality?.probeTargetHost || !quality.probeTargetPort) {
|
||||||
|
return DEFAULT_PROBE_TARGET_LABEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = quality.probeTargetHost.includes(":")
|
||||||
|
? `[${quality.probeTargetHost}]`
|
||||||
|
: quality.probeTargetHost;
|
||||||
|
|
||||||
|
return `${host}:${quality.probeTargetPort}`;
|
||||||
|
};
|
||||||
|
|
||||||
const formatTimestamp = (ts: number, rangeMs?: number): string => {
|
const formatTimestamp = (ts: number, rangeMs?: number): string => {
|
||||||
const date = new Date(ts);
|
const date = new Date(ts);
|
||||||
@@ -177,7 +190,7 @@ function UptimeHistoryBar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const displayLatency = latency >= 0 ? `${latency.toFixed(0)}ms` : "-";
|
const displayLatency = latency >= 0 ? `${latency.toFixed(0)}ms` : "-";
|
||||||
const tooltip = `${timeStr} | ${displayLatency} | ${statusText}`;
|
const tooltip = `${timeStr} | ${displayLatency} | ${statusText} | 测试目标: ${probeTargetLabel(q)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -305,7 +318,7 @@ const QualityChartCard = React.memo(function QualityChartCard({
|
|||||||
name === "entryToExit"
|
name === "entryToExit"
|
||||||
? "入口→出口"
|
? "入口→出口"
|
||||||
: name === "exitToBing"
|
: name === "exitToBing"
|
||||||
? "出口→Bing"
|
? "出口→测试目标"
|
||||||
: name;
|
: name;
|
||||||
|
|
||||||
return [`${n.toFixed(1)}ms`, label];
|
return [`${n.toFixed(1)}ms`, label];
|
||||||
@@ -1001,9 +1014,12 @@ export function TunnelMonitorView({
|
|||||||
</Card>
|
</Card>
|
||||||
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||||
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||||
<span className="text-[11px] text-default-500 mb-1.5 flex items-center gap-1">
|
<span
|
||||||
|
className="text-[11px] text-default-500 mb-1.5 flex items-center gap-1"
|
||||||
|
title={probeTargetLabel(quality)}
|
||||||
|
>
|
||||||
<Globe className="w-3 h-3" />
|
<Globe className="w-3 h-3" />
|
||||||
出口 → Bing 延迟
|
出口 → 测试目标 延迟
|
||||||
</span>
|
</span>
|
||||||
<LatencyDisplay
|
<LatencyDisplay
|
||||||
loading={qualityLoading}
|
loading={qualityLoading}
|
||||||
@@ -1027,8 +1043,11 @@ export function TunnelMonitorView({
|
|||||||
</Card>
|
</Card>
|
||||||
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||||
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||||
<span className="text-[11px] text-default-500 mb-1.5">
|
<span
|
||||||
出口 → Bing 丢包
|
className="text-[11px] text-default-500 mb-1.5"
|
||||||
|
title={probeTargetLabel(quality)}
|
||||||
|
>
|
||||||
|
出口 → 测试目标 丢包
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`text-sm font-semibold font-mono ${(quality?.exitToBingLoss ?? 0) > 0 ? "text-warning" : ""}`}
|
className={`text-sm font-semibold font-mono ${(quality?.exitToBingLoss ?? 0) > 0 ? "text-warning" : ""}`}
|
||||||
@@ -1054,6 +1073,9 @@ export function TunnelMonitorView({
|
|||||||
<span>实时隧道质量检测已关闭</span>
|
<span>实时隧道质量检测已关闭</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<span className="text-default-400">
|
||||||
|
· 测试目标: {probeTargetLabel(quality)}
|
||||||
|
</span>
|
||||||
{quality?.timestamp && (
|
{quality?.timestamp && (
|
||||||
<span className="text-default-400">
|
<span className="text-default-400">
|
||||||
· 最近更新:{" "}
|
· 最近更新:{" "}
|
||||||
@@ -1201,9 +1223,12 @@ export function TunnelMonitorView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-[10px] text-default-500 flex items-center gap-1">
|
<div
|
||||||
|
className="text-[10px] text-default-500 flex items-center gap-1"
|
||||||
|
title={probeTargetLabel(quality)}
|
||||||
|
>
|
||||||
<Globe className="w-3 h-3" />
|
<Globe className="w-3 h-3" />
|
||||||
出口→Bing
|
出口→测试目标
|
||||||
</div>
|
</div>
|
||||||
<UptimeHistoryBar
|
<UptimeHistoryBar
|
||||||
history={qualityHistoryMap[tunnel.id]}
|
history={qualityHistoryMap[tunnel.id]}
|
||||||
@@ -1259,7 +1284,7 @@ export function TunnelMonitorView({
|
|||||||
<TableColumn>状态</TableColumn>
|
<TableColumn>状态</TableColumn>
|
||||||
<TableColumn>名称</TableColumn>
|
<TableColumn>名称</TableColumn>
|
||||||
<TableColumn>入口→出口</TableColumn>
|
<TableColumn>入口→出口</TableColumn>
|
||||||
<TableColumn>出口→Bing</TableColumn>
|
<TableColumn>出口→测试目标</TableColumn>
|
||||||
<TableColumn>更新时间</TableColumn>
|
<TableColumn>更新时间</TableColumn>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody emptyContent="暂无隧道">
|
<TableBody emptyContent="暂无隧道">
|
||||||
@@ -1294,7 +1319,7 @@ export function TunnelMonitorView({
|
|||||||
type="entryToExit"
|
type="entryToExit"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell title={probeTargetLabel(quality)}>
|
||||||
<UptimeHistoryBar
|
<UptimeHistoryBar
|
||||||
history={qualityHistoryMap[tunnel.id]}
|
history={qualityHistoryMap[tunnel.id]}
|
||||||
latestValue={quality?.exitToBingLatency}
|
latestValue={quality?.exitToBingLatency}
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ interface Tunnel {
|
|||||||
flow: number; // 1: 单向, 2: 双向
|
flow: number; // 1: 单向, 2: 双向
|
||||||
trafficRatio: number;
|
trafficRatio: number;
|
||||||
ipPreference?: string;
|
ipPreference?: string;
|
||||||
|
probeTargetHost?: string;
|
||||||
|
probeTargetPort?: number;
|
||||||
bestExitState?: BestExitState;
|
bestExitState?: BestExitState;
|
||||||
status: number;
|
status: number;
|
||||||
createdTime: string;
|
createdTime: string;
|
||||||
@@ -156,6 +158,8 @@ interface TunnelForm {
|
|||||||
trafficRatio: number;
|
trafficRatio: number;
|
||||||
inIp: string; // 入口IP
|
inIp: string; // 入口IP
|
||||||
ipPreference: string;
|
ipPreference: string;
|
||||||
|
probeTargetHost?: string;
|
||||||
|
probeTargetPort?: number;
|
||||||
status: number;
|
status: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,6 +588,8 @@ export default function TunnelPage() {
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
: "",
|
: "",
|
||||||
ipPreference: tunnel.ipPreference || "",
|
ipPreference: tunnel.ipPreference || "",
|
||||||
|
probeTargetHost: tunnel.probeTargetHost || "",
|
||||||
|
probeTargetPort: tunnel.probeTargetPort || 0,
|
||||||
status: tunnel.status,
|
status: tunnel.status,
|
||||||
});
|
});
|
||||||
setErrors({});
|
setErrors({});
|
||||||
@@ -860,12 +866,18 @@ export default function TunnelPage() {
|
|||||||
.map((ip) => ip.trim())
|
.map((ip) => ip.trim())
|
||||||
.filter((ip) => ip)
|
.filter((ip) => ip)
|
||||||
.join(",");
|
.join(",");
|
||||||
|
const probeTargetHost = (form.probeTargetHost || "").trim();
|
||||||
|
const probeTargetPort = probeTargetHost
|
||||||
|
? Number(form.probeTargetPort || 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
...form,
|
...form,
|
||||||
inIp: inIpString,
|
inIp: inIpString,
|
||||||
outNodeId: cleanedOutNodeId,
|
outNodeId: cleanedOutNodeId,
|
||||||
chainNodes: cleanedChainNodes,
|
chainNodes: cleanedChainNodes,
|
||||||
|
probeTargetHost,
|
||||||
|
probeTargetPort,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = isEdit
|
const response = isEdit
|
||||||
@@ -2323,6 +2335,55 @@ export default function TunnelPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-divider/60 bg-default-50/40 p-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">质量检测目标</div>
|
||||||
|
<p className="text-xs text-default-500 mt-0.5">
|
||||||
|
用于实时隧道质量检测和 best 最优出口评分,留空使用
|
||||||
|
www.bing.com:443
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-[1fr_140px] gap-3">
|
||||||
|
<Input
|
||||||
|
errorMessage={errors.probeTargetHost}
|
||||||
|
isInvalid={!!errors.probeTargetHost}
|
||||||
|
label="Host"
|
||||||
|
placeholder="www.bing.com"
|
||||||
|
value={form.probeTargetHost || ""}
|
||||||
|
variant="bordered"
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
probeTargetHost: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
errorMessage={errors.probeTargetPort}
|
||||||
|
isInvalid={!!errors.probeTargetPort}
|
||||||
|
label="Port"
|
||||||
|
max={65535}
|
||||||
|
min={1}
|
||||||
|
placeholder="443"
|
||||||
|
type="number"
|
||||||
|
value={
|
||||||
|
form.probeTargetPort
|
||||||
|
? String(form.probeTargetPort)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
variant="bordered"
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
probeTargetPort: e.target.value
|
||||||
|
? Number(e.target.value)
|
||||||
|
: 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<h3 className="text-lg font-semibold">入口配置</h3>
|
<h3 className="text-lg font-semibold">入口配置</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ interface TunnelFormInput {
|
|||||||
inNodeId: TunnelChainNode[];
|
inNodeId: TunnelChainNode[];
|
||||||
outNodeId?: TunnelChainNode[];
|
outNodeId?: TunnelChainNode[];
|
||||||
trafficRatio: number;
|
trafficRatio: number;
|
||||||
|
probeTargetHost?: string;
|
||||||
|
probeTargetPort?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TunnelNodeInput {
|
interface TunnelNodeInput {
|
||||||
@@ -26,6 +28,8 @@ export const createTunnelFormDefaults = () => {
|
|||||||
trafficRatio: 1.0,
|
trafficRatio: 1.0,
|
||||||
inIp: "",
|
inIp: "",
|
||||||
ipPreference: "",
|
ipPreference: "",
|
||||||
|
probeTargetHost: "",
|
||||||
|
probeTargetPort: 0,
|
||||||
status: 1,
|
status: 1,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -63,6 +67,21 @@ export const validateTunnelForm = (
|
|||||||
errors.trafficRatio = "流量倍率须大于0,支持小数(如 0.5)";
|
errors.trafficRatio = "流量倍率须大于0,支持小数(如 0.5)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const probeHost = (form.probeTargetHost || "").trim();
|
||||||
|
const probePort = Number(form.probeTargetPort || 0);
|
||||||
|
|
||||||
|
if (probeHost || probePort > 0) {
|
||||||
|
if (!probeHost) {
|
||||||
|
errors.probeTargetHost = "请输入测试目标 Host";
|
||||||
|
} else if (probeHost.includes("://") || /[\s/?#]/.test(probeHost)) {
|
||||||
|
errors.probeTargetHost = "Host 不能包含协议、空格或路径";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(probePort) || probePort < 1 || probePort > 65535) {
|
||||||
|
errors.probeTargetPort = "端口必须是 1-65535";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (form.type === 2) {
|
if (form.type === 2) {
|
||||||
if (!form.outNodeId || form.outNodeId.length === 0) {
|
if (!form.outNodeId || form.outNodeId.length === 0) {
|
||||||
errors.outNodeId = "请至少选择一个出口节点";
|
errors.outNodeId = "请至少选择一个出口节点";
|
||||||
|
|||||||
Reference in New Issue
Block a user