fix(gui/web): 修复丢包率和 NAT 类型显示 (#2643)

This commit is contained in:
刚刚 authored and GitHub committed 2026-10-08 00:51:06 +08:00
1 parent eb32d2a930
commit 4837468d43
8 files changed
+239 -49

No files matched your search

@@ -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 {
@@ -1,4 +1,17 @@
import type { PeerRoutePair } from '../types/network'
import { NatType, type PeerRoutePair, type StunInfo } from '../types/network'
const udpNatTypeStrMap: Record<NatType, string> = {
[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]
}
@@ -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 {
@@ -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')
})
})
@@ -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%')
})
})
@@ -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) : '—' }}
</template></Column>
<Column :header="t('web.network_detail.loss_rate')"><template #body="{ data }">
{{ data.conn.loss_rate != null ? (data.conn.loss_rate * 100).toFixed(1) + '%' : '—' }}
{{ ((data.conn.loss_rate ?? 0) * 100).toFixed(1) + '%' }}
</template></Column>
<Column :header="t('web.network_detail.traffic')"><template #body="{ data }">
<span class="font-mono text-xs">↓{{ ((data.conn.stats?.rx_bytes ?? 0) / 1024).toFixed(0) }}K ↑{{ ((data.conn.stats?.tx_bytes ?? 0) / 1024).toFixed(0) }}K</span>
@@ -562,14 +562,17 @@ test('node detail groups populated peers, routes, connections and ACL stats', as
const { page } = await open(t, `/h/networks/${id(100)}`, { viewport: { width: 390, height: 844 }, colorScheme: 'dark' }, state => {
state.networks[0].network_id = id(100);
state.nodeRoutes = [{ peer_id: 2, hostname: 'Build server', ipv4_addr: { address: { addr: 175005442 }, network_length: 24 }, cost: 1, path_latency: 8, proxy_cidrs: [], version: '2.4.5', next_hop_peer_id: 2 }];
state.nodePeers = [{ peer_id: 2, conns: [{ conn_id: 'conn-1', tunnel: { tunnel_type: 'tcp', remote_addr: { url: 'tcp://192.0.2.11:11010' } }, stats: { latency_us: 8000, rx_bytes: 1024, tx_bytes: 2048 }, loss_rate: 0 }] }];
state.nodePeers = [{ peer_id: 2, conns: [
{ conn_id: 'conn-1', tunnel: { tunnel_type: 'tcp', remote_addr: { url: 'tcp://192.0.2.11:11010' } }, stats: { latency_us: 8000, rx_bytes: 1024, tx_bytes: 2048 } },
{ conn_id: 'conn-2', tunnel: { tunnel_type: 'udp', remote_addr: { url: 'udp://192.0.2.11:11010' } }, stats: { latency_us: 9000 }, loss_rate: 0.125 },
] }];
state.nodeAclStats = [{ rule: { name: 'Allow build server' }, stat: { packet_count: 42, byte_count: 4096 } }];
});
await page.locator('.mobile-list').getByRole('button', { name: 'Node Detail' }).click();
const drawer = page.locator('.console-node-drawer');
await drawer.locator('.node-drawer-mobile-peer').getByText('Build server').waitFor();
await page.waitForFunction(() => Math.abs(document.querySelector('.console-node-drawer').getBoundingClientRect().right - innerWidth) < 1);
assert.deepEqual(await drawer.locator('.node-drawer-metric strong').allTextContents().then(values => values.map(s => s.trim())), ['Running', '1', '1', '1']);
assert.deepEqual(await drawer.locator('.node-drawer-metric strong').allTextContents().then(values => values.map(s => s.trim())), ['Running', '1', '1', '2']);
assert.equal(await drawer.getByRole('tab').count(), 2);
const drawerWidth = await drawer.evaluate(el => ({ drawer: el.getBoundingClientRect().width, viewport: innerWidth }));
assert.ok(drawerWidth.drawer <= drawerWidth.viewport + 1, JSON.stringify(drawerWidth));
@@ -580,6 +583,8 @@ test('node detail groups populated peers, routes, connections and ACL stats', as
await drawer.locator('details').filter({ hasText: 'Routes' }).locator('summary').click();
await drawer.getByText('10.110.95.2/24').first().waitFor();
await drawer.locator('details').filter({ hasText: 'Connections' }).locator('summary').click();
await drawer.getByRole('cell', { name: '0.0%', exact: true }).waitFor();
await drawer.getByRole('cell', { name: '12.5%', exact: true }).waitFor();
await drawer.locator('details').filter({ hasText: 'ACL Stats' }).locator('summary').click();
await drawer.getByRole('cell', { name: 'Allow build server' }).waitFor();
});