Files
router-lists-ui/frontend/src/SettingsPage.jsx
T
2026-02-17 17:08:57 +07:00

809 lines
34 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import api from './lib/api.js';
import {
IconSettings,
IconDeviceFloppy,
IconPlugConnected,
IconNetwork,
IconCloud,
IconChartPie,
IconWorld,
IconSearch,
IconChartBar,
IconRefresh,
IconServer,
} from '@tabler/icons-react';
import FormField from './components/FormField';
import ErrorAlert from './components/ErrorAlert';
import PageHeader from './components/PageHeader';
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
/**
* Страница «Настройки интерфейса» — отдельный раздел в стиле Tabler UI и UniFi:
* коллапсируемые карточки-секции, иконки, чёткая структура.
*/
// Разделы бокового меню с группами (как Tabler Settings: Business settings / Experience)
const SIDEBAR_GROUPS = [
{
title: 'Подключения и сеть',
items: [
{ id: 'live-doh', title: 'BGP Live и DoH', icon: IconPlugConnected },
{ id: 'network-as', title: 'Сеть и AS', icon: IconNetwork },
],
},
{
title: 'Пинг',
items: [
{ id: 'ping', title: 'Пинг через MikroTik', icon: IconCloud },
{ id: 'ping-services', title: 'Пинг на главной', icon: IconChartPie },
],
},
{
title: 'DNS',
items: [
{ id: 'ptr-zone', title: 'PTR зона', icon: IconWorld },
],
},
{
title: 'Аналитика',
items: [
{ id: 'traffic-interfaces', title: 'Настройка Аналитики', icon: IconChartBar },
],
},
];
const SIDEBAR_SECTIONS = SIDEBAR_GROUPS.flatMap((g) => g.items);
export default function SettingsPage() {
const location = useLocation();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [etag, setEtag] = useState('');
const [rawSettings, setRawSettings] = useState({});
const [dohServer, setDohServer] = useState('');
const [wsUrl, setWsUrl] = useState('');
const [baseAS, setBaseAS] = useState('65001');
const [pingDomain, setPingDomain] = useState('');
const [pingCacheMinutes, setPingCacheMinutes] = useState('');
const [networkMapPingCacheSeconds, setNetworkMapPingCacheSeconds] = useState('');
const [ptrZoneReplaceFrom, setPtrZoneReplaceFrom] = useState('');
const [ptrZoneReplaceTo, setPtrZoneReplaceTo] = useState('');
const [pingServicesSource, setPingServicesSource] = useState('web');
const [pingServicesServerId, setPingServicesServerId] = useState('');
const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState('');
const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState('');
const [trafficInterfacesSelected, setTrafficInterfacesSelected] = useState([]);
const [trafficJumphosts, setTrafficJumphosts] = useState([]);
const [trafficInterfacesLoading, setTrafficInterfacesLoading] = useState(false);
const [trafficInterfacesError, setTrafficInterfacesError] = useState('');
const [serversList, setServersList] = useState([]);
const [sidebarSearch, setSidebarSearch] = useState('');
const [activeSection, setActiveSection] = useState(() => {
const hash = (typeof location.hash === 'string' && location.hash.slice(1)) || '';
return SIDEBAR_SECTIONS.some((s) => s.id === hash) ? hash : SIDEBAR_SECTIONS[0].id;
});
const routerServersForPing = useMemo(() => {
return (serversList || []).filter(
(s) =>
s &&
(String(s.type || '').toLowerCase() === 'jumphost' ||
String(s.type || '').toLowerCase() === 'home')
);
}, [serversList]);
const sidebarGroupsFiltered = useMemo(() => {
const q = (sidebarSearch || '').trim().toLowerCase();
if (!q) return SIDEBAR_GROUPS;
return SIDEBAR_GROUPS.map((group) => ({
...group,
items: group.items.filter(
(s) =>
s.title.toLowerCase().includes(q) || s.id.toLowerCase().includes(q)
),
})).filter((g) => g.items.length > 0);
}, [sidebarSearch]);
useEffect(() => {
const hash = (location.hash || '').slice(1);
if (hash && SIDEBAR_SECTIONS.some((s) => s.id === hash)) {
setActiveSection(hash);
}
}, [location.hash]);
const fetchTrafficInterfaces = useCallback(async () => {
setTrafficInterfacesLoading(true);
setTrafficInterfacesError('');
try {
const { data } = await api.get('/traffic/interface-stats');
const jumphosts = Array.isArray(data?.jumphosts) ? data.jumphosts : [];
const normalized = jumphosts.map((jh) => {
const interfaces = Array.isArray(jh.interfaces) ? jh.interfaces : [];
const names = interfaces
.filter((i) => i?.name != null && String(i.name).trim())
.map((i) => ({ ...i, name: String(i.name).trim() }))
.sort((a, b) => a.name.localeCompare(b.name));
return {
serverId: jh.serverId,
name: jh.name || jh.host || 'Jumphost',
host: jh.host,
error: jh.error,
interfaces: names,
};
});
setTrafficJumphosts(normalized);
} catch (e) {
setTrafficInterfacesError(e?.response?.data?.message || e?.message || 'Не удалось загрузить список интерфейсов');
setTrafficJumphosts([]);
} finally {
setTrafficInterfacesLoading(false);
}
}, []);
useEffect(() => {
if (activeSection !== 'traffic-interfaces' || trafficJumphosts.length > 0) return;
fetchTrafficInterfaces();
}, [activeSection, trafficJumphosts.length, fetchTrafficInterfaces]);
const getJumphostKey = useCallback((jh) => String(jh?.serverId || jh?.host || jh?.name || ''), []);
const trafficAllPairs = useMemo(() => {
const out = [];
for (const jh of trafficJumphosts) {
const sk = getJumphostKey(jh);
for (const i of jh.interfaces || []) {
if (i?.name) out.push({ serverKey: sk, interfaceName: i.name });
}
}
return out;
}, [trafficJumphosts, getJumphostKey]);
const isTrafficInterfaceSelected = useCallback(
(serverKey, interfaceName) =>
trafficInterfacesSelected.some(
(p) => p.serverKey === serverKey && p.interfaceName === interfaceName
),
[trafficInterfacesSelected]
);
const setTrafficInterfaceChecked = useCallback(
(serverKey, interfaceName, checked) => {
setTrafficInterfacesSelected((prev) => {
const next = prev.filter(
(p) => !(p.serverKey === serverKey && p.interfaceName === interfaceName)
);
if (checked) next.push({ serverKey, interfaceName });
return next;
});
},
[]
);
const goToSection = (id) => {
setActiveSection(id);
navigate(`/settings#${id}`, { replace: true });
};
useEffect(() => {
setError('');
setSuccess('');
setLoading(true);
(async () => {
try {
const [settingsRes, serversRes] = await Promise.all([
api.get('/ui-settings'),
api.get('/servers').catch(() => ({ data: [] })),
]);
const data = settingsRes?.data || {};
setRawSettings(data);
setDohServer(String(data?.dohServer || ''));
setWsUrl(String(data?.wsUpdateUrl || ''));
setBaseAS(String(data?.baseAS || '65001'));
setPingDomain(String(data?.pingDomain || '').trim());
setPingCacheMinutes(
data?.pingCacheMinutes != null ? String(data.pingCacheMinutes) : ''
);
setNetworkMapPingCacheSeconds(
data?.networkMapPingCacheSeconds != null
? String(data.networkMapPingCacheSeconds)
: ''
);
setPtrZoneReplaceFrom(String(data?.ptrZoneReplaceFrom || ''));
setPtrZoneReplaceTo(String(data?.ptrZoneReplaceTo || ''));
setPingServicesSource(
String(data?.pingServicesSource || 'web').toLowerCase() === 'router'
? 'router'
: 'web'
);
setPingServicesServerId(String(data?.pingServicesServerId || '').trim());
setPingServicesGatewayIp(
String(data?.pingServicesGatewayIp || '').trim()
);
setPingServicesCacheSeconds(
data?.pingServicesCacheSeconds != null
? String(data.pingServicesCacheSeconds)
: ''
);
const raw = data?.trafficInterfaces;
setTrafficInterfacesSelected(
Array.isArray(raw)
? raw
.filter(
(p) =>
p &&
(p.serverKey != null || p.serverId != null) &&
(p.interfaceName != null || p.name != null)
)
.map((p) => ({
serverKey: String(p.serverKey ?? p.serverId ?? ''),
interfaceName: String(p.interfaceName ?? p.name ?? ''),
}))
: []
);
const e =
settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
setEtag(e ? String(e) : '');
setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []);
} catch (e) {
setError('Не удалось загрузить настройки');
} finally {
setLoading(false);
}
})();
}, []);
const validateDoh = (value) => {
if (!value) return { valid: true, message: '' };
try {
const u = new URL(String(value));
return u.protocol === 'https:'
? { valid: true, message: 'Корректный HTTPS URL' }
: { valid: false, message: 'Используйте HTTPS' };
} catch {
return { valid: false, message: 'Некорректный URL' };
}
};
const validateWs = (value) => {
if (!value) return { valid: true, message: '' };
try {
const u = new URL(String(value));
return u.protocol === 'ws:' || u.protocol === 'wss:'
? { valid: true, message: 'Корректный WebSocket URL' }
: { valid: false, message: 'Используйте ws:// или wss://' };
} catch {
return { valid: false, message: 'Некорректный URL' };
}
};
const onSave = async () => {
setError('');
setSuccess('');
const dohValidation = validateDoh(dohServer);
const wsValidation = validateWs(wsUrl);
if (!dohValidation.valid) {
setError('Укажите корректный HTTPS URL для DoH');
return;
}
if (!wsValidation.valid) {
setError('Укажите корректный WebSocket URL (ws:// или wss://)');
return;
}
setSaving(true);
try {
const mergedSettings = {
...rawSettings,
dohServer: String(dohServer || '').trim(),
wsUpdateUrl: String(wsUrl || '').trim(),
baseAS: String(baseAS || '65001').trim(),
pingDomain: String(pingDomain || '').trim(),
pingCacheMinutes: Math.max(0, parseInt(pingCacheMinutes, 10) || 0),
networkMapPingCacheSeconds: Math.max(
0,
parseInt(networkMapPingCacheSeconds, 10) || 0
),
ptrZoneReplaceFrom: String(ptrZoneReplaceFrom || '').trim(),
ptrZoneReplaceTo: String(ptrZoneReplaceTo || '').trim(),
pingServicesSource:
pingServicesSource === 'router' ? 'router' : 'web',
pingServicesServerId: String(pingServicesServerId || '').trim(),
pingServicesGatewayIp: String(pingServicesGatewayIp || '').trim(),
pingServicesCacheSeconds: Math.max(
0,
parseInt(pingServicesCacheSeconds, 10) || 0
),
trafficInterfaces: Array.isArray(trafficInterfacesSelected)
? trafficInterfacesSelected.map((p) => ({
serverKey: p.serverKey,
interfaceName: p.interfaceName,
}))
: [],
};
const payload = { settings: mergedSettings, etag };
const res = await api.post('/ui-settings', payload);
const meta = res?.data || {};
setSuccess('Настройки успешно сохранены');
setEtag(String(meta?.etag || ''));
setRawSettings(mergedSettings);
setTimeout(() => setSuccess(''), 3000);
} catch (e) {
setError(
e?.response?.data?.message || 'Ошибка при сохранении настроек'
);
} finally {
setSaving(false);
}
};
// Заголовок секции без вложенной карточки (контент в общем card-body)
function SectionHeading({ title, icon: Icon }) {
return (
<h2 className="h3 mb-3 d-flex align-items-center">
{Icon && (
<span className="me-2 d-flex align-items-center text-muted">
<Icon size={22} />
</span>
)}
{title}
</h2>
);
}
if (loading) {
return (
<>
<PageHeader title="Настройки интерфейса" icon={<IconSettings size={24} />} pretitle="Интерфейс" />
<div className="card">
<div className="card-body text-center py-5">
<div className="spinner-border text-primary" role="status" />
<p className="mt-2 mb-0 text-muted">Загрузка настроек</p>
</div>
</div>
</>
);
}
return (
<>
{error && (
<ErrorAlert message={error} onClose={() => setError('')} />
)}
{success && (
<div className="alert alert-success alert-dismissible mb-3" role="alert">
<div className="d-flex">
<div className="flex-grow-1">{success}</div>
</div>
<button type="button" className="btn-close" onClick={() => setSuccess('')} aria-label="Закрыть" />
</div>
)}
<PageHeader
title="Настройки интерфейса"
icon={<IconSettings size={24} />}
pretitle="Интерфейс"
meta="WebSocket, DoH, пинг, PTR зона"
actions={
<div className="btn-list">
<button
type="button"
className="btn btn-primary"
onClick={onSave}
disabled={saving}
>
{saving && <span className="spinner-border spinner-border-sm me-2" />}
<IconDeviceFloppy size={18} className="me-1" />
Сохранить
</button>
</div>
}
/>
{/* Вёрстка как на https://preview.tabler.io/settings.html: одна card, row g-0, col-md-3 border-end + col-md-9 */}
<div className="card">
<div className="row g-0">
<div className="col-12 col-md-3 border-end">
<div className="card-body">
<div className="input-icon mb-3">
<span className="input-icon-addon">
<IconSearch size={18} className="text-muted" />
</span>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск разделов"
value={sidebarSearch}
onChange={(e) => setSidebarSearch(e.target.value)}
aria-label="Поиск разделов"
/>
</div>
{sidebarGroupsFiltered.map((group, idx) => (
<div key={group.title} className={idx > 0 ? 'mt-4' : ''}>
<h4 className="subheader">{group.title}</h4>
<div className="list-group list-group-transparent">
{group.items.map((section) => {
const Icon = section.icon;
const isActive = activeSection === section.id;
return (
<button
key={section.id}
type="button"
className={`list-group-item list-group-item-action d-flex align-items-center border-0 ${isActive ? 'active' : ''}`}
onClick={() => goToSection(section.id)}
>
<span className="me-2 d-flex opacity-75">
<Icon size={18} />
</span>
{section.title}
</button>
);
})}
</div>
</div>
))}
{sidebarGroupsFiltered.length === 0 && (
<p className="text-muted small mb-0">Нет подходящих разделов</p>
)}
</div>
</div>
<div className="col-12 col-md-9 d-flex flex-column">
<div className="card-body">
{activeSection === 'live-doh' && (
<>
<SectionHeading title="BGP Live и DNS (DoH)" icon={IconPlugConnected} />
<div className="row g-2">
<div className="col-12">
<FormField
label="WebSocket URL (BGP Live)"
name="wsUrl"
type="text"
value={wsUrl}
onChange={setWsUrl}
onValidate={validateWs}
placeholder="ws://host:port/ws/update_bgp?api_key=..."
helpText="URL для Live-обновления BGP (ws:// или wss://). Можно оставить пустым."
disabled={saving}
/>
</div>
<div className="col-12">
<FormField
label="DoH сервер"
name="dohServer"
type="text"
value={dohServer}
onChange={setDohServer}
onValidate={validateDoh}
placeholder="https://dns.google/dns-query"
helpText="HTTPS URL для DNS-over-HTTPS"
disabled={saving}
/>
</div>
</div>
</>
)}
{activeSection === 'network-as' && (
<>
<SectionHeading title="Сеть и AS" icon={IconNetwork} />
<div className="row g-2">
<div className="col-md-6">
<FormField
label="Базовая AS"
name="baseAS"
type="text"
value={baseAS}
onChange={setBaseAS}
placeholder="65001"
helpText="AS по умолчанию для community (например, 65001)."
disabled={saving}
/>
</div>
</div>
</>
)}
{activeSection === 'ping' && (
<>
<SectionHeading title="Пинг через MikroTik" icon={IconCloud} />
<div className="row g-2">
<div className="col-md-6">
<FormField
label="Домен для пинга"
name="pingDomain"
type="text"
value={pingDomain}
onChange={setPingDomain}
placeholder="8.8.8.8 или ya.ru"
helpText="Домен или IP для проверки пинга через MikroTik."
disabled={saving}
/>
</div>
<div className="col-md-6">
<FormField
label="Срок кеша пинга (мин)"
name="pingCacheMinutes"
type="number"
value={pingCacheMinutes}
onChange={setPingCacheMinutes}
placeholder="0"
helpText="0 — без кеша. Иначе результаты пинга кешируются в S3."
disabled={saving}
min={0}
/>
</div>
<div className="col-md-6">
<FormField
label="Кеш пингов на карте сети (сек)"
name="networkMapPingCacheSeconds"
type="number"
value={networkMapPingCacheSeconds}
onChange={setNetworkMapPingCacheSeconds}
placeholder="0"
helpText="0 — без кеша. Иначе пинги между серверами на карте сети кешируются на указанный срок."
disabled={saving}
min={0}
/>
</div>
</div>
</>
)}
{activeSection === 'ping-services' && (
<>
<SectionHeading title="Пинг сервисов на главной" icon={IconChartPie} />
<div className="mb-3">
<label className="form-label">Источник пинга</label>
<select
className="form-select"
value={pingServicesSource}
onChange={(e) => setPingServicesSource(e.target.value)}
disabled={saving}
>
<option value="web">
Веб (TCP с сервера приложения)
</option>
<option value="router">Роутер (RouterOS API)</option>
</select>
<div className="form-text">
«Веб» задержка до 8.8.8.8, 1.1.1.1 с сервера. «Роутер»
пинг через выбранный MikroTik (jumphost).
</div>
</div>
<div className="mb-3">
<FormField
label="Время кеширования пингов (сек)"
name="pingServicesCacheSeconds"
type="number"
value={pingServicesCacheSeconds}
onChange={setPingServicesCacheSeconds}
placeholder="0"
helpText="0 — без кеша. Результаты пинга кешируются на указанное число секунд."
disabled={saving}
min={0}
/>
</div>
{pingServicesSource === 'router' && (
<div className="row g-2">
<div className="col-md-6">
<label className="form-label">Домашний роутер</label>
<div className={saving ? 'opacity-75 pe-none' : ''}>
<ServerAutocompleteInput
value={pingServicesServerId}
onChange={(v) =>
setPingServicesServerId(String(v || '').trim())
}
servers={routerServersForPing}
placeholder="Выберите роутер (jumphost или входной)"
className="form-control"
maxSuggestions={10}
/>
</div>
<div className="form-text">
Роутер с MikroTik API для пинга с главной. Пусто
первый jumphost.
</div>
</div>
<div className="col-md-6">
<FormField
label="IP шлюза (не обязательно)"
name="pingServicesGatewayIp"
type="text"
value={pingServicesGatewayIp}
onChange={setPingServicesGatewayIp}
placeholder="IP шлюза"
helpText="Пусто — первый шлюз выбранного роутера."
disabled={saving}
/>
</div>
</div>
)}
</>
)}
{activeSection === 'ptr-zone' && (
<>
<SectionHeading title="Настройка PTR зоны" icon={IconWorld} />
<div className="row g-2">
<div className="col-md-6">
<FormField
label="Заменить в DNS домене"
name="ptrZoneReplaceFrom"
type="text"
value={ptrZoneReplaceFrom}
onChange={setPtrZoneReplaceFrom}
placeholder="rt.shx"
helpText='Часть DNS домена для замены (например, "rt.shx")'
disabled={saving}
/>
</div>
<div className="col-md-6">
<FormField
label="Заменить на"
name="ptrZoneReplaceTo"
type="text"
value={ptrZoneReplaceTo}
onChange={setPtrZoneReplaceTo}
placeholder="shrt"
helpText='На что заменить. Пример: "selectel.msk.rt.shx.su" → "selectel.msk.shrt.su"'
disabled={saving}
/>
</div>
</div>
</>
)}
{activeSection === 'traffic-interfaces' && (
<>
<SectionHeading title="Настройка Аналитики" icon={IconChartBar} />
<p className="text-muted mb-3">
Отметьте интерфейсы, которые нужно учитывать на странице <strong>Расход трафика</strong>.
Если ни один не выбран учитываются все интерфейсы.
</p>
<div className="mb-3">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<span className="form-label mb-0">Учитывать интерфейсы</span>
<span className="d-flex align-items-center gap-2">
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setTrafficInterfacesSelected([...trafficAllPairs])}
disabled={saving || trafficInterfacesLoading || trafficAllPairs.length === 0}
>
Выбрать все
</button>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setTrafficInterfacesSelected([])}
disabled={saving}
>
Снять все
</button>
<button
type="button"
className="btn btn-sm btn-ghost-secondary btn-icon"
onClick={fetchTrafficInterfaces}
disabled={saving || trafficInterfacesLoading}
title="Обновить список интерфейсов"
aria-label="Обновить список"
>
<IconRefresh size={18} className={trafficInterfacesLoading ? 'spin' : ''} />
</button>
</span>
</div>
{trafficInterfacesError && (
<div className="alert alert-warning py-2 mb-3">
{trafficInterfacesError}
</div>
)}
{trafficInterfacesLoading && trafficJumphosts.length === 0 && (
<div className="text-muted py-4 d-flex align-items-center gap-2">
<span className="spinner-border spinner-border-sm" role="status" aria-hidden="true" />
Загрузка списка интерфейсов с роутеров
</div>
)}
{!trafficInterfacesLoading && trafficJumphosts.length === 0 && !trafficInterfacesError && (
<div className="text-muted py-3">
Нет доступных серверов. Добавьте jumphost-серверы с MikroTik API и нажмите «Обновить».
</div>
)}
{trafficJumphosts.length > 0 && (
<div className="row row-cards g-3">
{trafficJumphosts.map((jh) => (
<div key={jh.serverId || jh.host || jh.name} className="col-12 col-xl-6">
<div className="card">
<div className="card-header d-flex align-items-center">
<span className="avatar avatar-sm me-2 bg-blue-lt text-blue">
<IconServer size={18} />
</span>
<div className="flex-grow-1 min-w-0">
<h3 className="card-title mb-0 text-truncate" title={jh.name}>
{jh.name}
</h3>
{jh.host && (
<div className="text-muted small text-truncate" title={jh.host}>
{jh.host}
</div>
)}
</div>
{!jh.error && (jh.interfaces?.length ?? 0) > 0 && (() => {
const sk = getJumphostKey(jh);
const pairs = (jh.interfaces || []).map((i) => ({ serverKey: sk, interfaceName: i.name }));
const allChecked = pairs.every((p) => isTrafficInterfaceSelected(p.serverKey, p.interfaceName));
return (
<button
type="button"
className="btn btn-sm btn-ghost-secondary"
onClick={() => {
setTrafficInterfacesSelected((prev) => {
const next = prev.filter(
(x) => !pairs.some((p) => p.serverKey === x.serverKey && p.interfaceName === x.interfaceName)
);
if (!allChecked) next.push(...pairs);
return next;
});
}}
disabled={saving}
title="Выбрать / снять все на этом сервере"
>
{allChecked ? 'Снять все' : 'Выбрать все'}
</button>
);
})()}
</div>
<div className="card-body">
{jh.error && (
<div className="alert alert-warning py-2 mb-0">
{jh.error}
</div>
)}
{!jh.error && (!jh.interfaces || jh.interfaces.length === 0) && (
<div className="text-muted small">Нет интерфейсов</div>
)}
{!jh.error && (jh.interfaces?.length ?? 0) > 0 && (
<div className="row g-2">
{jh.interfaces.map((iface) => {
const sk = getJumphostKey(jh);
const checked = isTrafficInterfaceSelected(sk, iface.name);
return (
<div key={iface.name} className="col-12 col-sm-6">
<label className="form-check">
<input
className="form-check-input"
type="checkbox"
checked={checked}
onChange={(e) => setTrafficInterfaceChecked(sk, iface.name, e.target.checked)}
disabled={saving}
aria-label={`Интерфейс ${iface.name}`}
/>
<span className="form-check-label font-monospace">{iface.name}</span>
</label>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</>
)}
</div>
</div>
</div>
</div>
</>
);
}