mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-05-09 11:14:30 +00:00
0aeea39fbe
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
486 lines
22 KiB
Vue
486 lines
22 KiB
Vue
<script setup lang="ts">
|
|
import InputGroup from 'primevue/inputgroup'
|
|
import InputGroupAddon from 'primevue/inputgroupaddon'
|
|
import { Checkbox, InputText, InputNumber, AutoComplete, Panel, Divider, ToggleButton, Button, Password, Dialog } from 'primevue'
|
|
import {
|
|
addRow,
|
|
DEFAULT_NETWORK_CONFIG,
|
|
NetworkConfig,
|
|
normalizeNetworkConfig,
|
|
removeRow
|
|
} from '../types/network'
|
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import UrlListInput from './UrlListInput.vue'
|
|
|
|
const props = defineProps<{
|
|
configInvalid?: boolean
|
|
hostname?: string
|
|
}>()
|
|
|
|
defineEmits(['runNetwork'])
|
|
|
|
const curNetwork = defineModel('curNetwork', {
|
|
type: Object as () => NetworkConfig,
|
|
default: DEFAULT_NETWORK_CONFIG,
|
|
})
|
|
|
|
const { t } = useI18n()
|
|
|
|
const protos: { [proto: string]: number } = {
|
|
tcp: 11010,
|
|
udp: 11010,
|
|
wg: 11011,
|
|
ws: 11011,
|
|
wss: 11012,
|
|
quic: 11012,
|
|
faketcp: 11013,
|
|
http: 80,
|
|
https: 443,
|
|
txt: 0,
|
|
srv: 0,
|
|
}
|
|
|
|
const inetSuggestions = ref([''])
|
|
|
|
function searchInetSuggestions(e: { query: string }) {
|
|
if (e.query.search('/') >= 0) {
|
|
inetSuggestions.value = [e.query]
|
|
} else {
|
|
const ret = []
|
|
for (let i = 0; i < 32; i++) {
|
|
ret.push(`${e.query}/${i}`)
|
|
}
|
|
inetSuggestions.value = ret
|
|
}
|
|
}
|
|
|
|
const exitNodesSuggestions = ref([''])
|
|
|
|
function searchExitNodesSuggestions(e: { query: string }) {
|
|
const ret = []
|
|
ret.push(e.query)
|
|
exitNodesSuggestions.value = ret
|
|
}
|
|
|
|
const whitelistSuggestions = ref([''])
|
|
|
|
function searchWhitelistSuggestions(e: { query: string }) {
|
|
const ret = []
|
|
ret.push(e.query)
|
|
whitelistSuggestions.value = ret
|
|
}
|
|
|
|
interface BoolFlag {
|
|
field: keyof NetworkConfig
|
|
help: string
|
|
}
|
|
|
|
const bool_flags: BoolFlag[] = [
|
|
{ field: 'latency_first', help: 'latency_first_help' },
|
|
{ field: 'use_smoltcp', help: 'use_smoltcp_help' },
|
|
{ field: 'disable_ipv6', help: 'disable_ipv6_help' },
|
|
{ field: 'enable_kcp_proxy', help: 'enable_kcp_proxy_help' },
|
|
{ field: 'disable_kcp_input', help: 'disable_kcp_input_help' },
|
|
{ field: 'enable_quic_proxy', help: 'enable_quic_proxy_help' },
|
|
{ field: 'disable_quic_input', help: 'disable_quic_input_help' },
|
|
{ field: 'disable_p2p', help: 'disable_p2p_help' },
|
|
{ field: 'p2p_only', help: 'p2p_only_help' },
|
|
{ field: 'lazy_p2p', help: 'lazy_p2p_help' },
|
|
{ field: 'bind_device', help: 'bind_device_help' },
|
|
{ field: 'no_tun', help: 'no_tun_help' },
|
|
{ field: 'enable_exit_node', help: 'enable_exit_node_help' },
|
|
{ field: 'relay_all_peer_rpc', help: 'relay_all_peer_rpc_help' },
|
|
{ field: 'need_p2p', help: 'need_p2p_help' },
|
|
{ field: 'multi_thread', help: 'multi_thread_help' },
|
|
{ field: 'proxy_forward_by_system', help: 'proxy_forward_by_system_help' },
|
|
{ field: 'disable_encryption', help: 'disable_encryption_help' },
|
|
{ field: 'disable_tcp_hole_punching', help: 'disable_tcp_hole_punching_help' },
|
|
{ field: 'disable_udp_hole_punching', help: 'disable_udp_hole_punching_help' },
|
|
{ field: 'disable_sym_hole_punching', help: 'disable_sym_hole_punching_help' },
|
|
{ field: 'enable_magic_dns', help: 'enable_magic_dns_help' },
|
|
{ field: 'enable_private_mode', help: 'enable_private_mode_help' },
|
|
]
|
|
|
|
const portForwardProtocolOptions = ref(["tcp", "udp"]);
|
|
|
|
const editingPortForward = ref(false);
|
|
const editingPortForwardIndex = ref(-1);
|
|
const editingPortForwardData = ref();
|
|
|
|
function openPortForwardEditor(index: number) {
|
|
editingPortForwardIndex.value = index;
|
|
// deep copy
|
|
editingPortForwardData.value = JSON.parse(JSON.stringify(curNetwork.value.port_forwards[index]));
|
|
editingPortForward.value = true;
|
|
}
|
|
|
|
function addPortForward() {
|
|
addRow(curNetwork.value.port_forwards)
|
|
if (isCompact.value) {
|
|
openPortForwardEditor(curNetwork.value.port_forwards.length - 1)
|
|
}
|
|
}
|
|
|
|
function savePortForward() {
|
|
curNetwork.value.port_forwards[editingPortForwardIndex.value] = editingPortForwardData.value;
|
|
editingPortForward.value = false;
|
|
}
|
|
|
|
const portForwardContainer = ref<HTMLElement | null>(null);
|
|
const isCompact = ref(false);
|
|
|
|
|
|
onMounted(() => {
|
|
if (portForwardContainer.value) {
|
|
let resizeObserver = new ResizeObserver(entries => {
|
|
for (const entry of entries) {
|
|
isCompact.value = entry.contentRect.width < 540;
|
|
}
|
|
});
|
|
resizeObserver.observe(portForwardContainer.value);
|
|
|
|
onUnmounted(() => {
|
|
if (resizeObserver && portForwardContainer.value) {
|
|
resizeObserver.unobserve(portForwardContainer.value);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
function syncNormalizedNetwork(network: NetworkConfig | undefined): void {
|
|
if (!network) {
|
|
return
|
|
}
|
|
|
|
Object.assign(network, normalizeNetworkConfig(network))
|
|
}
|
|
|
|
watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: false })
|
|
</script>
|
|
|
|
<template>
|
|
<div class="frontend-lib">
|
|
<div class="flex flex-col h-full">
|
|
<div class="flex flex-col">
|
|
<div class="w-full self-center ">
|
|
<Panel :header="t('basic_settings')">
|
|
<div class="flex flex-col gap-y-2">
|
|
<div class="flex flex-row gap-x-9 flex-wrap">
|
|
<div class="flex flex-col gap-2 basis-5/12 grow">
|
|
<div class="flex items-center" for="virtual_ip">
|
|
<label class="mr-2"> {{ t('virtual_ipv4') }} </label>
|
|
<Checkbox v-model="curNetwork.dhcp" input-id="virtual_ip_auto" :binary="true" />
|
|
|
|
<label for="virtual_ip_auto" class="ml-2">
|
|
{{ t('virtual_ipv4_dhcp') }}
|
|
</label>
|
|
</div>
|
|
<InputGroup>
|
|
<InputText id="virtual_ip" v-model="curNetwork.virtual_ipv4" :disabled="curNetwork.dhcp"
|
|
aria-describedby="virtual_ipv4-help" />
|
|
<InputGroupAddon>
|
|
<span>/</span>
|
|
</InputGroupAddon>
|
|
<InputNumber v-model="curNetwork.network_length" :disabled="curNetwork.dhcp"
|
|
inputId="horizontal-buttons" showButtons :step="1" mode="decimal" :min="1" :max="32" fluid
|
|
class="max-w-20" />
|
|
</InputGroup>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-row gap-x-9 flex-wrap">
|
|
<div class="flex flex-col gap-2 basis-5/12 grow">
|
|
<label for="network_name">{{ t('network_name') }}</label>
|
|
<InputText id="network_name" v-model="curNetwork.network_name" aria-describedby="network_name-help" />
|
|
</div>
|
|
<div class="flex flex-col gap-2 basis-5/12 grow">
|
|
<label for="network_secret">{{ t('network_secret') }}</label>
|
|
<Password id="network_secret" v-model="curNetwork.network_secret"
|
|
aria-describedby="network_secret-help" toggleMask :feedback="false" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-row gap-x-9 flex-wrap">
|
|
<div class="flex flex-col gap-2 basis-5/12 grow">
|
|
<div class="flex items-center">
|
|
<label for="initial_nodes">{{ t('initial_nodes') }}</label>
|
|
<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">
|
|
<UrlListInput id="initial_nodes" v-model="curNetwork.peer_urls" :protos="protos"
|
|
:add-label="t('add_initial_node')" :placeholder="t('initial_node_placeholder')" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
|
|
<Divider />
|
|
|
|
<Panel :header="t('advanced_settings')" toggleable collapsed>
|
|
<div class="flex flex-col gap-y-2">
|
|
|
|
<div class="flex flex-row gap-x-9 flex-wrap">
|
|
<div class="flex flex-col gap-2 basis-5/12 grow">
|
|
<label> {{ t('flags_switch') }} </label>
|
|
<div class="flex flex-row flex-wrap">
|
|
|
|
<div class="basis-[20rem] flex items-center" v-for="flag in bool_flags">
|
|
<Checkbox v-model="curNetwork[flag.field]" :input-id="flag.field" :binary="true" />
|
|
<label :for="flag.field" class="ml-2"> {{ t(flag.field) }} </label>
|
|
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t(flag.help)"></span>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-row gap-x-9 flex-wrap">
|
|
<div class="flex flex-col gap-2 basis-5/12 grow">
|
|
<label for="hostname">{{ t('hostname') }}</label>
|
|
<InputText id="hostname" v-model="curNetwork.hostname" aria-describedby="hostname-help" :format="true"
|
|
:placeholder="t('hostname_placeholder', [props.hostname])" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-row gap-x-9 flex-wrap w-full">
|
|
<div class="flex flex-col gap-2 grow p-fluid">
|
|
<label for="username">{{ t('proxy_cidrs') }}</label>
|
|
<AutoComplete id="subnet-proxy" v-model="curNetwork.proxy_cidrs"
|
|
:placeholder="t('chips_placeholder', ['10.0.0.0/24'])" class="w-full" multiple fluid
|
|
:suggestions="inetSuggestions" @complete="searchInetSuggestions" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-row gap-x-9 flex-wrap ">
|
|
<div class="flex flex-col gap-2 grow">
|
|
<label for="username">VPN Portal</label>
|
|
<ToggleButton v-model="curNetwork.enable_vpn_portal" on-icon="pi pi-check" off-icon="pi pi-times"
|
|
:on-label="t('off_text')" :off-label="t('on_text')" class="w-48" />
|
|
<div v-if="curNetwork.enable_vpn_portal" class="items-center flex flex-row gap-x-4">
|
|
<div class="flex flex-row gap-x-9 flex-wrap w-full">
|
|
<div class="flex flex-col gap-2 basis-8/12 grow">
|
|
<InputGroup>
|
|
<InputText v-model="curNetwork.vpn_portal_client_network_addr"
|
|
:placeholder="t('vpn_portal_client_network')" />
|
|
<InputGroupAddon>
|
|
<span>/{{ curNetwork.vpn_portal_client_network_len }}</span>
|
|
</InputGroupAddon>
|
|
</InputGroup>
|
|
</div>
|
|
<div class="flex flex-col gap-2 basis-3/12 grow">
|
|
<InputNumber v-model="curNetwork.vpn_portal_listen_port" :allow-empty="false" :format="false"
|
|
:min="0" :max="65535" fluid />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-row gap-x-9 flex-wrap">
|
|
<div class="flex flex-col gap-2 grow p-fluid">
|
|
<label for="listener_urls">{{ t('listener_urls') }}</label>
|
|
<UrlListInput v-model="curNetwork.listener_urls" :protos="protos" :add-label="t('add_listener_url')"
|
|
placeholder="0.0.0.0" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-row gap-x-9 flex-wrap">
|
|
<div class="flex flex-col gap-2 basis-5/12 grow">
|
|
<label for="dev_name">{{ t('dev_name') }}</label>
|
|
<InputText id="dev_name" v-model="curNetwork.dev_name" aria-describedby="dev_name-help" :format="true"
|
|
:placeholder="t('dev_name_placeholder')" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-row gap-x-9 flex-wrap">
|
|
<div class="flex flex-col gap-2 basis-5/12 grow">
|
|
<div class="flex">
|
|
<label for="mtu">{{ t('mtu') }}</label>
|
|
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('mtu_help')"></span>
|
|
</div>
|
|
<InputNumber id="mtu" v-model="curNetwork.mtu" aria-describedby="mtu-help" :format="false"
|
|
:placeholder="t('mtu_placeholder')" :min="400" :max="1380" fluid />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-row gap-x-9 flex-wrap">
|
|
<div class="flex flex-col gap-2 basis-5/12 grow">
|
|
<div class="flex">
|
|
<label for="relay_network_whitelist">{{ t('relay_network_whitelist') }}</label>
|
|
<span class="pi pi-question-circle ml-2 self-center"
|
|
v-tooltip="t('relay_network_whitelist_help')"></span>
|
|
</div>
|
|
<ToggleButton v-model="curNetwork.enable_relay_network_whitelist" on-icon="pi pi-check"
|
|
off-icon="pi pi-times" :on-label="t('off_text')" :off-label="t('on_text')" class="w-48" />
|
|
<div v-if="curNetwork.enable_relay_network_whitelist" class="items-center flex flex-row gap-x-4">
|
|
<div class="min-w-64 w-full">
|
|
<AutoComplete id="relay_network_whitelist" v-model="curNetwork.relay_network_whitelist"
|
|
:placeholder="t('relay_network_whitelist')" class="w-full" multiple fluid
|
|
:suggestions="whitelistSuggestions" @complete="searchWhitelistSuggestions" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-row gap-x-9 flex-wrap ">
|
|
<div class="flex flex-col gap-2 grow">
|
|
<div class="flex">
|
|
<label for="routes">{{ t('manual_routes') }}</label>
|
|
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('manual_routes_help')"></span>
|
|
</div>
|
|
<ToggleButton v-model="curNetwork.enable_manual_routes" on-icon="pi pi-check" off-icon="pi pi-times"
|
|
:on-label="t('off_text')" :off-label="t('on_text')" class="w-48" />
|
|
<div v-if="curNetwork.enable_manual_routes" class="items-center flex flex-row gap-x-4">
|
|
<div class="min-w-64 w-full">
|
|
<AutoComplete id="routes" v-model="curNetwork.routes"
|
|
:placeholder="t('chips_placeholder', ['192.168.0.0/16'])" class="w-full" multiple fluid
|
|
:suggestions="inetSuggestions" @complete="searchInetSuggestions" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-row gap-x-9 flex-wrap ">
|
|
<div class="flex flex-col gap-2 grow">
|
|
<div class="flex">
|
|
<label for="socks5_port">{{ t('socks5') }}</label>
|
|
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('socks5_help')"></span>
|
|
</div>
|
|
<ToggleButton v-model="curNetwork.enable_socks5" on-icon="pi pi-check" off-icon="pi pi-times"
|
|
:on-label="t('off_text')" :off-label="t('on_text')" class="w-48" />
|
|
<div v-if="curNetwork.enable_socks5" class="items-center flex flex-row gap-x-4">
|
|
<div class="min-w-64 w-full">
|
|
<InputNumber id="socks5_port" v-model="curNetwork.socks5_port" aria-describedby="rpc_port-help"
|
|
:format="false" :allow-empty="false" :min="0" :max="65535" class="w-full" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-row gap-x-9 flex-wrap w-full">
|
|
<div class="flex flex-col gap-2 grow p-fluid">
|
|
<div class="flex">
|
|
<label for="exit_nodes">{{ t('exit_nodes') }}</label>
|
|
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('exit_nodes_help')"></span>
|
|
</div>
|
|
<AutoComplete id="exit_nodes" v-model="curNetwork.exit_nodes"
|
|
:placeholder="t('chips_placeholder', ['192.168.8.8'])" class="w-full" multiple fluid
|
|
:suggestions="exitNodesSuggestions" @complete="searchExitNodesSuggestions" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-row gap-x-9 flex-wrap w-full">
|
|
<div class="flex flex-col gap-2 grow p-fluid">
|
|
<div class="flex">
|
|
<label for="mapped_listeners">{{ t('mapped_listeners') }}</label>
|
|
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('mapped_listeners_help')"></span>
|
|
</div>
|
|
<UrlListInput v-model="curNetwork.mapped_listeners" :protos="protos"
|
|
:add-label="t('add_mapped_listener')" />
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</Panel>
|
|
|
|
<Divider />
|
|
|
|
<Panel :header="t('port_forwards')" toggleable collapsed>
|
|
<div ref="portForwardContainer" class="flex flex-col gap-y-2">
|
|
<div class="flex flex-row gap-x-9 flex-wrap w-full">
|
|
<div class="flex flex-col gap-2 grow p-fluid">
|
|
<div class="flex">
|
|
<label for="port_forwards">{{ t('port_forwards_help') }}</label>
|
|
</div>
|
|
<div v-for="(row, index) in curNetwork.port_forwards" :key="index" class="form-row">
|
|
<!-- Wide screen view -->
|
|
<div v-if="!isCompact" class="flex gap-2 items-end">
|
|
<SelectButton v-model="row.proto" :options="portForwardProtocolOptions" :allow-empty="false" />
|
|
<div style="flex-grow: 4;">
|
|
<InputGroup>
|
|
<InputText v-model="row.bind_ip" :placeholder="t('port_forwards_bind_addr')" />
|
|
<InputGroupAddon>
|
|
<span style="font-weight: bold">:</span>
|
|
</InputGroupAddon>
|
|
<InputNumber v-model="row.bind_port" :format="false" inputId="horizontal-buttons" :step="1"
|
|
mode="decimal" :min="1" :max="65535" fluid class="max-w-20" />
|
|
</InputGroup>
|
|
</div>
|
|
<div style="flex-grow: 4;">
|
|
<InputGroup>
|
|
<InputText v-model="row.dst_ip" :placeholder="t('port_forwards_dst_addr')" />
|
|
<InputGroupAddon>
|
|
<span style="font-weight: bold">:</span>
|
|
</InputGroupAddon>
|
|
<InputNumber v-model="row.dst_port" :format="false" inputId="horizontal-buttons" :step="1"
|
|
mode="decimal" :min="1" :max="65535" fluid class="max-w-20" />
|
|
</InputGroup>
|
|
</div>
|
|
<div style="flex-grow: 1;">
|
|
<Button v-if="curNetwork.port_forwards.length > 0" icon="pi pi-trash" severity="danger" text
|
|
rounded @click="removeRow(index, curNetwork.port_forwards)" />
|
|
</div>
|
|
</div>
|
|
<!-- Small screen view -->
|
|
<div v-else class="flex justify-between items-center p-2 border-b">
|
|
<span>{{ row.proto }}://{{ row.bind_ip }}:{{ row.bind_port }}/{{ row.dst_ip }}:{{
|
|
row.dst_port }}</span>
|
|
<div class="flex gap-2">
|
|
<Button icon="pi pi-pencil" class="p-button-sm" @click="openPortForwardEditor(index)" />
|
|
<Button icon="pi pi-trash" class="p-button-sm p-button-danger"
|
|
@click="removeRow(index, curNetwork.port_forwards)" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-content-end mt-4">
|
|
<Button icon="pi pi-plus" :label="t('port_forwards_add_btn')" severity="success"
|
|
@click="addPortForward" />
|
|
</div>
|
|
|
|
<Dialog v-model:visible="editingPortForward" modal :header="t('edit_port_forward')"
|
|
:style="{ width: '90vw', maxWidth: '600px' }">
|
|
<div v-if="editingPortForwardData" class="flex flex-col gap-4">
|
|
<SelectButton v-model="editingPortForwardData.proto" :options="portForwardProtocolOptions"
|
|
:allow-empty="false" />
|
|
<InputGroup>
|
|
<InputText v-model="editingPortForwardData.bind_ip"
|
|
:placeholder="t('port_forwards_bind_addr')" />
|
|
<InputGroupAddon>
|
|
<span style="font-weight: bold">:</span>
|
|
</InputGroupAddon>
|
|
<InputNumber v-model="editingPortForwardData.bind_port" :format="false" :step="1" mode="decimal"
|
|
:min="1" :max="65535" class="max-w-20" />
|
|
</InputGroup>
|
|
<InputGroup>
|
|
<InputText v-model="editingPortForwardData.dst_ip" :placeholder="t('port_forwards_dst_addr')" />
|
|
<InputGroupAddon>
|
|
<span style="font-weight: bold">:</span>
|
|
</InputGroupAddon>
|
|
<InputNumber v-model="editingPortForwardData.dst_port" :format="false" :step="1" mode="decimal"
|
|
:min="1" :max="65535" class="max-w-20" />
|
|
</InputGroup>
|
|
</div>
|
|
<template #footer>
|
|
<Button :label="t('web.common.cancel')" icon="pi pi-times" @click="editingPortForward = false"
|
|
text />
|
|
<Button :label="t('web.common.save')" icon="pi pi-save" @click="savePortForward" />
|
|
</template>
|
|
</Dialog>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
|
|
<div class="flex pt-6 justify-center">
|
|
<Button :label="t('run_network')" icon="pi pi-arrow-right" icon-pos="right" :disabled="configInvalid"
|
|
@click="$emit('runNetwork', curNetwork)" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|