Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m5s
809 lines
34 KiB
React
809 lines
34 KiB
React
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>
|
||
</>
|
||
);
|
||
}
|