refactor(gui): collapse public server and standalone into initial peer list (#2017)

The GUI exposed three networking modes: public server, manual, and standalone. In practice EasyTier does not have a server/client role distinction here. Those options only mapped to different peer bootstrap shapes, which made the product model misleading and pushed users toward a non-existent "public server" concept.

This change rewrites the shared configuration UX around initial nodes. Users now add or remove one or more initial node URLs directly, and the UI explains that EasyTier networking works like plugging in a cable: once a node connects to one or more existing nodes, it can join the mesh. Initial nodes may be self-hosted or shared by others.

To preserve compatibility, the frontend keeps the legacy fields and adds normalization helpers in the shared NetworkConfig layer. Old configs are read as initial_node_urls, while saves, runs, validation, config generation, and persisted GUI config sync still denormalize back into the current backend shape: zero initial nodes -> Standalone, one -> PublicServer, many -> Manual. This avoids any proto or backend API change while making old saved configs and imported TOML files load cleanly in the new UI.

Code changes:

- add initial_node_urls plus normalize/denormalize helpers in the shared frontend NetworkConfig model

- remove the mode switch and public-server/manual specific inputs from the shared Config component and replace them with a single initial-node list plus explanatory copy

- update Chinese and English locale strings for the new terminology

- normalize configs received from GUI/web backends and denormalize them before outbound API calls

- normalize GUI save-config events before storing them in localStorage so legacy payloads remain editable under the new model
This commit is contained in:
KKRainbow
2026-03-27 11:37:09 +08:00
committed by GitHub
parent e000636d83
commit 0aeea39fbe
8 changed files with 104 additions and 39 deletions
+14 -9
View File
@@ -1,5 +1,5 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { Api, type NetworkTypes } from 'easytier-frontend-lib' import { Api, NetworkTypes } from 'easytier-frontend-lib'
import { GetNetworkMetasResponse } from 'node_modules/easytier-frontend-lib/dist/modules/api' import { GetNetworkMetasResponse } from 'node_modules/easytier-frontend-lib/dist/modules/api'
@@ -17,15 +17,16 @@ interface ServiceOptions {
export type ServiceStatus = "Running" | "Stopped" | "NotInstalled" export type ServiceStatus = "Running" | "Stopped" | "NotInstalled"
export async function parseNetworkConfig(cfg: NetworkConfig) { export async function parseNetworkConfig(cfg: NetworkConfig) {
return invoke<string>('parse_network_config', { cfg }) return invoke<string>('parse_network_config', { cfg: NetworkTypes.toBackendNetworkConfig(cfg) })
} }
export async function generateNetworkConfig(tomlConfig: string) { export async function generateNetworkConfig(tomlConfig: string) {
return invoke<NetworkConfig>('generate_network_config', { tomlConfig }) const config = await invoke<NetworkConfig>('generate_network_config', { tomlConfig })
return NetworkTypes.normalizeNetworkConfig(config)
} }
export async function runNetworkInstance(cfg: NetworkConfig, save: boolean) { export async function runNetworkInstance(cfg: NetworkConfig, save: boolean) {
return invoke('run_network_instance', { cfg, save }) return invoke('run_network_instance', { cfg: NetworkTypes.toBackendNetworkConfig(cfg), save })
} }
export async function collectNetworkInfo(instanceId: string) { export async function collectNetworkInfo(instanceId: string) {
@@ -57,20 +58,24 @@ export async function updateNetworkConfigState(instanceId: string, disabled: boo
} }
export async function saveNetworkConfig(cfg: NetworkConfig) { export async function saveNetworkConfig(cfg: NetworkConfig) {
return await invoke('save_network_config', { cfg }) return await invoke('save_network_config', { cfg: NetworkTypes.toBackendNetworkConfig(cfg) })
} }
export async function validateConfig(cfg: NetworkConfig) { export async function validateConfig(cfg: NetworkConfig) {
return await invoke<ValidateConfigResponse>('validate_config', { cfg }) return await invoke<ValidateConfigResponse>('validate_config', { cfg: NetworkTypes.toBackendNetworkConfig(cfg) })
} }
export async function getConfig(instanceId: string) { export async function getConfig(instanceId: string) {
return await invoke<NetworkConfig>('get_config', { instanceId }) const config = await invoke<NetworkConfig>('get_config', { instanceId })
return NetworkTypes.normalizeNetworkConfig(config)
} }
export async function sendConfigs(enabledNetworks: string[]) { export async function sendConfigs(enabledNetworks: string[]) {
let networkList: NetworkConfig[] = JSON.parse(localStorage.getItem('networkList') || '[]'); const networkList: NetworkConfig[] = JSON.parse(localStorage.getItem('networkList') || '[]');
return await invoke('load_configs', { configs: networkList, enabledNetworks }) return await invoke('load_configs', {
configs: networkList.map((config) => NetworkTypes.toBackendNetworkConfig(NetworkTypes.normalizeNetworkConfig(config))),
enabledNetworks
})
} }
export async function getNetworkMetas(instanceIds: string[]) { export async function getNetworkMetas(instanceIds: string[]) {
+1 -1
View File
@@ -14,7 +14,7 @@ const EVENTS = Object.freeze({
function onSaveConfigs(event: Event<NetworkTypes.NetworkConfig[]>) { function onSaveConfigs(event: Event<NetworkTypes.NetworkConfig[]>) {
console.log(`Received event '${EVENTS.SAVE_CONFIGS}': ${event.payload}`); console.log(`Received event '${EVENTS.SAVE_CONFIGS}': ${event.payload}`);
localStorage.setItem('networkList', JSON.stringify(event.payload)); localStorage.setItem('networkList', JSON.stringify(event.payload.map((config) => NetworkTypes.normalizeNetworkConfig(config))));
} }
async function onPreRunNetworkInstance(event: Event<string>) { async function onPreRunNetworkInstance(event: Event<string>) {
@@ -1,17 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import InputGroup from 'primevue/inputgroup' import InputGroup from 'primevue/inputgroup'
import InputGroupAddon from 'primevue/inputgroupaddon' import InputGroupAddon from 'primevue/inputgroupaddon'
import { SelectButton, Checkbox, InputText, InputNumber, AutoComplete, Panel, Divider, ToggleButton, Button, Password, Dialog } from 'primevue' import { Checkbox, InputText, InputNumber, AutoComplete, Panel, Divider, ToggleButton, Button, Password, Dialog } from 'primevue'
import { import {
addRow, addRow,
DEFAULT_NETWORK_CONFIG, DEFAULT_NETWORK_CONFIG,
NetworkConfig, NetworkConfig,
NetworkingMethod, normalizeNetworkConfig,
removeRow removeRow
} from '../types/network' } from '../types/network'
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import UrlInput from './UrlInput.vue'
import UrlListInput from './UrlListInput.vue' import UrlListInput from './UrlListInput.vue'
const props = defineProps<{ const props = defineProps<{
@@ -28,12 +27,6 @@ const curNetwork = defineModel('curNetwork', {
const { t } = useI18n() const { t } = useI18n()
const networking_methods = ref([
{ value: NetworkingMethod.PublicServer, label: () => t('public_server') },
{ value: NetworkingMethod.Manual, label: () => t('manual') },
{ value: NetworkingMethod.Standalone, label: () => t('standalone') },
])
const protos: { [proto: string]: number } = { const protos: { [proto: string]: number } = {
tcp: 11010, tcp: 11010,
udp: 11010, udp: 11010,
@@ -154,6 +147,16 @@ onMounted(() => {
}); });
} }
}); });
function syncNormalizedNetwork(network: NetworkConfig | undefined): void {
if (!network) {
return
}
Object.assign(network, normalizeNetworkConfig(network))
}
watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: false })
</script> </script>
<template> <template>
@@ -200,15 +203,13 @@ onMounted(() => {
<div class="flex flex-row gap-x-9 flex-wrap"> <div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-col gap-2 basis-5/12 grow"> <div class="flex flex-col gap-2 basis-5/12 grow">
<label for="nm">{{ t('networking_method') }}</label> <div class="flex items-center">
<SelectButton v-model="curNetwork.networking_method" :options="networking_methods" <label for="initial_nodes">{{ t('initial_nodes') }}</label>
:option-label="(v) => v.label()" option-value="value" /> <span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('initial_nodes_help')"></span>
</div>
<div class="items-center flex flex-col p-fluid gap-y-2"> <div class="items-center flex flex-col p-fluid gap-y-2">
<UrlListInput v-if="curNetwork.networking_method === NetworkingMethod.Manual" <UrlListInput id="initial_nodes" v-model="curNetwork.peer_urls" :protos="protos"
v-model="curNetwork.peer_urls" :protos="protos" :add-label="t('add_peer_url')" /> :add-label="t('add_initial_node')" :placeholder="t('initial_node_placeholder')" />
<UrlInput v-if="curNetwork.networking_method === NetworkingMethod.PublicServer"
v-model="curNetwork.public_server_url" :protos="protos" />
</div> </div>
</div> </div>
</div> </div>
@@ -3,6 +3,14 @@ networking_method: 网络方式
public_server: 公共服务器 public_server: 公共服务器
manual: 手动 manual: 手动
standalone: 独立 standalone: 独立
initial_nodes: 初始节点
initial_nodes_help: |
EasyTier 不分服务端/客户端。
• 填“初始节点” = 插上网线,直接加入已有网络。
• 留空 = 节点独立启动,等别人来连,或你后续手动连。
• 无论直接还是间接连通(通过其他节点搭桥),都能组网互通。
初始节点可以用自己的,也可以用别人分享的。
initial_node_placeholder: 例如:tcp://node.example.com:11010
virtual_ipv4: 虚拟IPv4地址 virtual_ipv4: 虚拟IPv4地址
virtual_ipv4_dhcp: DHCP virtual_ipv4_dhcp: DHCP
network_name: 网络名称 network_name: 网络名称
@@ -26,6 +34,7 @@ error_msg: 错误信息
detail: 详情 detail: 详情
add_new_network: 添加新网络 add_new_network: 添加新网络
add_peer_url: 添加节点 add_peer_url: 添加节点
add_initial_node: 添加初始节点
add_listener_url: 添加监听地址 add_listener_url: 添加监听地址
add_mapped_listener: 添加监听映射 add_mapped_listener: 添加监听映射
del_cur_network: 删除当前网络 del_cur_network: 删除当前网络
@@ -3,6 +3,14 @@ networking_method: Networking Method
public_server: Public Server public_server: Public Server
manual: Manual manual: Manual
standalone: Standalone standalone: Standalone
initial_nodes: Initial Nodes
initial_nodes_help: |
EasyTier does not distinguish between server and client roles.
• Filling in Initial Nodes = plugging in the cable and joining an existing network.
• Leaving it empty = the node starts alone until others connect to it, or you connect it later yourself.
• Direct or indirect connectivity, including through relay nodes, can form one network.
Initial nodes can be your own nodes or ones shared by others.
initial_node_placeholder: "Example: tcp://node.example.com:11010"
virtual_ipv4: Virtual IPv4 virtual_ipv4: Virtual IPv4
virtual_ipv4_dhcp: DHCP virtual_ipv4_dhcp: DHCP
network_name: Network Name network_name: Network Name
@@ -26,6 +34,7 @@ error_msg: Error Message
detail: Detail detail: Detail
add_new_network: New Network add_new_network: New Network
add_peer_url: Add Peer add_peer_url: Add Peer
add_initial_node: Add Initial Node
add_listener_url: Add Listener add_listener_url: Add Listener
add_mapped_listener: Add Mapped Listener add_mapped_listener: Add Mapped Listener
del_cur_network: Delete Current Network del_cur_network: Delete Current Network
+2
View File
@@ -49,4 +49,6 @@
.v-popper__inner { .v-popper__inner {
white-space: pre-wrap; white-space: pre-wrap;
max-width: 32rem;
line-height: 1.5;
} }
+35 -3
View File
@@ -97,9 +97,8 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
network_secret: '', network_secret: '',
credential_file: '', credential_file: '',
networking_method: NetworkingMethod.PublicServer, networking_method: NetworkingMethod.Manual,
public_server_url: '',
public_server_url: 'tcp://public.easytier.top:11010',
peer_urls: [], peer_urls: [],
proxy_cidrs: [], proxy_cidrs: [],
@@ -154,6 +153,39 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
} }
} }
function cleanPeerUrls(urls: string[] | undefined): string[] {
return (urls ?? []).map((url) => url.trim()).filter((url) => url.length > 0)
}
export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
const normalized: NetworkConfig = {
...config,
peer_urls: cleanPeerUrls(config.peer_urls),
}
const publicServerUrl = normalized.public_server_url?.trim() ?? ''
switch (normalized.networking_method) {
case NetworkingMethod.PublicServer:
normalized.peer_urls = publicServerUrl ? [publicServerUrl] : []
break
case NetworkingMethod.Manual:
break
case NetworkingMethod.Standalone:
default:
normalized.peer_urls = []
break
}
normalized.networking_method = NetworkingMethod.Manual
normalized.public_server_url = ''
return normalized
}
export function toBackendNetworkConfig(config: NetworkConfig): NetworkConfig {
return normalizeNetworkConfig(config)
}
export interface NetworkInstance { export interface NetworkInstance {
instance_id: string instance_id: string
+13 -6
View File
@@ -1,5 +1,5 @@
import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { type Api, type NetworkTypes, Utils } from 'easytier-frontend-lib'; import { type Api, NetworkTypes, Utils } from 'easytier-frontend-lib';
import { Md5 } from 'ts-md5'; import { Md5 } from 'ts-md5';
export interface ValidateConfigResponse { export interface ValidateConfigResponse {
@@ -206,13 +206,13 @@ class WebRemoteClient implements Api.RemoteClient {
} }
async validate_config(config: NetworkTypes.NetworkConfig): Promise<Api.ValidateConfigResponse> { async validate_config(config: NetworkTypes.NetworkConfig): Promise<Api.ValidateConfigResponse> {
const response = await this.client.post<NetworkTypes.NetworkConfig, ValidateConfigResponse>(`/machines/${this.machine_id}/validate-config`, { const response = await this.client.post<NetworkTypes.NetworkConfig, ValidateConfigResponse>(`/machines/${this.machine_id}/validate-config`, {
config: config, config: NetworkTypes.toBackendNetworkConfig(config),
}); });
return response; return response;
} }
async run_network(config: NetworkTypes.NetworkConfig, save: boolean): Promise<undefined> { async run_network(config: NetworkTypes.NetworkConfig, save: boolean): Promise<undefined> {
await this.client.post<string>(`/machines/${this.machine_id}/networks`, { await this.client.post<string>(`/machines/${this.machine_id}/networks`, {
config: config, config: NetworkTypes.toBackendNetworkConfig(config),
save: save save: save
}); });
} }
@@ -233,15 +233,19 @@ class WebRemoteClient implements Api.RemoteClient {
}); });
} }
async save_config(config: NetworkTypes.NetworkConfig): Promise<undefined> { async save_config(config: NetworkTypes.NetworkConfig): Promise<undefined> {
await this.client.put(`/machines/${this.machine_id}/networks/config/${config.instance_id}`, { config }); await this.client.put(`/machines/${this.machine_id}/networks/config/${config.instance_id}`, {
config: NetworkTypes.toBackendNetworkConfig(config)
});
} }
async get_network_config(inst_id: string): Promise<NetworkTypes.NetworkConfig> { async get_network_config(inst_id: string): Promise<NetworkTypes.NetworkConfig> {
const response = await this.client.get<any, NetworkTypes.NetworkConfig>('/machines/' + this.machine_id + '/networks/config/' + inst_id); const response = await this.client.get<any, NetworkTypes.NetworkConfig>('/machines/' + this.machine_id + '/networks/config/' + inst_id);
return response; return NetworkTypes.normalizeNetworkConfig(response);
} }
async generate_config(config: NetworkTypes.NetworkConfig): Promise<Api.GenerateConfigResponse> { async generate_config(config: NetworkTypes.NetworkConfig): Promise<Api.GenerateConfigResponse> {
try { try {
const response = await this.client.post<any, GenerateConfigResponse>('/generate-config', { config }); const response = await this.client.post<any, GenerateConfigResponse>('/generate-config', {
config: NetworkTypes.toBackendNetworkConfig(config)
});
return response; return response;
} catch (error) { } catch (error) {
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
@@ -253,6 +257,9 @@ class WebRemoteClient implements Api.RemoteClient {
async parse_config(toml_config: string): Promise<Api.ParseConfigResponse> { async parse_config(toml_config: string): Promise<Api.ParseConfigResponse> {
try { try {
const response = await this.client.post<any, ParseConfigResponse>('/parse-config', { toml_config }); const response = await this.client.post<any, ParseConfigResponse>('/parse-config', { toml_config });
if (response.config) {
response.config = NetworkTypes.normalizeNetworkConfig(response.config);
}
return response; return response;
} catch (error) { } catch (error) {
if (error instanceof AxiosError) { if (error instanceof AxiosError) {