From 4837468d43b6fe48865b2ce926312b66afd10bef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=9A=E5=88=9A?= <225284228a@gmail.com> Date: Thu, 8 Oct 2026 05:51:06 +1300 Subject: [PATCH] =?UTF-8?q?fix(gui/web):=20=E4=BF=AE=E5=A4=8D=E4=B8=A2?= =?UTF-8?q?=E5=8C=85=E7=8E=87=E5=92=8C=20NAT=20=E7=B1=BB=E5=9E=8B=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=20(#2643)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- easytier-proto/src/api.rs | 67 ++++++++++++++ .../frontend-lib/src/components/Status.vue | 42 ++------- .../frontend-lib/src/modules/statusDisplay.ts | 26 +++++- .../frontend-lib/src/types/network.ts | 6 +- .../frontend-lib/tests/status-display.spec.ts | 49 ++++++++++- .../tests/status-rendering.spec.ts | 87 +++++++++++++++++++ .../frontend/src/components/NetworkDetail.vue | 2 +- .../frontend/tests/dashboard.test.mjs | 9 +- 8 files changed, 239 insertions(+), 49 deletions(-) create mode 100644 easytier-web/frontend-lib/tests/status-rendering.spec.ts diff --git a/easytier-proto/src/api.rs b/easytier-proto/src/api.rs index 337e75c1..8b152f3b 100644 --- a/easytier-proto/src/api.rs +++ b/easytier-proto/src/api.rs @@ -540,4 +540,71 @@ mod tests { assert_eq!(pair.get_loss_rate(), Some(0.0)); } + + #[cfg(feature = "json-rpc")] + #[test] + fn peer_route_pair_json_omits_zero_loss_rate_and_preserves_nonzero_loss_rate() { + let pair = PeerRoutePair { + peer: Some(PeerInfo { + conns: vec![ + PeerConnInfo { + conn_id: "zero-loss".to_owned(), + loss_rate: 0.0, + ..Default::default() + }, + PeerConnInfo { + conn_id: "nonzero-loss".to_owned(), + loss_rate: 0.25, + ..Default::default() + }, + ], + ..Default::default() + }), + ..Default::default() + }; + + let json = serde_json::to_value(pair).unwrap(); + let conns = json["peer"]["conns"].as_array().unwrap(); + assert!(!conns[0].as_object().unwrap().contains_key("loss_rate")); + assert_eq!(conns[1]["loss_rate"], serde_json::json!(0.25)); + } + + #[cfg(feature = "json-rpc")] + #[test] + fn peer_route_pair_json_stun_info_uses_enum_names_and_omits_unknown_nat() { + use super::instance::Route; + use crate::proto::common::{NatType, StunInfo}; + + let known = PeerRoutePair { + route: Some(Route { + stun_info: Some(StunInfo { + udp_nat_type: NatType::FullCone as i32, + tcp_nat_type: NatType::NoPat as i32, + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + let unknown = PeerRoutePair { + route: Some(Route { + stun_info: Some(StunInfo::default()), + ..Default::default() + }), + ..Default::default() + }; + + let known_json = serde_json::to_value(known).unwrap(); + assert_eq!( + known_json["route"]["stun_info"]["udp_nat_type"], + serde_json::json!("FullCone") + ); + assert_eq!( + known_json["route"]["stun_info"]["tcp_nat_type"], + serde_json::json!("NoPAT") + ); + + let unknown_json = serde_json::to_value(unknown).unwrap(); + assert_eq!(unknown_json["route"]["stun_info"], serde_json::json!({})); + } } diff --git a/easytier-web/frontend-lib/src/components/Status.vue b/easytier-web/frontend-lib/src/components/Status.vue index 8710f0e1..9412b9c7 100644 --- a/easytier-web/frontend-lib/src/components/Status.vue +++ b/easytier-web/frontend-lib/src/components/Status.vue @@ -5,7 +5,7 @@ import type { RemoteClient } from '../modules/api' import { useI18n } from 'vue-i18n'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { ipv4InetToString, ipv4ToString, ipv6ToString } from '../modules/utils'; -import { latencyMs, lossRate, numericValue, peerConns } from '../modules/statusDisplay'; +import { latencyMs, lossRate, numericValue, peerConns, udpNatTypeName } from '../modules/statusDisplay'; import { Badge, DataTable, Column, Tag, Chip, Button, Dialog, Timeline, Divider, Card, } from 'primevue'; import NetworkChart from './NetworkChart.vue'; import VpnPortalDialog from './VpnPortalDialog.vue'; @@ -144,34 +144,6 @@ interface Chip { icon: string } -// udp nat type -enum NatType { - // has NAT; but own a single public IP, port is not changed - Unknown = 0, - OpenInternet = 1, - NoPAT = 2, - FullCone = 3, - Restricted = 4, - PortRestricted = 5, - Symmetric = 6, - SymUdpFirewall = 7, - SymmetricEasyInc = 8, - SymmetricEasyDec = 9, -}; - -const udpNatTypeStrMap = { - [NatType.Unknown]: 'Unknown', - [NatType.OpenInternet]: 'Open Internet', - [NatType.NoPAT]: 'No PAT', - [NatType.FullCone]: 'Full Cone', - [NatType.Restricted]: 'Restricted', - [NatType.PortRestricted]: 'Port Restricted', - [NatType.Symmetric]: 'Symmetric', - [NatType.SymUdpFirewall]: 'Symmetric UDP Firewall', - [NatType.SymmetricEasyInc]: 'Symmetric Easy Inc', - [NatType.SymmetricEasyDec]: 'Symmetric Easy Dec', -} - const myNodeInfoChips = computed(() => { if (!props.curNetworkInst) return [] @@ -246,10 +218,10 @@ const myNodeInfoChips = computed(() => { } as Chip) } - const udpNatType: NatType = my_node_info.stun_info?.udp_nat_type - if (udpNatType !== undefined) { + const udpNatType = udpNatTypeName(my_node_info.stun_info) + if (udpNatType) { chips.push({ - label: `UDP NAT Type: ${udpNatTypeStrMap[udpNatType]}`, + label: `UDP NAT Type: ${udpNatType}`, icon: '', } as Chip) } @@ -279,11 +251,7 @@ function rxGlobalSum() { } function natType(info: PeerRoutePair): string { - const udpNatType = info.route?.stun_info?.udp_nat_type; - if (udpNatType !== undefined) - return udpNatTypeStrMap[udpNatType as NatType] - - return '' + return udpNatTypeName(info.route?.stun_info) } function isPublicServerRoute(info: PeerRoutePair): boolean { diff --git a/easytier-web/frontend-lib/src/modules/statusDisplay.ts b/easytier-web/frontend-lib/src/modules/statusDisplay.ts index db77e8be..be99cc3c 100644 --- a/easytier-web/frontend-lib/src/modules/statusDisplay.ts +++ b/easytier-web/frontend-lib/src/modules/statusDisplay.ts @@ -1,4 +1,17 @@ -import type { PeerRoutePair } from '../types/network' +import { NatType, type PeerRoutePair, type StunInfo } from '../types/network' + +const udpNatTypeStrMap: Record = { + [NatType.Unknown]: 'Unknown', + [NatType.OpenInternet]: 'Open Internet', + [NatType.NoPAT]: 'No PAT', + [NatType.FullCone]: 'Full Cone', + [NatType.Restricted]: 'Restricted', + [NatType.PortRestricted]: 'Port Restricted', + [NatType.Symmetric]: 'Symmetric', + [NatType.SymUdpFirewall]: 'Symmetric UDP Firewall', + [NatType.SymmetricEasyInc]: 'Symmetric Easy Inc', + [NatType.SymmetricEasyDec]: 'Symmetric Easy Dec', +} export function numericValue(value: unknown): number | undefined { if (typeof value === 'number') @@ -71,7 +84,7 @@ export function latencyMs(info: PeerRoutePair) { export function lossRate(info: PeerRoutePair) { for (const conn of defaultConnFirst(info)) { - const loss = numericValue(conn.loss_rate) + const loss = numericValue(conn.loss_rate ?? 0) if (loss === undefined) continue @@ -80,3 +93,12 @@ export function lossRate(info: PeerRoutePair) { return '' } + +export function udpNatTypeName(stunInfo: StunInfo | undefined) { + if (!stunInfo) + return '' + + const value = stunInfo.udp_nat_type ?? NatType.Unknown + const natType = numericValue(value) ?? NatType[value as keyof typeof NatType] + return udpNatTypeStrMap[natType as NatType] ?? udpNatTypeStrMap[NatType.Unknown] +} diff --git a/easytier-web/frontend-lib/src/types/network.ts b/easytier-web/frontend-lib/src/types/network.ts index 2f4a0c41..a0d9d346 100644 --- a/easytier-web/frontend-lib/src/types/network.ts +++ b/easytier-web/frontend-lib/src/types/network.ts @@ -437,9 +437,9 @@ export interface NodeInfo { } export interface StunInfo { - udp_nat_type: number - tcp_nat_type: number - last_update_time: number + udp_nat_type?: number | string + tcp_nat_type?: number | string + last_update_time?: number | string } export interface Route { diff --git a/easytier-web/frontend-lib/tests/status-display.spec.ts b/easytier-web/frontend-lib/tests/status-display.spec.ts index 68b4fecb..13f6ac2d 100644 --- a/easytier-web/frontend-lib/tests/status-display.spec.ts +++ b/easytier-web/frontend-lib/tests/status-display.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { latencyMs, lossRate } from '../src/modules/statusDisplay' +import { latencyMs, lossRate, udpNatTypeName } from '../src/modules/statusDisplay' import { ipv4ToString, ipv6ToString } from '../src/modules/utils' function peerRoutePair(conns: any[]) { @@ -49,7 +49,7 @@ describe('status display helpers', () => { expect(ipv6ToString({ part4: 1 } as any)).toBe('::1') }) - it('skips missing latency and loss values', () => { + it('skips missing latency and invalid loss values', () => { expect(latencyMs(peerRoutePair([ { conn_id: 'missing', stats: {} }, { conn_id: 'valid', stats: { latency_us: '2500' } }, @@ -61,16 +61,25 @@ describe('status display helpers', () => { ]))).toBe('') expect(lossRate(peerRoutePair([ - { conn_id: 'missing' }, { conn_id: 'valid', loss_rate: '0.25' }, { conn_id: 'invalid', loss_rate: 'unknown' }, ]))).toBe('25%') expect(lossRate(peerRoutePair([ - { conn_id: 'missing' }, { conn_id: 'invalid', loss_rate: 'unknown' }, ]))).toBe('') }) + it('renders omitted protobuf loss as zero only for an existing connection', () => { + expect(lossRate(peerRoutePair([{ conn_id: 'zero' }]))).toBe('0%') + expect(lossRate(peerRoutePair([{ conn_id: 'zero', loss_rate: 0 }]))).toBe('0%') + expect(lossRate(peerRoutePair([ + { conn_id: 'zero' }, + { conn_id: 'lossy', loss_rate: 0.7 }, + ]))).toBe('0%') + expect(lossRate(peerRoutePair([]))).toBe('') + expect(lossRate({ route: {} } as any)).toBe('') + }) + it('prefers the default connection when its metric is valid', () => { const defaultConnId = '00000001-0002-0003-0004-000000000005' const conns = [ @@ -81,4 +90,36 @@ describe('status display helpers', () => { expect(latencyMs(peerRoutePairWithDefaultConn(conns, defaultConnId))).toBe('9ms') expect(lossRate(peerRoutePairWithDefaultConn(conns, defaultConnId))).toBe('50%') }) + + it('keeps an omitted zero loss on the default connection', () => { + const defaultConnId = '00000001-0002-0003-0004-000000000005' + expect(lossRate(peerRoutePairWithDefaultConn([ + { conn_id: 'fallback', loss_rate: 0.25 }, + { conn_id: defaultConnId }, + ], defaultConnId))).toBe('0%') + }) + + it.each([ + [0, 'Unknown', 'Unknown'], + [1, 'OpenInternet', 'Open Internet'], + [2, 'NoPAT', 'No PAT'], + [3, 'FullCone', 'Full Cone'], + [4, 'Restricted', 'Restricted'], + [5, 'PortRestricted', 'Port Restricted'], + [6, 'Symmetric', 'Symmetric'], + [7, 'SymUdpFirewall', 'Symmetric UDP Firewall'], + [8, 'SymmetricEasyInc', 'Symmetric Easy Inc'], + [9, 'SymmetricEasyDec', 'Symmetric Easy Dec'], + ])('renders numeric and protobuf enum NAT type %s', (value, name, label) => { + expect(udpNatTypeName({ udp_nat_type: value })).toBe(label) + expect(udpNatTypeName({ udp_nat_type: name })).toBe(label) + expect(udpNatTypeName({ udp_nat_type: String(value) })).toBe(label) + }) + + it('distinguishes missing STUN data from default or unrecognized NAT types', () => { + expect(udpNatTypeName(undefined)).toBe('') + expect(udpNatTypeName({})).toBe('Unknown') + expect(udpNatTypeName({ udp_nat_type: 100 })).toBe('Unknown') + expect(udpNatTypeName({ udp_nat_type: 'FutureNatType' })).toBe('Unknown') + }) }) diff --git a/easytier-web/frontend-lib/tests/status-rendering.spec.ts b/easytier-web/frontend-lib/tests/status-rendering.spec.ts new file mode 100644 index 00000000..f8985c47 --- /dev/null +++ b/easytier-web/frontend-lib/tests/status-rendering.spec.ts @@ -0,0 +1,87 @@ +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import { afterEach, describe, expect, it, vi } from 'vitest' +import Status from '../src/components/Status.vue' +import type { NetworkInstance } from '../src/types/network' + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) + +const wrappers: VueWrapper[] = [] + +afterEach(() => { + wrappers.splice(0).forEach(wrapper => wrapper.unmount()) +}) + +async function render(localStun: unknown, peerStun: unknown, lossRate?: number) { + const instance = { + instance_id: '12345678-9abc-def0-fedc-ba9876543210', + running: true, + detail: { + my_node_info: { + hostname: 'local-node', + version: 'test', + peer_id: 1, + stun_info: localStun, + }, + peer_route_pairs: [{ + route: { + hostname: 'remote-node', + version: 'test', + cost: 1, + stun_info: peerStun, + }, + peer: { + conns: [{ + conn_id: 'connection', + ...(lossRate === undefined ? {} : { loss_rate: lossRate }), + }], + }, + }], + }, + } as unknown as NetworkInstance + const wrapper = mount(Status, { + props: { + curNetworkInst: instance, + api: { get_network_config: vi.fn(async () => ({})) } as any, + }, + global: { + plugins: [PrimeVue], + directives: { tooltip: () => {} }, + stubs: { HumanEvent: true, NetworkChart: true, VpnPortalDialog: true }, + }, + }) + wrappers.push(wrapper) + await flushPromises() + return wrapper +} + +describe('status protobuf JSON rendering', () => { + it('renders enum names and omitted zero loss in the real peer table', async () => { + const wrapper = await render({ udp_nat_type: 'FullCone' }, { udp_nat_type: 'PortRestricted' }) + const rows = wrapper.findAll('tbody tr') + expect(rows[0].text()).toContain('Full Cone') + expect(rows[0].text()).not.toContain('0%') + expect(rows[1].text()).toContain('Port Restricted') + expect(rows[1].text()).toContain('0%') + await wrapper.findAll('button').find(button => button.text() === 'show_node_details')!.trigger('click') + expect(wrapper.text()).toContain('UDP NAT Type: Full Cone') + expect(wrapper.text()).not.toContain('UDP NAT Type: undefined') + }) + + it('renders omitted enum defaults as Unknown but leaves missing STUN data blank', async () => { + const wrapper = await render({}, undefined, 0.25) + const rows = wrapper.findAll('tbody tr') + expect(rows[0].text()).toContain('Unknown') + expect(rows[1].text()).not.toContain('Unknown') + expect(rows[1].text()).toContain('25%') + expect(wrapper.text()).toContain('UDP NAT Type: Unknown') + }) + + it('preserves numeric NAT values from older backends', async () => { + const wrapper = await render({ udp_nat_type: 1 }, { udp_nat_type: 6 }, 0.5) + const rows = wrapper.findAll('tbody tr') + expect(rows[0].text()).toContain('Open Internet') + expect(rows[1].text()).toContain('Symmetric') + expect(rows[1].text()).toContain('50%') + }) +}) diff --git a/easytier-web/frontend/src/components/NetworkDetail.vue b/easytier-web/frontend/src/components/NetworkDetail.vue index c482fb6c..69a7020a 100644 --- a/easytier-web/frontend/src/components/NetworkDetail.vue +++ b/easytier-web/frontend/src/components/NetworkDetail.vue @@ -1499,7 +1499,7 @@ const switchTab = async (tab: string) => { {{ data.conn.stats ? latencyLabel(data.conn.stats.latency_us ? data.conn.stats.latency_us / 1000 : undefined) : '—' }}