mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-08-07 13:09:46 +00:00
fix(web): preserve managed config and status compatibility (#2389)
Fix web/frontend compat bugs in managed config & runtime status - Preserve [[peer]].peer_public_key when TOML configs round-trip through the web/managed NetworkConfig path - Keep old peer_urls clients working while adding structured peer metadata for new clients - Make frontend protobuf JSON normalization preserve omitted-field semantics instead of turning missing data into misleading defaults - Harden runtime status rendering against omitted or string-encoded backend fields - Expose peer-route feature flags in the web status UI
This commit is contained in:
@@ -114,6 +114,12 @@ function allFieldFixture() {
|
||||
networking_method: NetworkingMethod.Manual,
|
||||
public_server_url: 'tcp://public.example:11010',
|
||||
peer_urls: [' tcp://peer-a:11010 ', '', 'udp://peer-b:11010'],
|
||||
peers: [
|
||||
{
|
||||
uri: 'tcp://peer-a:11010',
|
||||
peer_public_key: 'peer-a-public-key',
|
||||
},
|
||||
],
|
||||
proxy_cidrs: ['10.10.0.0/16', '192.168.2.0/24->10.99.0.0/24'],
|
||||
enable_vpn_portal: true,
|
||||
vpn_portal_listen_port: 23000,
|
||||
@@ -259,6 +265,8 @@ function assertFullFieldRoundTrip() {
|
||||
assert.equal(backend.networking_method, 'Manual')
|
||||
assert.equal(backend.public_server_url, '')
|
||||
assert.deepEqual(backend.peer_urls, ['tcp://peer-a:11010', 'udp://peer-b:11010'])
|
||||
assert.equal(backend.peers[0].peer_public_key, 'peer-a-public-key')
|
||||
assert.deepEqual(backend.peers[1], { uri: 'udp://peer-b:11010' })
|
||||
assert.equal(backend.data_compress_algo, 'Zstd')
|
||||
assert.equal(backend.instance_recv_bps_limit, '9007199254740993')
|
||||
assert.equal(backend.secure_mode.enabled, true)
|
||||
@@ -415,6 +423,59 @@ function assertNetworkingMethodNormalization() {
|
||||
})
|
||||
|
||||
assert.deepEqual(missing.peer_urls, ['tcp://one', 'udp://two'])
|
||||
|
||||
const publicServerMissingUrl = normalizeNetworkConfig({
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
networking_method: 'PublicServer',
|
||||
public_server_url: '',
|
||||
peer_urls: ['tcp://manual.example:11010'],
|
||||
})
|
||||
|
||||
assert.deepEqual(publicServerMissingUrl.peer_urls, [])
|
||||
}
|
||||
|
||||
function assertPeerPublicKeysPreserved() {
|
||||
const normalized = normalizeNetworkConfig({
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
peer_urls: [],
|
||||
peers: [
|
||||
{
|
||||
uri: ' tcp://peer-a:11010 ',
|
||||
peer_public_key: 'peer-a-public-key',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
assert.deepEqual(normalized.peer_urls, ['tcp://peer-a:11010'])
|
||||
assert.deepEqual(normalized.peers, [
|
||||
{
|
||||
uri: 'tcp://peer-a:11010',
|
||||
peer_public_key: 'peer-a-public-key',
|
||||
},
|
||||
])
|
||||
|
||||
const unchangedUrl = toBackendNetworkConfig({
|
||||
...normalized,
|
||||
peer_urls: ['tcp://peer-a:11010', 'tcp://peer-b:11010'],
|
||||
})
|
||||
|
||||
assert.equal(unchangedUrl.peers[0].peer_public_key, 'peer-a-public-key')
|
||||
assert.deepEqual(unchangedUrl.peers[1], { uri: 'tcp://peer-b:11010' })
|
||||
|
||||
const changedUrl = toBackendNetworkConfig({
|
||||
...normalized,
|
||||
peer_urls: ['tcp://peer-c:11010'],
|
||||
})
|
||||
|
||||
assert.deepEqual(changedUrl.peers, [{ uri: 'tcp://peer-c:11010' }])
|
||||
|
||||
const clearedUrls = toBackendNetworkConfig({
|
||||
...normalized,
|
||||
peer_urls: [],
|
||||
})
|
||||
|
||||
assert.deepEqual(clearedUrls.peer_urls ?? [], [])
|
||||
assert.deepEqual(clearedUrls.peers ?? [], [])
|
||||
}
|
||||
|
||||
function assertNumberBoundaries() {
|
||||
@@ -469,6 +530,7 @@ const tests = [
|
||||
assertEnumCompatibility,
|
||||
assertAclDefaultsAndExplicitZero,
|
||||
assertNetworkingMethodNormalization,
|
||||
assertPeerPublicKeysPreserved,
|
||||
assertNumberBoundaries,
|
||||
]
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ const currentNetworkConfig = ref<NetworkTypes.NetworkConfig | undefined>(undefin
|
||||
const listInstanceIdResponse = ref<Api.ListNetworkInstanceIdResponse | undefined>(undefined);
|
||||
|
||||
const isRunning = (instanceId: string) => {
|
||||
return listInstanceIdResponse.value?.running_inst_ids.map(Utils.UuidToStr).includes(instanceId);
|
||||
return (listInstanceIdResponse.value?.running_inst_ids ?? []).map(Utils.UuidToStr).includes(instanceId);
|
||||
}
|
||||
|
||||
const networkMetaCache = ref<Record<string, Api.NetworkMeta>>({});
|
||||
@@ -46,7 +46,7 @@ const loadNetworkMetas = async (instanceIds: string[]) => {
|
||||
|
||||
try {
|
||||
const response = await props.api.get_network_metas(missingIds);
|
||||
Object.assign(networkMetaCache.value, response.metas);
|
||||
Object.assign(networkMetaCache.value, response.metas ?? {});
|
||||
} catch (e) {
|
||||
console.error("Failed to load network metas", e);
|
||||
}
|
||||
@@ -80,8 +80,8 @@ const updateInstanceList = () => {
|
||||
let insts = new Set<string>();
|
||||
let t = listInstanceIdResponse.value;
|
||||
if (t) {
|
||||
t.running_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u)));
|
||||
t.disabled_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u)));
|
||||
(t.running_inst_ids ?? []).forEach((u) => insts.add(Utils.UuidToStr(u)));
|
||||
(t.disabled_inst_ids ?? []).forEach((u) => insts.add(Utils.UuidToStr(u)));
|
||||
}
|
||||
|
||||
const newList = Array.from(insts).map((instance: string) => {
|
||||
@@ -149,7 +149,7 @@ const networkIsDisabled = computed(() => {
|
||||
if (!selectedInstanceId.value) {
|
||||
return false;
|
||||
}
|
||||
return listInstanceIdResponse.value?.disabled_inst_ids.map(Utils.UuidToStr).includes(selectedInstanceId.value?.uuid);
|
||||
return (listInstanceIdResponse.value?.disabled_inst_ids ?? []).map(Utils.UuidToStr).includes(selectedInstanceId.value?.uuid);
|
||||
});
|
||||
watch(networkIsDisabled, async (newVal, oldVal) => {
|
||||
if (newVal !== oldVal && newVal === true) {
|
||||
@@ -287,17 +287,35 @@ const loadNetworkInstanceIds = async () => {
|
||||
}
|
||||
|
||||
const loadCurrentNetworkInfo = async () => {
|
||||
if (!selectedInstanceId.value) {
|
||||
const selected = selectedInstanceId.value?.uuid;
|
||||
if (!selected) {
|
||||
curNetworkInfo.value = null;
|
||||
return;
|
||||
}
|
||||
if (!needShowNetworkStatus.value) {
|
||||
curNetworkInfo.value = null;
|
||||
return;
|
||||
}
|
||||
if (curNetworkInfo.value?.instance_id !== selected) {
|
||||
curNetworkInfo.value = null;
|
||||
}
|
||||
|
||||
let network_info = await props.api.get_network_info(selected);
|
||||
if (selectedInstanceId.value?.uuid !== selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
let network_info = await props.api.get_network_info(selectedInstanceId.value.uuid);
|
||||
if (!network_info) {
|
||||
curNetworkInfo.value = {
|
||||
instance_id: selected,
|
||||
running: false,
|
||||
error_msg: t('web.device_management.network_info_unavailable'),
|
||||
} as NetworkTypes.NetworkInstance;
|
||||
return;
|
||||
}
|
||||
|
||||
curNetworkInfo.value = {
|
||||
instance_id: selectedInstanceId.value.uuid,
|
||||
instance_id: selected,
|
||||
running: network_info?.running ?? false,
|
||||
error_msg: network_info?.error_msg ?? '',
|
||||
detail: network_info,
|
||||
@@ -492,7 +510,7 @@ onUnmounted(() => {
|
||||
<div class="flex items-center min-w-0">
|
||||
<div class="mr-4 min-w-0 flex-1">
|
||||
<span class="truncate block">{{ t('network_name') }}: {{
|
||||
slotProps.option.meta.network_name }}</span>
|
||||
slotProps.option.meta?.network_name ?? slotProps.option.uuid }}</span>
|
||||
</div>
|
||||
<Tag class="my-auto leading-3 shrink-0"
|
||||
:severity="isRunning(slotProps.option.uuid) ? 'success' : 'info'"
|
||||
@@ -569,10 +587,13 @@ onUnmounted(() => {
|
||||
<h2 class="text-xl font-medium">{{ t('web.device_management.network_status') }}</h2>
|
||||
</div>
|
||||
|
||||
<Status v-if="(curNetworkInfo?.error_msg ?? '') === ''" v-bind:cur-network-inst="curNetworkInfo"
|
||||
<Status v-if="curNetworkInfo && curNetworkInfo.error_msg === ''" v-bind:cur-network-inst="curNetworkInfo"
|
||||
class="mb-4">
|
||||
</Status>
|
||||
<Message v-else severity="error" class="mb-4">{{ curNetworkInfo?.error_msg }}</Message>
|
||||
<Message v-else-if="curNetworkInfo?.error_msg" severity="error" class="mb-4">{{
|
||||
curNetworkInfo.error_msg }}</Message>
|
||||
<Message v-else severity="info" class="mb-4">{{ t('web.device_management.loading_network_status') }}
|
||||
</Message>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<Button @click="stopNetwork" :disabled="!currentNetworkControl.deletable.value"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeAgo } from '@vueuse/core'
|
||||
import { IPv4 } from 'ip-num/IPNumber'
|
||||
import { NetworkInstance, type TunnelInfo, type NodeInfo, type PeerRoutePair } from '../types/network'
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { ipv4InetToString, ipv4ToString, ipv6ToString } from '../modules/utils';
|
||||
import { latencyMs, lossRate, numericValue, peerConns } from '../modules/statusDisplay';
|
||||
import { Badge, DataTable, Column, Tag, Chip, Button, Dialog, ScrollPanel, Timeline, Divider, Card, } from 'primevue';
|
||||
import NetworkChart from './NetworkChart.vue';
|
||||
|
||||
@@ -39,8 +39,8 @@ function routeCost(info: any) {
|
||||
return '?'
|
||||
}
|
||||
|
||||
function resolveObjPath(path: string, obj = globalThis, separator = '.') {
|
||||
const properties = Array.isArray(path) ? path : path.split(separator)
|
||||
function resolveObjPath(path: string, obj: any = globalThis, separator = '.') {
|
||||
const properties = path.split(separator)
|
||||
return properties.reduce((prev, curr) => prev?.[curr], obj)
|
||||
}
|
||||
|
||||
@@ -48,10 +48,17 @@ function statsCommon(info: any, field: string): number | undefined {
|
||||
if (!info.peer)
|
||||
return undefined
|
||||
|
||||
const conns = info.peer.conns
|
||||
return conns.reduce((acc: number, conn: any) => {
|
||||
return acc + resolveObjPath(field, conn)
|
||||
}, 0)
|
||||
let sum = 0
|
||||
let hasValue = false
|
||||
for (const conn of peerConns(info)) {
|
||||
const value = numericValue(resolveObjPath(field, conn))
|
||||
if (value === undefined)
|
||||
continue
|
||||
|
||||
sum += value
|
||||
hasValue = true
|
||||
}
|
||||
return hasValue ? sum : undefined
|
||||
}
|
||||
|
||||
function humanFileSize(bytes: number, si = false, dp = 1) {
|
||||
@@ -74,14 +81,6 @@ function humanFileSize(bytes: number, si = false, dp = 1) {
|
||||
return `${bytes.toFixed(dp)} ${units[u]}`
|
||||
}
|
||||
|
||||
function latencyMs(info: PeerRoutePair) {
|
||||
let lat_us_sum = statsCommon(info, 'stats.latency_us')
|
||||
if (lat_us_sum === undefined)
|
||||
return ''
|
||||
lat_us_sum = lat_us_sum / 1000 / info.peer!.conns.length
|
||||
return `${lat_us_sum % 1 > 0 ? Math.round(lat_us_sum) + 1 : Math.round(lat_us_sum)}ms`
|
||||
}
|
||||
|
||||
function txBytes(info: PeerRoutePair) {
|
||||
const tx = statsCommon(info, 'stats.tx_bytes')
|
||||
return tx ? humanFileSize(tx) : ''
|
||||
@@ -92,11 +91,6 @@ function rxBytes(info: PeerRoutePair) {
|
||||
return rx ? humanFileSize(rx) : ''
|
||||
}
|
||||
|
||||
function lossRate(info: PeerRoutePair) {
|
||||
const lossRate = statsCommon(info, 'loss_rate')
|
||||
return lossRate !== undefined ? `${Math.round(lossRate * 100)}%` : ''
|
||||
}
|
||||
|
||||
function version(info: PeerRoutePair) {
|
||||
return info.route.version === '' ? 'unknown' : info.route.version
|
||||
}
|
||||
@@ -105,7 +99,7 @@ function ipFormat(info: PeerRoutePair) {
|
||||
const ip = info.route.ipv4_addr
|
||||
if (typeof ip === 'string')
|
||||
return ip
|
||||
return ip ? `${IPv4.fromNumber(ip.address.addr)}/${ip.network_length}` : ''
|
||||
return ip ? ipv4InetToString(ip) : ''
|
||||
}
|
||||
|
||||
function oneTunnelProto(tunnel?: TunnelInfo): string {
|
||||
@@ -131,7 +125,7 @@ function oneTunnelProto(tunnel?: TunnelInfo): string {
|
||||
}
|
||||
|
||||
function tunnelProto(info: PeerRoutePair) {
|
||||
return [...new Set(info.peer?.conns.map(c => oneTunnelProto(c.tunnel)))].join(',')
|
||||
return [...new Set(peerConns(info).map(c => oneTunnelProto(c.tunnel)))].join(',')
|
||||
}
|
||||
|
||||
const myNodeInfo = computed(() => {
|
||||
@@ -206,7 +200,7 @@ const myNodeInfoChips = computed(() => {
|
||||
|
||||
// local ipv4s
|
||||
const local_ipv4s = my_node_info.ips?.interface_ipv4s
|
||||
for (const [idx, ip] of local_ipv4s?.entries()) {
|
||||
for (const [idx, ip] of local_ipv4s?.entries() ?? []) {
|
||||
chips.push({
|
||||
label: `Local IPv4 ${idx}: ${ipv4ToString(ip)}`,
|
||||
icon: '',
|
||||
@@ -215,7 +209,7 @@ const myNodeInfoChips = computed(() => {
|
||||
|
||||
// local ipv6s
|
||||
const local_ipv6s = my_node_info.ips?.interface_ipv6s
|
||||
for (const [idx, ip] of local_ipv6s?.entries()) {
|
||||
for (const [idx, ip] of local_ipv6s?.entries() ?? []) {
|
||||
chips.push({
|
||||
label: `Local IPv6 ${idx}: ${ipv6ToString(ip)}`,
|
||||
icon: '',
|
||||
@@ -226,7 +220,7 @@ const myNodeInfoChips = computed(() => {
|
||||
const public_ip = my_node_info.ips?.public_ipv4
|
||||
if (public_ip) {
|
||||
chips.push({
|
||||
label: `Public IP: ${IPv4.fromNumber(public_ip.addr)}`,
|
||||
label: `Public IP: ${ipv4ToString(public_ip)}`,
|
||||
icon: '',
|
||||
} as Chip)
|
||||
}
|
||||
@@ -241,7 +235,7 @@ const myNodeInfoChips = computed(() => {
|
||||
|
||||
// listeners:
|
||||
const listeners = my_node_info.listeners
|
||||
for (const [idx, listener] of listeners?.entries()) {
|
||||
for (const [idx, listener] of listeners?.entries() ?? []) {
|
||||
chips.push({
|
||||
label: `Listener ${idx}: ${listener.url}`,
|
||||
icon: '',
|
||||
@@ -288,6 +282,14 @@ function natType(info: PeerRoutePair): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function isPublicServerRoute(info: PeerRoutePair): boolean {
|
||||
return info.route?.feature_flag?.is_public_server ?? false
|
||||
}
|
||||
|
||||
function shouldAvoidRelayData(info: PeerRoutePair): boolean {
|
||||
return info.route?.feature_flag?.avoid_relay_data ?? false
|
||||
}
|
||||
|
||||
const peerCount = computed(() => {
|
||||
if (!peerRouteInfos.value)
|
||||
return 0
|
||||
@@ -342,7 +344,7 @@ function showEventLogs() {
|
||||
if (!detail)
|
||||
return
|
||||
|
||||
dialogContent.value = detail.events.map((event: string) => JSON.parse(event))
|
||||
dialogContent.value = detail.events?.map((event: string) => JSON.parse(event)) ?? []
|
||||
dialogHeader.value = 'event_log'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@@ -434,16 +436,16 @@ function showEventLogs() {
|
||||
<Column :field="ipFormat" :header="t('virtual_ipv4')" />
|
||||
<Column :header="t('hostname')">
|
||||
<template #body="slotProps">
|
||||
<div v-if="!slotProps.data.route.cost || !slotProps.data.route.feature_flag.is_public_server"
|
||||
<div v-if="!slotProps.data.route.cost || !isPublicServerRoute(slotProps.data)"
|
||||
v-tooltip="slotProps.data.route.hostname">
|
||||
{{
|
||||
slotProps.data.route.hostname }}
|
||||
</div>
|
||||
<div v-else v-tooltip="slotProps.data.route.hostname" class="space-x-1">
|
||||
<Tag v-if="slotProps.data.route.feature_flag.is_public_server" severity="info" value="Info">
|
||||
<Tag v-if="isPublicServerRoute(slotProps.data)" severity="info" value="Info">
|
||||
{{ t('status.server') }}
|
||||
</Tag>
|
||||
<Tag v-if="slotProps.data.route.feature_flag.avoid_relay_data" severity="warn" value="Warn">
|
||||
<Tag v-if="shouldAvoidRelayData(slotProps.data)" severity="warn" value="Warn">
|
||||
{{ t('status.relay') }}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Button, Column, DataTable, Divider, InputText, Select, SelectButton, ToggleButton } from 'primevue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AclAction, AclChain, AclChainType, AclProtocol, AclRule } from '../../types/network'
|
||||
import { AclAction, AclChain, AclChainType, AclProtocol, AclRule, ensureAclChain, ensureAclRuleLists } from '../../types/network'
|
||||
import AclRuleDialog from './AclRuleDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -13,7 +13,11 @@ const chain = defineModel<AclChain>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
watch(() => chain.value.rules, (newRules) => {
|
||||
function rules() {
|
||||
return ensureAclChain(chain.value).rules
|
||||
}
|
||||
|
||||
watch(() => rules(), (newRules) => {
|
||||
if (!newRules) return
|
||||
const isSorted = newRules.every((rule, i) => i === 0 || (rule.priority || 0) <= (newRules[i - 1].priority || 0))
|
||||
if (!isSorted) {
|
||||
@@ -60,7 +64,7 @@ function addRule() {
|
||||
editingRule.value = {
|
||||
name: '',
|
||||
description: '',
|
||||
priority: chain.value.rules.length,
|
||||
priority: rules().length,
|
||||
enabled: true,
|
||||
protocol: AclProtocol.Any,
|
||||
ports: [],
|
||||
@@ -79,28 +83,31 @@ function addRule() {
|
||||
|
||||
function editRule(index: number) {
|
||||
editingRuleIndex.value = index
|
||||
editingRule.value = JSON.parse(JSON.stringify(chain.value.rules[index]))
|
||||
editingRule.value = ensureAclRuleLists(JSON.parse(JSON.stringify(rules()[index])))
|
||||
showRuleDialog.value = true
|
||||
}
|
||||
|
||||
function deleteRule(index: number) {
|
||||
chain.value.rules.splice(index, 1)
|
||||
rules().splice(index, 1)
|
||||
}
|
||||
|
||||
function saveRule(rule: AclRule) {
|
||||
const chainRules = rules()
|
||||
ensureAclRuleLists(rule)
|
||||
if (editingRuleIndex.value === -1) {
|
||||
chain.value.rules.push(rule)
|
||||
chainRules.push(rule)
|
||||
} else {
|
||||
chain.value.rules[editingRuleIndex.value] = rule
|
||||
chainRules[editingRuleIndex.value] = rule
|
||||
}
|
||||
chain.value.rules.sort((a, b) => (b.priority || 0) - (a.priority || 0))
|
||||
chainRules.sort((a, b) => (b.priority || 0) - (a.priority || 0))
|
||||
}
|
||||
|
||||
function onRowReorder(event: any) {
|
||||
chain.value.rules = event.value
|
||||
chain.value.rules = event.value ?? []
|
||||
const chainRules = rules()
|
||||
// Update priorities based on new order (higher priority at top)
|
||||
chain.value.rules.forEach((rule, index) => {
|
||||
rule.priority = chain.value.rules.length - index - 1
|
||||
chainRules.forEach((rule, index) => {
|
||||
rule.priority = chainRules.length - index - 1
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -143,7 +150,7 @@ function onRowReorder(event: any) {
|
||||
<Button icon="pi pi-plus" :label="t('acl.add_rule')" severity="success" size="small" @click="addRule" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="chain.rules" @row-reorder="onRowReorder" responsiveLayout="scroll">
|
||||
<DataTable :value="rules()" @row-reorder="onRowReorder" responsiveLayout="scroll">
|
||||
<Column rowReorder headerStyle="width: 3rem" />
|
||||
<Column field="enabled" :header="t('acl.rule.enabled')">
|
||||
<template #body="{ data }">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Column, DataTable, Dialog, InputText, MultiSelect, Password } from 'primevue';
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { GroupIdentity, GroupInfo } from '../../types/network';
|
||||
import { GroupIdentity, GroupInfo, ensureGroupInfo } from '../../types/network';
|
||||
|
||||
const props = defineProps<{
|
||||
groupNames?: string[]
|
||||
@@ -18,6 +18,17 @@ const editingGroupIndex = ref(-1)
|
||||
const showGroupDialog = ref(false)
|
||||
const oldGroupName = ref('')
|
||||
|
||||
function groupInfo() {
|
||||
return ensureGroupInfo(group.value)
|
||||
}
|
||||
|
||||
const members = computed({
|
||||
get: () => groupInfo().members,
|
||||
set: value => {
|
||||
groupInfo().members = value
|
||||
},
|
||||
})
|
||||
|
||||
function addGroup() {
|
||||
editingGroupIndex.value = -1
|
||||
editingGroup.value = {
|
||||
@@ -30,13 +41,13 @@ function addGroup() {
|
||||
|
||||
function editGroup(index: number) {
|
||||
editingGroupIndex.value = index
|
||||
editingGroup.value = JSON.parse(JSON.stringify(group.value.declares[index]))
|
||||
editingGroup.value = JSON.parse(JSON.stringify(groupInfo().declares[index]))
|
||||
oldGroupName.value = editingGroup.value?.group_name || ''
|
||||
showGroupDialog.value = true
|
||||
}
|
||||
|
||||
function deleteGroup(index: number) {
|
||||
group.value.declares.splice(index, 1)
|
||||
groupInfo().declares.splice(index, 1)
|
||||
}
|
||||
|
||||
function saveGroup() {
|
||||
@@ -44,15 +55,15 @@ function saveGroup() {
|
||||
const newName = editingGroup.value.group_name
|
||||
|
||||
if (editingGroupIndex.value === -1) {
|
||||
group.value.declares.push(editingGroup.value)
|
||||
groupInfo().declares.push(editingGroup.value)
|
||||
} else {
|
||||
if (oldGroupName.value && oldGroupName.value !== newName) {
|
||||
// Sync in members
|
||||
group.value.members = group.value.members.map(m => m === oldGroupName.value ? newName : m)
|
||||
groupInfo().members = groupInfo().members.map(m => m === oldGroupName.value ? newName : m)
|
||||
// Notify parent to sync in rules
|
||||
emit('rename-group', { oldName: oldGroupName.value, newName })
|
||||
}
|
||||
group.value.declares[editingGroupIndex.value] = editingGroup.value
|
||||
groupInfo().declares[editingGroupIndex.value] = editingGroup.value
|
||||
}
|
||||
showGroupDialog.value = false
|
||||
}
|
||||
@@ -70,7 +81,7 @@ function saveGroup() {
|
||||
<Button icon="pi pi-plus" :label="t('web.common.add')" severity="success" @click="addGroup" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="group.declares" responsiveLayout="scroll">
|
||||
<DataTable :value="groupInfo().declares" responsiveLayout="scroll">
|
||||
<Column field="group_name" :header="t('acl.group.name')" />
|
||||
<Column field="group_secret" :header="t('acl.group.secret')">
|
||||
<template #body="{ data }">
|
||||
@@ -90,7 +101,7 @@ function saveGroup() {
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-bold text-lg">{{ t('acl.group.members') }}</label>
|
||||
<MultiSelect v-model="group.members" :options="props.groupNames" multiple fluid filter
|
||||
<MultiSelect v-model="members" :options="props.groupNames" multiple fluid filter
|
||||
:placeholder="t('acl.group.members')" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Button, Menu, Tab, TabList, TabPanel, TabPanels, Tabs } from 'primevue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Acl, AclAction, AclChainType } from '../../types/network'
|
||||
import { Acl, AclAction, AclChainType, ensureAclV1 } from '../../types/network'
|
||||
import AclChainEditor from './AclChainEditor.vue'
|
||||
import AclGroupEditor from './AclGroupEditor.vue'
|
||||
|
||||
@@ -12,6 +12,7 @@ const { t } = useI18n()
|
||||
|
||||
const activeTab = ref(0)
|
||||
const menu = ref()
|
||||
const aclV1 = computed(() => ensureAclV1(acl.value))
|
||||
|
||||
const addMenuModel = ref([
|
||||
{ label: () => t('acl.inbound'), command: () => addChain(AclChainType.Inbound) },
|
||||
@@ -20,10 +21,6 @@ const addMenuModel = ref([
|
||||
])
|
||||
|
||||
function addChain(type: AclChainType) {
|
||||
if (!acl.value.acl_v1) {
|
||||
acl.value.acl_v1 = { chains: [], group: { declares: [], members: [] } }
|
||||
}
|
||||
|
||||
let defaultName = ''
|
||||
switch (type) {
|
||||
case AclChainType.Inbound: defaultName = 'Inbound'; break;
|
||||
@@ -31,7 +28,7 @@ function addChain(type: AclChainType) {
|
||||
case AclChainType.Forward: defaultName = 'Forward'; break;
|
||||
}
|
||||
|
||||
acl.value.acl_v1.chains.push({
|
||||
aclV1.value.chains.push({
|
||||
name: defaultName,
|
||||
chain_type: type,
|
||||
description: '',
|
||||
@@ -40,21 +37,20 @@ function addChain(type: AclChainType) {
|
||||
default_action: AclAction.Allow
|
||||
})
|
||||
|
||||
activeTab.value = acl.value.acl_v1.chains.length - 1
|
||||
activeTab.value = aclV1.value.chains.length - 1
|
||||
}
|
||||
|
||||
function removeChain(index: number) {
|
||||
if (confirm(t('acl.delete_chain_confirm'))) {
|
||||
acl.value.acl_v1?.chains.splice(index, 1)
|
||||
if (activeTab.value >= (acl.value.acl_v1?.chains.length || 0)) {
|
||||
activeTab.value = Math.max(0, (acl.value.acl_v1?.chains.length || 0))
|
||||
aclV1.value.chains.splice(index, 1)
|
||||
if (activeTab.value >= aclV1.value.chains.length) {
|
||||
activeTab.value = Math.max(0, aclV1.value.chains.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleRenameGroup({ oldName, newName }: { oldName: string, newName: string }) {
|
||||
if (!acl.value.acl_v1) return
|
||||
acl.value.acl_v1.chains.forEach(chain => {
|
||||
aclV1.value.chains.forEach(chain => {
|
||||
chain.rules.forEach(rule => {
|
||||
rule.source_groups = rule.source_groups.map(g => g === oldName ? newName : g)
|
||||
rule.destination_groups = rule.destination_groups.map(g => g === oldName ? newName : g)
|
||||
@@ -63,11 +59,11 @@ function handleRenameGroup({ oldName, newName }: { oldName: string, newName: str
|
||||
}
|
||||
|
||||
const groupNames = computed(() => {
|
||||
return acl.value.acl_v1?.group?.declares.map(g => g.group_name) || []
|
||||
return aclV1.value.group?.declares.map(g => g.group_name) || []
|
||||
})
|
||||
|
||||
const tabs = computed(() => {
|
||||
const chains = acl.value.acl_v1?.chains || []
|
||||
const chains = aclV1.value.chains
|
||||
const result: { type: string, label: string, index: number }[] = []
|
||||
|
||||
if (chains.length === 0) {
|
||||
@@ -124,24 +120,13 @@ const tabs = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Rule Chains -->
|
||||
<div v-if="tab.type === 'chain' && acl.acl_v1 && acl.acl_v1.chains[tab.index]" class="py-4">
|
||||
<AclChainEditor v-model="acl.acl_v1.chains[tab.index]" :group-names="groupNames" />
|
||||
<div v-if="tab.type === 'chain' && aclV1.chains[tab.index]" class="py-4">
|
||||
<AclChainEditor v-model="aclV1.chains[tab.index]" :group-names="groupNames" />
|
||||
</div>
|
||||
|
||||
<!-- Group Management -->
|
||||
<div v-if="tab.type === 'groups'" class="py-4">
|
||||
<template v-if="acl.acl_v1">
|
||||
<AclGroupEditor v-if="acl.acl_v1.group" v-model="acl.acl_v1.group" :group-names="groupNames"
|
||||
@rename-group="handleRenameGroup" />
|
||||
<div v-else class="flex justify-center p-4">
|
||||
<Button :label="t('web.common.add') + ' ' + t('acl.groups')"
|
||||
@click="acl.acl_v1.group = { declares: [], members: [] }" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="flex justify-center p-4">
|
||||
<Button :label="t('acl.enabled')"
|
||||
@click="acl.acl_v1 = { chains: [], group: { declares: [], members: [] } }" />
|
||||
</div>
|
||||
<AclGroupEditor v-model="aclV1.group" :group-names="groupNames" @rename-group="handleRenameGroup" />
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { AutoComplete, Button, Checkbox, Dialog, InputNumber, InputText, MultiSelect, Panel, SelectButton, ToggleButton } from 'primevue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { AclAction, AclProtocol, AclRule } from '../../types/network';
|
||||
import { AclAction, AclProtocol, AclRule, ensureAclRuleLists } from '../../types/network';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
@@ -32,6 +32,8 @@ const showPorts = computed(() => {
|
||||
return rule.value.protocol === AclProtocol.TCP || rule.value.protocol === AclProtocol.UDP || rule.value.protocol === AclProtocol.Any
|
||||
})
|
||||
|
||||
watch(() => rule.value, ensureAclRuleLists, { immediate: true })
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
@@ -341,6 +341,8 @@ web:
|
||||
import_config: 导入配置
|
||||
create_new: 创建新网络
|
||||
network_status: 网络状态
|
||||
loading_network_status: 正在加载网络状态
|
||||
network_info_unavailable: 网络状态不可用
|
||||
network_configuration: 网络配置
|
||||
loading_network_configuration: 加载网络配置
|
||||
no_network_selected: 未选择网络
|
||||
|
||||
@@ -341,6 +341,8 @@ web:
|
||||
import_config: Import Config
|
||||
create_new: Create New Network
|
||||
network_status: Network Status
|
||||
loading_network_status: Loading Network Status
|
||||
network_info_unavailable: Network status is unavailable
|
||||
network_configuration: Network Configuration
|
||||
loading_network_configuration: Loading Network Configuration
|
||||
no_network_selected: No Network Selected
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { PeerRoutePair } from '../types/network'
|
||||
|
||||
export function numericValue(value: unknown): number | undefined {
|
||||
if (typeof value === 'number')
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
|
||||
if (typeof value !== 'string' || value.trim() === '')
|
||||
return undefined
|
||||
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
export function peerConns(info: PeerRoutePair) {
|
||||
return info.peer?.conns || []
|
||||
}
|
||||
|
||||
function defaultConnId(info: PeerRoutePair) {
|
||||
const defaultConn = info.peer?.default_conn_id
|
||||
if (!defaultConn)
|
||||
return undefined
|
||||
|
||||
const part1 = defaultConn.part1 ?? 0
|
||||
const part2 = defaultConn.part2 ?? 0
|
||||
const part3 = defaultConn.part3 ?? 0
|
||||
const part4 = defaultConn.part4 ?? 0
|
||||
if (part1 === 0 && part2 === 0 && part3 === 0 && part4 === 0)
|
||||
return undefined
|
||||
|
||||
const toHex = (value: number) => value.toString(16).padStart(8, '0')
|
||||
const part1Hex = toHex(part1)
|
||||
const part2Hex = toHex(part2)
|
||||
const part3Hex = toHex(part3)
|
||||
const part4Hex = toHex(part4)
|
||||
return `${part1Hex}-${part2Hex.slice(0, 4)}-${part2Hex.slice(4, 8)}-${part3Hex.slice(0, 4)}-${part3Hex.slice(4, 8)}${part4Hex}`
|
||||
}
|
||||
|
||||
function defaultConnFirst(info: PeerRoutePair) {
|
||||
const conns = peerConns(info)
|
||||
const connId = defaultConnId(info)
|
||||
if (!connId)
|
||||
return conns
|
||||
|
||||
const defaultConn = conns.find(conn => conn.conn_id === connId)
|
||||
return defaultConn ? [defaultConn, ...conns.filter(conn => conn !== defaultConn)] : conns
|
||||
}
|
||||
|
||||
export function latencyMs(info: PeerRoutePair) {
|
||||
const connId = defaultConnId(info)
|
||||
let minLatencyUs: number | undefined
|
||||
|
||||
for (const conn of peerConns(info)) {
|
||||
if (!conn.stats)
|
||||
continue
|
||||
|
||||
const latencyUs = numericValue(conn.stats.latency_us)
|
||||
if (latencyUs === undefined)
|
||||
continue
|
||||
|
||||
if (connId === conn.conn_id)
|
||||
return `${Math.ceil(latencyUs / 1000)}ms`
|
||||
|
||||
minLatencyUs = Math.min(minLatencyUs ?? latencyUs, latencyUs)
|
||||
}
|
||||
|
||||
if (minLatencyUs === undefined)
|
||||
return ''
|
||||
|
||||
return `${Math.ceil(minLatencyUs / 1000)}ms`
|
||||
}
|
||||
|
||||
export function lossRate(info: PeerRoutePair) {
|
||||
for (const conn of defaultConnFirst(info)) {
|
||||
const loss = numericValue(conn.loss_rate)
|
||||
if (loss === undefined)
|
||||
continue
|
||||
|
||||
return `${Math.round(loss * 100)}%`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
@@ -1,24 +1,30 @@
|
||||
import { IPv4, IPv6 } from 'ip-num/IPNumber'
|
||||
import { Ipv4Addr, Ipv4Inet, Ipv6Addr } from '../types/network'
|
||||
|
||||
export function ipv4ToString(ip: Ipv4Addr) {
|
||||
return IPv4.fromNumber(ip.addr).toString()
|
||||
export function ipv4ToString(ip: Ipv4Addr | null | undefined) {
|
||||
if (!ip) {
|
||||
return ''
|
||||
}
|
||||
return IPv4.fromNumber(ip.addr ?? 0).toString()
|
||||
}
|
||||
|
||||
export function ipv4InetToString(ip: Ipv4Inet | undefined) {
|
||||
if (ip?.address === undefined) {
|
||||
return 'undefined'
|
||||
}
|
||||
return `${ipv4ToString(ip.address)}/${ip.network_length}`
|
||||
return `${ipv4ToString(ip.address)}/${ip.network_length ?? 0}`
|
||||
}
|
||||
|
||||
export function ipv6ToString(ip: Ipv6Addr) {
|
||||
export function ipv6ToString(ip: Ipv6Addr | null | undefined) {
|
||||
if (!ip) {
|
||||
return ''
|
||||
}
|
||||
return IPv6.fromBigInt(
|
||||
(BigInt(ip.part1) << BigInt(96))
|
||||
+ (BigInt(ip.part2) << BigInt(64))
|
||||
+ (BigInt(ip.part3) << BigInt(32))
|
||||
+ BigInt(ip.part4),
|
||||
)
|
||||
(BigInt(ip.part1 ?? 0) << BigInt(96))
|
||||
+ (BigInt(ip.part2 ?? 0) << BigInt(64))
|
||||
+ (BigInt(ip.part3 ?? 0) << BigInt(32))
|
||||
+ BigInt(ip.part4 ?? 0),
|
||||
).toString()
|
||||
}
|
||||
|
||||
function toHexString(uint64: bigint, padding = 9): string {
|
||||
@@ -43,14 +49,17 @@ function uint32ToUuid(part1: number, part2: number, part3: number, part4: number
|
||||
}
|
||||
|
||||
export interface UUID {
|
||||
part1: number;
|
||||
part2: number;
|
||||
part3: number;
|
||||
part4: number;
|
||||
part1?: number;
|
||||
part2?: number;
|
||||
part3?: number;
|
||||
part4?: number;
|
||||
}
|
||||
|
||||
export function UuidToStr(uuid: UUID): string {
|
||||
return uint32ToUuid(uuid.part1, uuid.part2, uuid.part3, uuid.part4);
|
||||
export function UuidToStr(uuid: UUID | null | undefined): string {
|
||||
if (!uuid) {
|
||||
return '';
|
||||
}
|
||||
return uint32ToUuid(uuid.part1 ?? 0, uuid.part2 ?? 0, uuid.part3 ?? 0, uuid.part4 ?? 0);
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
@@ -71,11 +80,12 @@ export interface DeviceInfo {
|
||||
}
|
||||
|
||||
export function buildDeviceInfo(device: any): DeviceInfo {
|
||||
const runningInstances = device.info?.running_network_instances ?? [];
|
||||
let dev_info: DeviceInfo = {
|
||||
hostname: device.info?.hostname,
|
||||
public_ip: device.client_url,
|
||||
running_network_instances: device.info?.running_network_instances.map((instance: any) => UuidToStr(instance)),
|
||||
running_network_count: device.info?.running_network_instances.length,
|
||||
running_network_instances: runningInstances.map((instance: any) => UuidToStr(instance)),
|
||||
running_network_count: runningInstances.length,
|
||||
report_time: device.info?.report_time,
|
||||
easytier_version: device.info?.easytier_version,
|
||||
machine_id: UuidToStr(device.info?.machine_id),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid'
|
||||
import {
|
||||
NetworkConfig as NetworkConfigPb,
|
||||
NetworkingMethod,
|
||||
type NetworkPeerConfig,
|
||||
type NetworkConfig as ProtoNetworkConfig,
|
||||
type PortForwardConfig,
|
||||
} from '../generated/proto/api_manage'
|
||||
@@ -16,11 +17,16 @@ import {
|
||||
type GroupInfo,
|
||||
type Rule as AclRule,
|
||||
} from '../generated/proto/acl'
|
||||
import { CompressionAlgoPb, NatType, type SecureModeConfig } from '../generated/proto/common'
|
||||
import {
|
||||
CompressionAlgoPb,
|
||||
NatType,
|
||||
type PeerFeatureFlag,
|
||||
type SecureModeConfig,
|
||||
} from '../generated/proto/common'
|
||||
import { prepareNetworkConfigForProtoJson } from './networkCompat'
|
||||
|
||||
export { AclAction, AclChainType, AclProtocol, CompressionAlgoPb, NatType, NetworkingMethod }
|
||||
export type { Acl, AclChain, AclRule, AclV1, GroupIdentity, GroupInfo, PortForwardConfig, SecureModeConfig }
|
||||
export type { Acl, AclChain, AclRule, AclV1, GroupIdentity, GroupInfo, NetworkPeerConfig, PeerFeatureFlag, PortForwardConfig, SecureModeConfig }
|
||||
|
||||
export type NetworkConfig = Omit<
|
||||
ProtoNetworkConfig,
|
||||
@@ -32,14 +38,39 @@ export type NetworkConfig = Omit<
|
||||
networking_method: NetworkingMethod | string
|
||||
}
|
||||
|
||||
export type NormalizedAclV1 = AclV1 & {
|
||||
group: GroupInfo
|
||||
}
|
||||
|
||||
const UINT64_MAX = (1n << 64n) - 1n
|
||||
|
||||
interface NetworkingConfigFields {
|
||||
peer_urls: string[]
|
||||
peers?: NetworkPeerConfig[]
|
||||
public_server_url?: string
|
||||
networking_method?: NetworkingMethod | string
|
||||
}
|
||||
|
||||
interface NetworkingMethodOptions {
|
||||
fillPeerUrlsFromPeers?: boolean
|
||||
}
|
||||
|
||||
function emptyGroupInfo(): GroupInfo {
|
||||
return {
|
||||
declares: [],
|
||||
members: [],
|
||||
}
|
||||
}
|
||||
|
||||
function emptyAcl(): Acl {
|
||||
return {
|
||||
acl_v1: {
|
||||
group: emptyGroupInfo(),
|
||||
chains: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
|
||||
return {
|
||||
...NetworkConfigPb.create(),
|
||||
@@ -110,15 +141,7 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
|
||||
enable_magic_dns: false,
|
||||
enable_private_mode: false,
|
||||
port_forwards: [],
|
||||
acl: {
|
||||
acl_v1: {
|
||||
group: {
|
||||
declares: [],
|
||||
members: [],
|
||||
},
|
||||
chains: [],
|
||||
},
|
||||
},
|
||||
acl: emptyAcl(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +149,88 @@ function cleanPeerUrls(urls: string[] | undefined): string[] {
|
||||
return (urls ?? []).map((url) => url.trim()).filter((url) => url.length > 0)
|
||||
}
|
||||
|
||||
function cleanNetworkPeers(peers: NetworkPeerConfig[] | undefined): NetworkPeerConfig[] {
|
||||
return (peers ?? [])
|
||||
.map((peer) => ({
|
||||
...peer,
|
||||
uri: peer.uri.trim(),
|
||||
}))
|
||||
.filter((peer) => peer.uri.length > 0)
|
||||
}
|
||||
|
||||
function peersFromUrls(urls: string[], existingPeers: NetworkPeerConfig[]): NetworkPeerConfig[] {
|
||||
const peersByUri = new Map<string, NetworkPeerConfig>()
|
||||
for (const peer of existingPeers) {
|
||||
if (!peersByUri.has(peer.uri)) {
|
||||
peersByUri.set(peer.uri, peer)
|
||||
}
|
||||
}
|
||||
|
||||
return urls.map((uri) => ({
|
||||
...(peersByUri.get(uri) ?? {}),
|
||||
uri,
|
||||
}))
|
||||
}
|
||||
|
||||
export function ensureAclRuleLists(rule: AclRule): AclRule {
|
||||
rule.ports ??= []
|
||||
rule.source_ips ??= []
|
||||
rule.destination_ips ??= []
|
||||
rule.source_ports ??= []
|
||||
rule.source_groups ??= []
|
||||
rule.destination_groups ??= []
|
||||
return rule
|
||||
}
|
||||
|
||||
export function ensureAclChain(chain: AclChain): AclChain {
|
||||
chain.rules ??= []
|
||||
chain.rules.forEach(ensureAclRuleLists)
|
||||
return chain
|
||||
}
|
||||
|
||||
export function ensureGroupInfo(group: GroupInfo): GroupInfo {
|
||||
group.declares ??= []
|
||||
group.members ??= []
|
||||
return group
|
||||
}
|
||||
|
||||
export function ensureAclV1(acl: Acl): NormalizedAclV1 {
|
||||
acl.acl_v1 ??= { chains: [], group: emptyGroupInfo() }
|
||||
acl.acl_v1.chains ??= []
|
||||
acl.acl_v1.chains.forEach(ensureAclChain)
|
||||
acl.acl_v1.group = ensureGroupInfo(acl.acl_v1.group ?? emptyGroupInfo())
|
||||
return acl.acl_v1 as NormalizedAclV1
|
||||
}
|
||||
|
||||
function normalizeAcl(acl: Acl | undefined): Acl {
|
||||
const source = acl ?? emptyAcl()
|
||||
const aclV1 = source.acl_v1 ?? { chains: [], group: emptyGroupInfo() }
|
||||
return {
|
||||
...source,
|
||||
acl_v1: {
|
||||
...aclV1,
|
||||
chains: (aclV1.chains ?? []).map((chain) => ({
|
||||
...chain,
|
||||
rules: (chain.rules ?? []).map((rule) => ({ ...ensureAclRuleLists({ ...rule }) })),
|
||||
})),
|
||||
group: ensureGroupInfo({
|
||||
...(aclV1.group ?? emptyGroupInfo()),
|
||||
declares: aclV1.group?.declares ?? [],
|
||||
members: aclV1.group?.members ?? [],
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function isGroupInfoEmpty(group: GroupInfo | undefined): boolean {
|
||||
return (group?.declares?.length ?? 0) === 0 && (group?.members?.length ?? 0) === 0
|
||||
}
|
||||
|
||||
function isAclEmpty(acl: Acl | undefined): boolean {
|
||||
const aclV1 = acl?.acl_v1
|
||||
return !aclV1 || ((aclV1.chains?.length ?? 0) === 0 && isGroupInfoEmpty(aclV1.group))
|
||||
}
|
||||
|
||||
function normalizeUint64ForInput(v: bigint | number | string | null | undefined): number | string | null {
|
||||
if (v == null) return null
|
||||
|
||||
@@ -154,15 +259,24 @@ function toBackendUint64(v: number | bigint | string | null | undefined): bigint
|
||||
}
|
||||
}
|
||||
|
||||
function applyNetworkingMethod(config: NetworkingConfigFields): void {
|
||||
function applyNetworkingMethod(
|
||||
config: NetworkingConfigFields,
|
||||
options: NetworkingMethodOptions = {},
|
||||
): void {
|
||||
const existingPeers = cleanNetworkPeers(config.peers)
|
||||
config.peer_urls = cleanPeerUrls(config.peer_urls)
|
||||
if (options.fillPeerUrlsFromPeers && config.peer_urls.length === 0 && existingPeers.length > 0) {
|
||||
config.peer_urls = existingPeers.map((peer) => peer.uri)
|
||||
}
|
||||
|
||||
const publicServerUrl = config.public_server_url?.trim() ?? ''
|
||||
const networkingMethod = config.networking_method ?? NetworkingMethod.Manual
|
||||
|
||||
switch (networkingMethod) {
|
||||
case NetworkingMethod.PublicServer:
|
||||
config.peer_urls = publicServerUrl ? [publicServerUrl] : []
|
||||
config.peer_urls = publicServerUrl
|
||||
? [publicServerUrl]
|
||||
: (options.fillPeerUrlsFromPeers ? existingPeers.map((peer) => peer.uri) : [])
|
||||
break
|
||||
case NetworkingMethod.Manual:
|
||||
break
|
||||
@@ -174,6 +288,7 @@ function applyNetworkingMethod(config: NetworkingConfigFields): void {
|
||||
|
||||
config.networking_method = NetworkingMethod.Manual
|
||||
config.public_server_url = ''
|
||||
config.peers = peersFromUrls(config.peer_urls, existingPeers)
|
||||
}
|
||||
|
||||
export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
|
||||
@@ -181,11 +296,19 @@ export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
|
||||
ignoreUnknownFields: true,
|
||||
}) as unknown as NetworkConfig
|
||||
|
||||
applyNetworkingMethod(normalized)
|
||||
applyNetworkingMethod(normalized, { fillPeerUrlsFromPeers: true })
|
||||
normalized.mtu = normalizeNumberForInput(normalized.mtu)
|
||||
normalized.instance_recv_bps_limit = normalizeUint64ForInput(
|
||||
normalized.instance_recv_bps_limit as any,
|
||||
)
|
||||
normalized.proxy_cidrs ??= []
|
||||
normalized.listener_urls ??= []
|
||||
normalized.relay_network_whitelist ??= []
|
||||
normalized.routes ??= []
|
||||
normalized.exit_nodes ??= []
|
||||
normalized.mapped_listeners ??= []
|
||||
normalized.port_forwards ??= []
|
||||
normalized.acl = config.acl === undefined ? undefined : normalizeAcl(normalized.acl)
|
||||
|
||||
return normalized
|
||||
}
|
||||
@@ -198,6 +321,9 @@ export function toBackendNetworkConfig(config: NetworkConfig): NetworkConfig {
|
||||
applyNetworkingMethod(backend)
|
||||
backend.mtu = normalizeNumberForInput(config.mtu) ?? undefined
|
||||
backend.instance_recv_bps_limit = toBackendUint64(config.instance_recv_bps_limit)
|
||||
if (config.acl === undefined || isAclEmpty(config.acl)) {
|
||||
backend.acl = undefined
|
||||
}
|
||||
|
||||
return NetworkConfigPb.toJson(backend, {
|
||||
useProtoFieldName: true,
|
||||
@@ -286,6 +412,7 @@ export interface Route {
|
||||
proxy_cidrs: string[]
|
||||
hostname: string
|
||||
stun_info?: StunInfo
|
||||
feature_flag?: PeerFeatureFlag
|
||||
inst_id: string
|
||||
version: string
|
||||
}
|
||||
@@ -293,6 +420,7 @@ export interface Route {
|
||||
export interface PeerInfo {
|
||||
peer_id: number
|
||||
conns: PeerConnInfo[]
|
||||
default_conn_id?: CommonUuid
|
||||
}
|
||||
|
||||
export interface PeerConnInfo {
|
||||
@@ -303,7 +431,7 @@ export interface PeerConnInfo {
|
||||
features: string[]
|
||||
tunnel?: TunnelInfo
|
||||
stats?: PeerConnStats
|
||||
loss_rate: number
|
||||
loss_rate?: number | string
|
||||
}
|
||||
|
||||
export interface PeerRoutePair {
|
||||
@@ -322,11 +450,18 @@ export interface TunnelInfo {
|
||||
}
|
||||
|
||||
export interface PeerConnStats {
|
||||
rx_bytes: number
|
||||
tx_bytes: number
|
||||
rx_packets: number
|
||||
tx_packets: number
|
||||
latency_us: number
|
||||
rx_bytes: number | string
|
||||
tx_bytes: number | string
|
||||
rx_packets: number | string
|
||||
tx_packets: number | string
|
||||
latency_us: number | string
|
||||
}
|
||||
|
||||
export interface CommonUuid {
|
||||
part1?: number
|
||||
part2?: number
|
||||
part3?: number
|
||||
part4?: number
|
||||
}
|
||||
|
||||
// 添加新行
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { latencyMs, lossRate } from '../src/modules/statusDisplay'
|
||||
import { ipv4ToString, ipv6ToString } from '../src/modules/utils'
|
||||
|
||||
function peerRoutePair(conns: any[]) {
|
||||
return {
|
||||
route: {
|
||||
ipv4_addr: '10.0.0.2',
|
||||
hostname: 'peer',
|
||||
version: 'test',
|
||||
},
|
||||
peer: {
|
||||
conns,
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
function peerRoutePairWithDefaultConn(conns: any[], defaultConnId: string) {
|
||||
const [part1, part2, part3, part4] = defaultConnId
|
||||
.replaceAll('-', '')
|
||||
.match(/.{8}/g)!
|
||||
.map((part) => Number.parseInt(part, 16))
|
||||
|
||||
return {
|
||||
...peerRoutePair(conns),
|
||||
peer: {
|
||||
default_conn_id: {
|
||||
part1,
|
||||
part2,
|
||||
part3,
|
||||
part4,
|
||||
},
|
||||
conns,
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('status display helpers', () => {
|
||||
it('does not render missing IP values as zero addresses', () => {
|
||||
expect(ipv4ToString(undefined)).toBe('')
|
||||
expect(ipv4ToString(null)).toBe('')
|
||||
expect(ipv4ToString({} as any)).toBe('0.0.0.0')
|
||||
expect(ipv4ToString({ addr: 0 })).toBe('0.0.0.0')
|
||||
|
||||
expect(ipv6ToString(undefined)).toBe('')
|
||||
expect(ipv6ToString(null)).toBe('')
|
||||
expect(ipv6ToString({} as any)).toBe('::0')
|
||||
expect(ipv6ToString({ part1: 0, part2: 0, part3: 0, part4: 0 })).toBe('::0')
|
||||
expect(ipv6ToString({ part4: 1 } as any)).toBe('::1')
|
||||
})
|
||||
|
||||
it('skips missing latency and loss values', () => {
|
||||
expect(latencyMs(peerRoutePair([
|
||||
{ conn_id: 'missing', stats: {} },
|
||||
{ conn_id: 'valid', stats: { latency_us: '2500' } },
|
||||
{ conn_id: 'invalid', stats: { latency_us: 'unknown' } },
|
||||
]))).toBe('3ms')
|
||||
expect(latencyMs(peerRoutePair([
|
||||
{ conn_id: 'missing', stats: {} },
|
||||
{ conn_id: 'invalid', stats: { latency_us: 'unknown' } },
|
||||
]))).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('prefers the default connection when its metric is valid', () => {
|
||||
const defaultConnId = '00000001-0002-0003-0004-000000000005'
|
||||
const conns = [
|
||||
{ conn_id: 'fallback', stats: { latency_us: '1000' }, loss_rate: '0.01' },
|
||||
{ conn_id: defaultConnId, stats: { latency_us: '9000' }, loss_rate: '0.5' },
|
||||
]
|
||||
|
||||
expect(latencyMs(peerRoutePairWithDefaultConn(conns, defaultConnId))).toBe('9ms')
|
||||
expect(lossRate(peerRoutePairWithDefaultConn(conns, defaultConnId))).toBe('50%')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user