Finalize network view move to vue

This commit is contained in:
Johannes Zellner
2025-01-23 16:19:23 +01:00
parent fa3bdb5464
commit 5ea4c90292
6 changed files with 539 additions and 159 deletions
+168
View File
@@ -0,0 +1,168 @@
<script setup>
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? import.meta.env.VITE_API_ORIGIN : window.location.origin;
import { ref, onMounted, useTemplateRef, computed } from 'vue';
import { Dialog, FormGroup } from 'pankow';
import Section from '../components/Section.vue';
import NetworkModel from '../models/NetworkModel.js';
const networkModel = NetworkModel.create(API_ORIGIN, localStorage.token);
const blocklistLength = ref(0);
const blocklist = ref('');
const blocklistDialog = useTemplateRef('blocklistDialog');
const editBlocklistError = ref('');
const editBlocklistBusy = ref(false);
const editBlocklist = ref('');
const isBlocklistValid = computed(() => {
return true;
});
async function refreshBlocklist() {
const [error, result] = await networkModel.getBlocklist();
if (error) return console.error(error);
blocklist.value = result;
blocklistLength.value = result.split('\n').filter(function (l) { return l.length !== 0 && l[0] !== '#'; }).length;
}
function onBlocklistConfigure() {
editBlocklistBusy.value = false;
editBlocklistError.value = '';
editBlocklist.value = blocklist.value;
blocklistDialog.value.open();
}
async function onBlocklistSubmit() {
if (!isBlocklistValid.value) return;
editBlocklistBusy.value = true;
const [error] = await networkModel.setBlocklist(editBlocklist.value);
if (error) {
editBlocklistBusy.value = false;
editBlocklistError.value = error.body ? error.body.message : 'Internal error';
return;
}
await refreshBlocklist();
blocklistDialog.value.close();
editBlocklistBusy.value = false;
}
const trustedIpsLength = ref(0);
const trustedIps = ref('');
const trustedIpsDialog = useTemplateRef('trustedIpsDialog');
const editTrustedIpsError = ref('');
const editTrustedIpsBusy = ref(false);
const editTrustedIps = ref('');
const isTrustedIpsValid = computed(() => {
return true;
});
function onTrustedIpsConfigure() {
editTrustedIpsError.value = '';
editTrustedIpsBusy.value = false;
editTrustedIps.value = trustedIps.value;
trustedIpsDialog.value.open();
}
async function onTrustedIpsSubmit() {
if (!isTrustedIpsValid.value) return;
editTrustedIpsBusy.value = true;
const [error] = await networkModel.setTrustedIps(editTrustedIps.value);
if (error) {
editTrustedIpsBusy.value = false;
editTrustedIpsError.value = error.body ? error.body.message : 'Internal error';
return;
}
await refreshTrustedIps();
trustedIpsDialog.value.close();
editTrustedIpsBusy.value = false;
}
async function refreshTrustedIps() {
const [error, result] = await networkModel.getTrustedIps();
if (error) return console.error(error);
trustedIps.value = result;
trustedIpsLength.value = result.split('\n').filter(function (l) { return l.length !== 0 && l[0] !== '#'; }).length;
}
onMounted(async () => {
await refreshBlocklist();
await refreshTrustedIps();
});
</script>
<template>
<div>
<Dialog ref="blocklistDialog"
:title="$t('network.firewall.configure.title')"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="editBlocklistBusy"
:confirm-active="!editBlocklistBusy && isBlocklistValid"
:reject-label="$t('main.dialog.cancel')"
reject-style="secondary"
@confirm="onBlocklistSubmit()"
>
<div>
<p class="small">{{ $t('network.firewall.configure.description') }}</p>
<form novalidate @submit.prevent="onBlocklistSubmit()" autocomplete="off">
<fieldset :disabled="editBlocklistBusy">
<input style="display: none" type="submit" :disabled="editBlocklistBusy || !isBlocklistValid"/>
<FormGroup>
<label for="blocklistInput">{{ $t('network.firewall.blockedIpRanges') }}</label>
<div class="has-error" v-show="editBlocklistError">{{ editBlocklistError }}</div>
<textarea id="blocklistInput" v-model="editBlocklist" :placeholder="$t('network.firewall.configure.blocklistPlaceholder')" rows="4"></textarea>
</FormGroup>
</fieldset>
</form>
</div>
</Dialog>
<Dialog ref="trustedIpsDialog"
:title="$t('network.trustedIps.title')"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="editTrustedIpsBusy"
:confirm-active="!editTrustedIpsBusy && isTrustedIpsValid"
:reject-label="$t('main.dialog.cancel')"
reject-style="secondary"
@confirm="onTrustedIpsSubmit()"
>
<div>
<p class="small">{{ $t('network.trustedIps.description') }}</p>
<form novalidate @submit.prevent="onTrustedIpsSubmit()" autocomplete="off">
<fieldset :disabled="editTrustedIpsBusy">
<input style="display: none;" type="submit" :disabled="editTrustedIpsBusy || !isTrustedIpsValid"/>
<FormGroup>
<label for="">{{ $t('network.trustedIpRanges') }}</label>
<div class="has-error" v-show="editTrustedIpsError">{{ editTrustedIpsError }}</div>
<textarea v-model="editTrustedIps" :placeholder="$t('network.firewall.configure.blocklistPlaceholder')" rows="4"></textarea>
</FormGroup>
</fieldset>
</form>
</div>
</Dialog>
<Section :title="$t('network.firewall.title')">
<div class="info-row">
<div class="info-label">{{ $t('network.firewall.blockedIpRanges') }}</div>
<div class="info-value actionable" @click="onBlocklistConfigure()">{{ $t('network.firewall.blocklist', { blockCount: blocklistLength }) }} <i class="fa-solid fa-edit text-small"></i></div>
</div>
<div class="info-row">
<div class="info-label">{{ $t('network.trustedIpRanges') }}</div>
<div class="info-value actionable" @click="onTrustedIpsConfigure()">{{ $t('network.trustedIps.summary', { trustCount: trustedIpsLength }) }} <i class="fa-solid fa-edit text-small"></i></div>
</div>
</Section>
</div>
</template>
+162
View File
@@ -0,0 +1,162 @@
<script setup>
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? import.meta.env.VITE_API_ORIGIN : window.location.origin;
import { ref, onMounted, useTemplateRef, computed } from 'vue';
import { Button, Dialog, Dropdown, FormGroup, TextInput } from 'pankow';
import Section from '../components/Section.vue';
import NetworkModel from '../models/NetworkModel.js';
const networkModel = NetworkModel.create(API_ORIGIN, localStorage.token);
// keep in sync with sysinfo.js
const providers = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
];
function prettyIpProviderName(provider) {
switch (provider) {
case 'noop': return 'Disabled';
case 'generic': return 'Public IP';
case 'fixed': return 'Static IP Address';
case 'network-interface': return 'Network Interface';
default: return 'Unknown';
}
}
const provider = ref('');
const address = ref('');
const detectedAddress = ref('');
const interfaceName = ref('');
const dialog = useTemplateRef('dialog');
const editError = ref({});
const editBusy = ref(false);
const editProvider = ref('');
const editAddress = ref('');
const editInterfaceName = ref('');
const isValid = computed(() => {
if (editProvider.value === 'fixed' && !editAddress.value) return false;
if (editProvider.value === 'network-interface' && !editInterfaceName.value) return false;
return true;
});
async function refresh() {
let [error, result] = await networkModel.getIpv4Config();
if (error) return console.error(error);
provider.value = result.provider;
address.value = result.ip;
interfaceName.value = result.ifname;
[error, result] = await networkModel.getIpv4();
if (error) return console.error(error);
detectedAddress.value = result;
}
function onConfigure() {
editBusy.value = false;
editError.value = {};
editProvider.value = provider.value;
editAddress.value = address.value;
editInterfaceName.value = interfaceName.value;
dialog.value.open();
}
async function onSubmit() {
if (!isValid.value) return;
editBusy.value = true;
const [error] = await networkModel.setIpv4Config(editProvider.value, editAddress.value, editInterfaceName.value);
if (error) {
editBusy.value = false;
if (error.body && error.body.message === 'invalid IPv4') editError.value.ipv4 = error.body.message;
else if (error.body && error.body.message.indexOf('No interface named') === 0) editError.value.ifname = error.body.message;
else editError.value.generic = error.body ? error.body.message : 'Internal error';
return;
}
await refresh();
dialog.value.close();
editBusy.value = false;
}
onMounted(async () => {
await refresh();
});
</script>
<template>
<div>
<Dialog ref="dialog"
:title="$t('network.configureIp.title')"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="editBusy"
:confirm-active="isValid"
:reject-label="$t('main.dialog.cancel')"
reject-style="secondary"
@confirm="onSubmit()"
>
<div>
<form novalidate @submit.prevent="onSubmit()" autocomplete="off">
<fieldset :disabled="editBusy">
<input style="display: none" type="submit" :disabled="editBusy || !isValid"/>
<FormGroup>
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/networking/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<!-- <select v-model="sysinfo.newProvider" :options="a.value as a.name for a in sysinfoProvider"></select> -->
<Dropdown id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name"/>
<p class="has-error" v-show="editError.generic">{{ editError.generic }}</p>
</FormGroup>
<p v-show="editProvider === 'generic'">
{{ $t('network.configureIp.providerGenericDescription') }} <sup><a ng-href="https://ipv4.api.cloudron.io/api/v1/helper/public_ip" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
</p>
<!-- Fixed -->
<FormGroup v-show="editProvider === 'fixed'">
<label for="addressInput">{{ $t('network.ipv4.address') }}</label>
<TextInput id="addressInput" v-model="editAddress" :required="editProvider === 'fixed'" />
<p class="has-error" v-show="editError.ipv4">{{ editError.ipv4 }}</p>
</FormGroup>
<!-- Network Interface -->
<FormGroup v-show="editProvider === 'network-interface'">
<label for="interfaceNameInput">{{ $t('network.ip.interface') }}</label>
<p>{{ $t('network.ip.interfaceDescription') }} <code>ip -f inet -br addr</code></p>
<TextInput id="interfaceNameInput" v-model="editInterfaceName" :required="editProvider === 'network-interface'" />
<p class="has-error" v-show="editError.ifname">{{ editError.ifname }}</p>
</FormGroup>
</fieldset>
</form>
</div>
</Dialog>
<Section :title="$t('network.ip.title')">
<p>{{ $t('network.ip.description') }}</p>
<div class="info-row">
<div class="info-label">{{ $t('network.ip.provider') }}</div>
<div class="info-value">{{ prettyIpProviderName(provider) }}</div>
</div>
<div class="info-row" v-show="provider !== 'noop'">
<div class="info-label">{{ $t('network.ip.address') }}</div>
<div class="info-value">{{ address || `${detectedAddress} (${$t('network.ip.detected')})` }}</div>
</div>
<div class="info-row" v-show="interfaceName">
<div class="info-label">{{ $t('network.ip.interface') }}</div>
<div class="info-value">{{ interfaceName }}</div>
</div>
<Button @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
</Section>
</div>
</template>
+162
View File
@@ -0,0 +1,162 @@
<script setup>
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? import.meta.env.VITE_API_ORIGIN : window.location.origin;
import { ref, onMounted, useTemplateRef, computed } from 'vue';
import { Button, Dialog, Dropdown, FormGroup, TextInput } from 'pankow';
import Section from '../components/Section.vue';
import NetworkModel from '../models/NetworkModel.js';
const networkModel = NetworkModel.create(API_ORIGIN, localStorage.token);
// keep in sync with sysinfo.js
const providers = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
];
function prettyIpProviderName(provider) {
switch (provider) {
case 'noop': return 'Disabled';
case 'generic': return 'Public IP';
case 'fixed': return 'Static IP Address';
case 'network-interface': return 'Network Interface';
default: return 'Unknown';
}
}
const provider = ref('');
const address = ref('');
const detectedAddress = ref('');
const interfaceName = ref('');
const dialog = useTemplateRef('dialog');
const editError = ref({});
const editBusy = ref(false);
const editProvider = ref('');
const editAddress = ref('');
const editInterfaceName = ref('');
const isValid = computed(() => {
if (editProvider.value === 'fixed' && !editAddress.value) return false;
if (editProvider.value === 'network-interface' && !editInterfaceName.value) return false;
return true;
});
async function refresh() {
let [error, result] = await networkModel.getIpv6Config();
if (error) return console.error(error);
provider.value = result.provider;
address.value = result.ip;
interfaceName.value = result.ifname;
[error, result] = await networkModel.getIpv6();
if (error) return console.error(error);
detectedAddress.value = result;
}
function onConfigure() {
editBusy.value = false;
editError.value = {};
editProvider.value = provider.value;
editAddress.value = address.value;
editInterfaceName.value = interfaceName.value;
dialog.value.open();
}
async function onSubmit() {
if (!isValid.value) return;
editBusy.value = true;
const [error] = await networkModel.setIpv6Config(editProvider.value, editAddress.value, editInterfaceName.value);
if (error) {
editBusy.value = false;
if (error.body && error.body.message === 'invalid IPv6') editError.value.ipv4 = error.body.message;
else if (error.body && error.body.message.indexOf('No interface named') === 0) editError.value.ifname = error.body.message;
else editError.value.generic = error.body ? error.body.message : 'Internal error';
return;
}
await refresh();
dialog.value.close();
editBusy.value = false;
}
onMounted(async () => {
await refresh();
});
</script>
<template>
<div>
<Dialog ref="dialog"
:title="$t('network.configureIpv6.title')"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="editBusy"
:confirm-active="isValid"
:reject-label="$t('main.dialog.cancel')"
reject-style="secondary"
@confirm="onSubmit()"
>
<div>
<form novalidate @submit.prevent="onSubmit()" autocomplete="off">
<fieldset :disabled="editBusy">
<input style="display: none" type="submit" :disabled="editBusy || !isValid"/>
<FormGroup>
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/networking/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<!-- <select v-model="sysinfo.newProvider" :options="a.value as a.name for a in sysinfoProvider"></select> -->
<Dropdown id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name"/>
<p class="has-error" v-show="editError.generic">{{ editError.generic }}</p>
</FormGroup>
<p v-show="editProvider === 'generic'">
{{ $t('network.configureIp.providerGenericDescription') }} <sup><a ng-href="https://ipv4.api.cloudron.io/api/v1/helper/public_ip" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
</p>
<!-- Fixed -->
<FormGroup v-show="editProvider === 'fixed'">
<label for="addressInput">{{ $t('network.ipv4.address') }}</label>
<TextInput id="addressInput" v-model="editAddress" :required="editProvider === 'fixed'" />
<p class="has-error" v-show="editError.ipv4">{{ editError.ipv4 }}</p>
</FormGroup>
<!-- Network Interface -->
<FormGroup v-show="editProvider === 'network-interface'">
<label for="interfaceNameInput">{{ $t('network.ip.interface') }}</label>
<p>{{ $t('network.ip.interfaceDescription') }} <code>ip -f inet -br addr</code></p>
<TextInput id="interfaceNameInput" v-model="editInterfaceName" :required="editProvider === 'network-interface'" />
<p class="has-error" v-show="editError.ifname">{{ editError.ifname }}</p>
</FormGroup>
</fieldset>
</form>
</div>
</Dialog>
<Section :title="$t('network.ipv6.title')">
<p>{{ $t('network.ipv6.description') }}</p>
<div class="info-row">
<div class="info-label">{{ $t('network.ip.provider') }}</div>
<div class="info-value">{{ prettyIpProviderName(provider) }}</div>
</div>
<div class="info-row" v-show="provider !== 'noop'">
<div class="info-label">{{ $t('network.ip.address') }}</div>
<div class="info-value">{{ address || `${detectedAddress} (${$t('network.ip.detected')})` }}</div>
</div>
<div class="info-row" v-show="interfaceName">
<div class="info-label">{{ $t('network.ip.interface') }}</div>
<div class="info-value">{{ interfaceName }}</div>
</div>
<Button @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
</Section>
</div>
</template>
+32
View File
@@ -14,6 +14,22 @@ function create(origin, accessToken) {
if (error || result.status !== 200) return [error || result];
return [null, result.body];
},
async setIpv4Config(provider, ip, ifname) {
const data = { provider };
if (provider === 'fixed') data.ip = ip;
else if (provider === 'network-interface') data.ifname = ifname;
let error, result;
try {
result = await fetcher.post(`${origin}/api/v1/network/ipv4_config`, data, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 200) return [error || result];
return [null];
},
async getIpv4() {
let error, result;
try {
@@ -36,6 +52,22 @@ function create(origin, accessToken) {
if (error || result.status !== 200) return [error || result];
return [null, result.body];
},
async setIpv6Config(provider, ip, ifname) {
const data = { provider };
if (provider === 'fixed') data.ip = ip;
else if (provider === 'network-interface') data.ifname = ifname;
let error, result;
try {
result = await fetcher.post(`${origin}/api/v1/network/ipv6_config`, data, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 200) return [error || result];
return [null];
},
async getIpv6() {
let error, result;
try {
+9 -1
View File
@@ -150,4 +150,12 @@ tr:hover .table-actions {
text-overflow: ellipsis;
overflow: hidden;
text-wrap: nowrap;
}
}
.actionable {
cursor: pointer;
}
.actionable:hover {
color: var(--pankow-color-primary);
}
+6 -158
View File
@@ -5,115 +5,13 @@ const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? import.meta.env.VITE_API_OR
import { ref, onMounted } from 'vue';
import { Button } from 'pankow';
import Section from '../components/Section.vue';
import Ipv4Config from '../components/Ipv4Config.vue';
import Ipv6Config from '../components/Ipv6Config.vue';
import Firewall from '../components/Firewall.vue';
import NetworkModel from '../models/NetworkModel.js';
const networkModel = NetworkModel.create(API_ORIGIN, localStorage.token);
// keep in sync with sysinfo.js
const ipv4Providers = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
];
const ipv6Providers = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
];
function prettyIpProviderName(provider) {
switch (provider) {
case 'noop': return 'Disabled';
case 'generic': return 'Public IP';
case 'fixed': return 'Static IP Address';
case 'network-interface': return 'Network Interface';
default: return 'Unknown';
}
}
// IPv4
const ipv4Provider = ref('');
const ipv4Address = ref('');
const ipv4DetectedAddress = ref('');
const ipv4InterfaceName = ref('');
async function refreshIpv4() {
let [error, result] = await networkModel.getIpv4Config();
if (error) return console.error(error);
ipv4Provider.value = result.provider;
ipv4Address.value = result.ip;
ipv4InterfaceName.value = result.ifname;
[error, result] = await networkModel.getIpv4();
if (error) return console.error(error);
ipv4DetectedAddress.value = result;
}
function onIpv4Configure() {
}
// IPv6
const ipv6Provider = ref('');
const ipv6Address = ref('');
const ipv6DetectedAddress = ref('');
const ipv6InterfaceName = ref('');
async function refreshIpv6() {
let [error, result] = await networkModel.getIpv6Config();
if (error) return console.error(error);
ipv6Provider.value = result.provider;
ipv6Address.value = result.ip;
ipv6InterfaceName.value = result.ifname;
[error, result] = await networkModel.getIpv6();
if (error) return console.error(error);
ipv6DetectedAddress.value = result;
}
function onIpv6Configure() {
}
// Firewall
const blocklistLength = ref(0);
const blocklist = ref('');
function onBlocklistEdit() {
}
async function refreshBlocklist() {
const [error, result] = await networkModel.getBlocklist();
if (error) return console.error(error);
blocklist.value = result;
blocklistLength.value = result.split('\n').filter(function (l) { return l.length !== 0 && l[0] !== '#'; }).length;
}
const trustedIpsLength = ref(0);
const trustedIps = ref('');
function onTrustedIpsEdit() {
}
async function refreshTrustedIps() {
const [error, result] = await networkModel.getTrustedIps();
if (error) return console.error(error);
trustedIps.value = result;
trustedIpsLength.value = result.split('\n').filter(function (l) { return l.length !== 0 && l[0] !== '#'; }).length;
}
// dyndns
const dynDnsBusy = ref(false);
const dynDnsIsEnabled = ref(false);
@@ -135,11 +33,7 @@ async function onDyndnsToggle() {
}
onMounted(async () => {
await refreshIpv4();
await refreshIpv6();
await refreshDynDns();
await refreshBlocklist();
await refreshTrustedIps();
});
</script>
@@ -148,55 +42,9 @@ onMounted(async () => {
<div class="content">
<h1>{{ $t('network.title') }}</h1>
<Section :title="$t('network.ip.title')">
<p>{{ $t('network.ip.description') }}</p>
<div class="info-row">
<div class="info-label">{{ $t('network.ip.provider') }}</div>
<div class="info-value">{{ prettyIpProviderName(ipv4Provider) }}</div>
</div>
<div class="info-row">
<div class="info-label">{{ $t('network.ip.address') }}</div>
<div class="info-value">{{ ipv4Address || `${ipv4DetectedAddress} (${$t('network.ip.detected')})` }}</div>
</div>
<div class="info-row" v-show="ipv4InterfaceName">
<div class="info-label">{{ $t('network.ip.interface') }}</div>
<div class="info-value">{{ ipv4InterfaceName }}</div>
</div>
<Button @click="onIpv4Configure()">{{ $t('network.ip.configure') }}</Button>
</Section>
<Section :title="$t('network.ipv6.title')">
<p>{{ $t('network.ipv6.description') }}</p>
<div class="info-row">
<div class="info-label">{{ $t('network.ip.provider') }}</div>
<div class="info-value">{{ prettyIpProviderName(ipv6Provider) }}</div>
</div>
<div class="info-row" v-show="ipv6Provider !== 'noop'">
<div class="info-label">{{ $t('network.ip.address') }}</div>
<div class="info-value">{{ ipv6Address || `${ipv6DetectedAddress} (${$t('network.ip.detected')})` }}</div>
<!-- TODO <span ng-show="ipv6Configure.displayError" class="text-danger">{{ ipv6Configure.displayError }}</span> -->
</div>
<div class="info-row" v-show="ipv6InterfaceName">
<div class="info-label">{{ $t('network.ip.interface') }}</div>
<div class="info-value">{{ ipv6InterfaceName }}</div>
</div>
<Button @click="onIpv6Configure()">{{ $t('network.ip.configure') }}</Button>
</Section>
<Section :title="$t('network.firewall.title')">
<div class="info-row">
<div class="info-label">{{ $t('network.firewall.blockedIpRanges') }}</div>
<div class="info-value">{{ $t('network.firewall.blocklist', { blockCount: blocklistLength }) }} <span @click="onBlocklistEdit()"><i class="fa-solid fa-edit text-small"></i></span></div>
</div>
<div class="info-row">
<div class="info-label">{{ $t('network.trustedIpRanges') }}</div>
<div class="info-value">{{ $t('network.trustedIps.summary', { trustCount: trustedIpsLength }) }} <span @click="onTrustedIpsEdit()"><i class="fa-solid fa-edit text-small"></i></span></div>
</div>
</Section>
<Ipv4Config />
<Ipv6Config />
<Firewall />
<Section :title="$t('network.dyndns.title')">
<p>{{ $t('network.dyndns.description') }}</p>