mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-08 10:56:13 -08:00
fix(gui/web): 修复丢包率和 NAT 类型显示 (#2643)
This commit is contained in:
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();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user