Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m35s
2004 lines
92 KiB
React
2004 lines
92 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,
|
||
IconClock,
|
||
IconBell,
|
||
IconCpu,
|
||
IconDeviceDesktop,
|
||
IconDatabase,
|
||
IconBrain,
|
||
} 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 },
|
||
{ id: 'uptime-monitor', title: 'Uptime Monitor', icon: IconClock },
|
||
],
|
||
},
|
||
{
|
||
title: 'DNS',
|
||
items: [
|
||
{ id: 'ptr-zone', title: 'PTR зона', icon: IconWorld },
|
||
],
|
||
},
|
||
{
|
||
title: 'Аналитика',
|
||
items: [
|
||
{ id: 'traffic-interfaces', title: 'Настройка Аналитики', icon: IconChartBar },
|
||
{ id: 'route-ai', title: 'AI оптимизация маршрутов', icon: IconBrain },
|
||
],
|
||
},
|
||
{
|
||
title: 'Оповещения',
|
||
items: [
|
||
{ id: 'alerts', title: 'Настройки оповещений', icon: IconBell },
|
||
],
|
||
},
|
||
];
|
||
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 [pingServicesSchedulerEnabled, setPingServicesSchedulerEnabled] = useState(true);
|
||
const [pingServicesSchedulerIntervalMinutes, setPingServicesSchedulerIntervalMinutes] = useState('2');
|
||
const [interfaceSpeedTestProtocol, setInterfaceSpeedTestProtocol] = useState('tcp');
|
||
const [interfaceSpeedTestDurationSeconds, setInterfaceSpeedTestDurationSeconds] = useState('10');
|
||
const [interfaceSpeedTestCacheMinutes, setInterfaceSpeedTestCacheMinutes] = useState('');
|
||
const [trafficInterfacesSelected, setTrafficInterfacesSelected] = useState([]);
|
||
const [trafficJumphosts, setTrafficJumphosts] = useState([]);
|
||
const [trafficInterfacesLoading, setTrafficInterfacesLoading] = useState(false);
|
||
const [trafficInterfacesError, setTrafficInterfacesError] = useState('');
|
||
const [uptimeMonitorIntervalSeconds, setUptimeMonitorIntervalSeconds] = useState('120');
|
||
const [uptimeMonitorCheckType, setUptimeMonitorCheckType] = useState('http');
|
||
const [uptimeMonitorCacheSeconds, setUptimeMonitorCacheSeconds] = useState('120');
|
||
const [uptimeMonitorSchedulerEnabled, setUptimeMonitorSchedulerEnabled] = useState(true);
|
||
const [uptimeMonitorSchedulerIntervalMinutes, setUptimeMonitorSchedulerIntervalMinutes] = useState('2');
|
||
const [alertServerOffline, setAlertServerOffline] = useState(true);
|
||
const [alertServerOfflineMinutes, setAlertServerOfflineMinutes] = useState('5');
|
||
const [alertMikrotikUnreachable, setAlertMikrotikUnreachable] = useState(true);
|
||
const [alertMikrotikUnreachableMinutes, setAlertMikrotikUnreachableMinutes] = useState('5');
|
||
const [alertHighCpuEnabled, setAlertHighCpuEnabled] = useState(true);
|
||
const [alertHighCpuThreshold, setAlertHighCpuThreshold] = useState('85');
|
||
const [alertHighCpuDurationMinutes, setAlertHighCpuDurationMinutes] = useState('10');
|
||
const [alertHighRamEnabled, setAlertHighRamEnabled] = useState(true);
|
||
const [alertHighRamThreshold, setAlertHighRamThreshold] = useState('85');
|
||
const [alertHighRamDurationMinutes, setAlertHighRamDurationMinutes] = useState('10');
|
||
const [alertHighHddEnabled, setAlertHighHddEnabled] = useState(true);
|
||
const [alertHighHddThreshold, setAlertHighHddThreshold] = useState('90');
|
||
const [alertHighHddDurationMinutes, setAlertHighHddDurationMinutes] = useState('10');
|
||
const [alertMapLowSpeedMbps, setAlertMapLowSpeedMbps] = useState('40');
|
||
const [alertMapBelowNormSpeedMbps, setAlertMapBelowNormSpeedMbps] = useState('80');
|
||
const [alertMapNormalSpeedMbps, setAlertMapNormalSpeedMbps] = useState('120');
|
||
const [serversList, setServersList] = useState([]);
|
||
const [tunnelConnections, setTunnelConnections] = useState([]);
|
||
const [tunnelThresholds, setTunnelThresholds] = useState([]);
|
||
const [aiLatencyWeight, setAiLatencyWeight] = useState('0.55');
|
||
const [aiBandwidthWeight, setAiBandwidthWeight] = useState('0.35');
|
||
const [aiFreshnessWeight, setAiFreshnessWeight] = useState('0.10');
|
||
const [aiHomeToJhWeight, setAiHomeToJhWeight] = useState('0.45');
|
||
const [aiJhToExitWeight, setAiJhToExitWeight] = useState('0.55');
|
||
const [aiProbabilityScale, setAiProbabilityScale] = useState('5');
|
||
const [aiMinProbabilityGainForSwitch, setAiMinProbabilityGainForSwitch] = useState('10');
|
||
const [aiNoPingScore, setAiNoPingScore] = useState('0.2');
|
||
const [aiNoSpeedScore, setAiNoSpeedScore] = useState('0.15');
|
||
const [aiStaleScore, setAiStaleScore] = useState('0.35');
|
||
const [aiFreshnessExcellentSeconds, setAiFreshnessExcellentSeconds] = useState('120');
|
||
const [aiFreshnessGoodSeconds, setAiFreshnessGoodSeconds] = useState('600');
|
||
const [aiFreshnessFairSeconds, setAiFreshnessFairSeconds] = useState('1800');
|
||
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, networkRes] = await Promise.all([
|
||
api.get('/ui-settings'),
|
||
api.get('/servers').catch(() => ({ data: [] })),
|
||
api.get('/network-config').catch(() => ({ data: null })),
|
||
]);
|
||
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)
|
||
: ''
|
||
);
|
||
setPingServicesSchedulerEnabled(data?.pingServicesSchedulerEnabled !== false);
|
||
setPingServicesSchedulerIntervalMinutes(
|
||
data?.pingServicesSchedulerIntervalMinutes != null
|
||
? String(data.pingServicesSchedulerIntervalMinutes)
|
||
: '2'
|
||
);
|
||
setInterfaceSpeedTestProtocol(
|
||
String(data?.interfaceSpeedTestProtocol || 'tcp').toLowerCase() ===
|
||
'udp'
|
||
? 'udp'
|
||
: 'tcp'
|
||
);
|
||
setInterfaceSpeedTestDurationSeconds(
|
||
data?.interfaceSpeedTestDurationSeconds != null
|
||
? String(data.interfaceSpeedTestDurationSeconds)
|
||
: '5'
|
||
);
|
||
setInterfaceSpeedTestCacheMinutes(
|
||
data?.interfaceSpeedTestCacheMinutes != null
|
||
? String(data.interfaceSpeedTestCacheMinutes)
|
||
: ''
|
||
);
|
||
setUptimeMonitorIntervalSeconds(
|
||
data?.uptimeMonitorIntervalSeconds != null
|
||
? String(data.uptimeMonitorIntervalSeconds)
|
||
: '120'
|
||
);
|
||
const checkType = String(data?.uptimeMonitorCheckType || 'http').toLowerCase();
|
||
setUptimeMonitorCheckType(
|
||
checkType === 'internal-ping' || checkType === 'external-ping' ? checkType : 'http'
|
||
);
|
||
setUptimeMonitorCacheSeconds(
|
||
data?.uptimeMonitorCacheSeconds != null ? String(data.uptimeMonitorCacheSeconds) : '120'
|
||
);
|
||
setUptimeMonitorSchedulerEnabled(data?.uptimeMonitorSchedulerEnabled !== false);
|
||
setUptimeMonitorSchedulerIntervalMinutes(
|
||
data?.uptimeMonitorSchedulerIntervalMinutes != null
|
||
? String(data.uptimeMonitorSchedulerIntervalMinutes)
|
||
: '2'
|
||
);
|
||
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 serversData = Array.isArray(serversRes?.data) ? serversRes.data : [];
|
||
setServersList(serversData);
|
||
|
||
// Построить список туннелей (как в NetworkMapDashboard / планировщике карты сети)
|
||
const config = networkRes?.data || {};
|
||
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
|
||
const getServer = (serverId) =>
|
||
serversData.find(
|
||
(s) => s.id === serverId || s.ip === serverId || s.dns === serverId
|
||
);
|
||
const tunnelConns = [];
|
||
tunnelInterfaces.forEach((iface) => {
|
||
if (!iface.serverId || !iface.serverId2) return;
|
||
const s1 = getServer(iface.serverId);
|
||
const s2 = getServer(iface.serverId2);
|
||
if (!s1 || !s2 || s1.ip === s2.ip) return;
|
||
const s1Key = s1.id || s1.dns || s1.ip;
|
||
const s2Key = s2.id || s2.dns || s2.ip;
|
||
if (!s1Key || !s2Key) return;
|
||
tunnelConns.push({
|
||
from: s1.ip,
|
||
to: s2.ip,
|
||
fromKey: s1Key,
|
||
toKey: s2Key,
|
||
interfaceName: iface.name || '',
|
||
fromLabel: s1.name || s1.dns || s1.ip || s1.id || 'Сервер',
|
||
toLabel: s2.name || s2.dns || s2.ip || s2.id || 'Сервер',
|
||
});
|
||
});
|
||
setTunnelConnections(tunnelConns);
|
||
|
||
const a = data?.alertSettings || {};
|
||
setAlertServerOffline(a.serverOffline?.enabled !== false);
|
||
setAlertServerOfflineMinutes(
|
||
a.serverOffline?.offlineMinutes != null ? String(a.serverOffline.offlineMinutes) : '5'
|
||
);
|
||
setAlertMikrotikUnreachable(a.mikrotikUnreachable?.enabled !== false);
|
||
setAlertMikrotikUnreachableMinutes(
|
||
a.mikrotikUnreachable?.durationMinutes != null
|
||
? String(a.mikrotikUnreachable.durationMinutes)
|
||
: '5'
|
||
);
|
||
setAlertHighCpuEnabled(a.highCpu?.enabled !== false);
|
||
setAlertHighCpuThreshold(
|
||
a.highCpu?.thresholdPercent != null ? String(a.highCpu.thresholdPercent) : '85'
|
||
);
|
||
setAlertHighCpuDurationMinutes(
|
||
a.highCpu?.durationMinutes != null ? String(a.highCpu.durationMinutes) : '10'
|
||
);
|
||
setAlertHighRamEnabled(a.highRam?.enabled !== false);
|
||
setAlertHighRamThreshold(
|
||
a.highRam?.thresholdPercent != null ? String(a.highRam.thresholdPercent) : '85'
|
||
);
|
||
setAlertHighRamDurationMinutes(
|
||
a.highRam?.durationMinutes != null ? String(a.highRam.durationMinutes) : '10'
|
||
);
|
||
setAlertHighHddEnabled(a.highHdd?.enabled !== false);
|
||
setAlertHighHddThreshold(
|
||
a.highHdd?.thresholdPercent != null ? String(a.highHdd.thresholdPercent) : '90'
|
||
);
|
||
setAlertHighHddDurationMinutes(
|
||
a.highHdd?.durationMinutes != null ? String(a.highHdd.durationMinutes) : '10'
|
||
);
|
||
setAlertMapLowSpeedMbps(
|
||
a.networkMapNodeThresholds?.lowSpeedMbps != null
|
||
? String(a.networkMapNodeThresholds.lowSpeedMbps)
|
||
: '40'
|
||
);
|
||
setAlertMapBelowNormSpeedMbps(
|
||
a.networkMapNodeThresholds?.belowNormSpeedMbps != null
|
||
? String(a.networkMapNodeThresholds.belowNormSpeedMbps)
|
||
: '80'
|
||
);
|
||
setAlertMapNormalSpeedMbps(
|
||
a.networkMapNodeThresholds?.normalSpeedMbps != null
|
||
? String(a.networkMapNodeThresholds.normalSpeedMbps)
|
||
: '120'
|
||
);
|
||
|
||
const rawTunnelThresholds = Array.isArray(a.tunnelThresholds) ? a.tunnelThresholds : [];
|
||
setTunnelThresholds(
|
||
rawTunnelThresholds
|
||
.filter((t) => t && (t.fromKey || t.toKey))
|
||
.map((t) => ({
|
||
fromKey: String(t.fromKey || '').trim(),
|
||
toKey: String(t.toKey || '').trim(),
|
||
interfaceName: t.interfaceName != null ? String(t.interfaceName) : '',
|
||
enabled: t.enabled !== false,
|
||
maxPingMs:
|
||
t.maxPingMs != null
|
||
? String(t.maxPingMs)
|
||
: t.thresholdMs != null
|
||
? String(t.thresholdMs)
|
||
: '',
|
||
minDownloadMbps:
|
||
t.minDownloadMbps != null
|
||
? String(t.minDownloadMbps)
|
||
: t.minDownMbps != null
|
||
? String(t.minDownMbps)
|
||
: '',
|
||
minUploadMbps:
|
||
t.minUploadMbps != null
|
||
? String(t.minUploadMbps)
|
||
: t.minUpMbps != null
|
||
? String(t.minUpMbps)
|
||
: '',
|
||
}))
|
||
);
|
||
|
||
const ai = data?.aiRouteOptimizer || {};
|
||
setAiLatencyWeight(
|
||
ai?.latencyWeight != null ? String(ai.latencyWeight) : '0.55'
|
||
);
|
||
setAiBandwidthWeight(
|
||
ai?.bandwidthWeight != null ? String(ai.bandwidthWeight) : '0.35'
|
||
);
|
||
setAiFreshnessWeight(
|
||
ai?.freshnessWeight != null ? String(ai.freshnessWeight) : '0.10'
|
||
);
|
||
setAiHomeToJhWeight(
|
||
ai?.combineHomeToJumphostWeight != null
|
||
? String(ai.combineHomeToJumphostWeight)
|
||
: '0.45'
|
||
);
|
||
setAiJhToExitWeight(
|
||
ai?.combineJumphostToExitWeight != null
|
||
? String(ai.combineJumphostToExitWeight)
|
||
: '0.55'
|
||
);
|
||
setAiProbabilityScale(
|
||
ai?.probabilityScale != null ? String(ai.probabilityScale) : '5'
|
||
);
|
||
setAiMinProbabilityGainForSwitch(
|
||
ai?.minProbabilityGainForSwitch != null
|
||
? String(ai.minProbabilityGainForSwitch)
|
||
: '10'
|
||
);
|
||
setAiNoPingScore(
|
||
ai?.noPingScore != null ? String(ai.noPingScore) : '0.2'
|
||
);
|
||
setAiNoSpeedScore(
|
||
ai?.noSpeedScore != null ? String(ai.noSpeedScore) : '0.15'
|
||
);
|
||
setAiStaleScore(
|
||
ai?.staleScore != null ? String(ai.staleScore) : '0.35'
|
||
);
|
||
setAiFreshnessExcellentSeconds(
|
||
ai?.freshnessExcellentSeconds != null
|
||
? String(ai.freshnessExcellentSeconds)
|
||
: '120'
|
||
);
|
||
setAiFreshnessGoodSeconds(
|
||
ai?.freshnessGoodSeconds != null
|
||
? String(ai.freshnessGoodSeconds)
|
||
: '600'
|
||
);
|
||
setAiFreshnessFairSeconds(
|
||
ai?.freshnessFairSeconds != null
|
||
? String(ai.freshnessFairSeconds)
|
||
: '1800'
|
||
);
|
||
|
||
const e =
|
||
settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
|
||
setEtag(e ? String(e) : '');
|
||
} catch {
|
||
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 tunnelKey = (fromKey, toKey, interfaceName) => {
|
||
const a = String(fromKey || '');
|
||
const b = String(toKey || '');
|
||
const pair = [a, b].sort().join('__');
|
||
return `${pair}::${interfaceName || ''}`;
|
||
};
|
||
|
||
const findTunnelThreshold = (conn) => {
|
||
const key = tunnelKey(conn.fromKey || conn.from, conn.toKey || conn.to, conn.interfaceName || '');
|
||
return tunnelThresholds.find(
|
||
(t) => tunnelKey(t.fromKey, t.toKey, t.interfaceName) === key
|
||
);
|
||
};
|
||
|
||
const upsertTunnelThreshold = (conn, patch) => {
|
||
setTunnelThresholds((prev) => {
|
||
const key = tunnelKey(conn.fromKey || conn.from, conn.toKey || conn.to, conn.interfaceName || '');
|
||
const idx = prev.findIndex(
|
||
(t) => tunnelKey(t.fromKey, t.toKey, t.interfaceName) === key
|
||
);
|
||
const base =
|
||
idx >= 0
|
||
? prev[idx]
|
||
: {
|
||
fromKey: String(conn.fromKey || conn.from || '').trim(),
|
||
toKey: String(conn.toKey || conn.to || '').trim(),
|
||
interfaceName: conn.interfaceName || '',
|
||
enabled: true,
|
||
maxPingMs: '',
|
||
minDownloadMbps: '',
|
||
minUploadMbps: '',
|
||
};
|
||
const nextItem = { ...base, ...patch };
|
||
const next = [...prev];
|
||
if (idx >= 0) next[idx] = nextItem;
|
||
else next.push(nextItem);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
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 parsedLow = Math.max(1, parseInt(alertMapLowSpeedMbps, 10) || 40);
|
||
const parsedBelowNorm = Math.max(parsedLow, parseInt(alertMapBelowNormSpeedMbps, 10) || 80);
|
||
const parsedNormal = Math.max(parsedBelowNorm, parseInt(alertMapNormalSpeedMbps, 10) || 120);
|
||
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
|
||
),
|
||
pingServicesSchedulerEnabled: Boolean(pingServicesSchedulerEnabled),
|
||
pingServicesSchedulerIntervalMinutes: Math.max(
|
||
1,
|
||
Math.min(1440, parseInt(pingServicesSchedulerIntervalMinutes, 10) || 2)
|
||
),
|
||
interfaceSpeedTestProtocol:
|
||
interfaceSpeedTestProtocol === 'udp' ? 'udp' : 'tcp',
|
||
interfaceSpeedTestDurationSeconds: Math.max(
|
||
1,
|
||
parseInt(interfaceSpeedTestDurationSeconds, 10) || 5
|
||
),
|
||
interfaceSpeedTestCacheMinutes: Math.max(
|
||
0,
|
||
parseInt(interfaceSpeedTestCacheMinutes, 10) || 0
|
||
),
|
||
uptimeMonitorIntervalSeconds: Math.max(
|
||
30,
|
||
parseInt(uptimeMonitorIntervalSeconds, 10) || 120
|
||
),
|
||
uptimeMonitorCheckType:
|
||
uptimeMonitorCheckType === 'internal-ping' || uptimeMonitorCheckType === 'external-ping'
|
||
? uptimeMonitorCheckType
|
||
: 'http',
|
||
uptimeMonitorCacheSeconds: Math.max(
|
||
0,
|
||
parseInt(uptimeMonitorCacheSeconds, 10) || 120
|
||
),
|
||
uptimeMonitorSchedulerEnabled: Boolean(uptimeMonitorSchedulerEnabled),
|
||
uptimeMonitorSchedulerIntervalMinutes: Math.max(
|
||
1,
|
||
Math.min(1440, parseInt(uptimeMonitorSchedulerIntervalMinutes, 10) || 2)
|
||
),
|
||
trafficInterfaces: Array.isArray(trafficInterfacesSelected)
|
||
? trafficInterfacesSelected.map((p) => ({
|
||
serverKey: p.serverKey,
|
||
interfaceName: p.interfaceName,
|
||
}))
|
||
: [],
|
||
aiRouteOptimizer: {
|
||
latencyWeight: Math.max(0, parseFloat(aiLatencyWeight) || 0.55),
|
||
bandwidthWeight: Math.max(0, parseFloat(aiBandwidthWeight) || 0.35),
|
||
freshnessWeight: Math.max(0, parseFloat(aiFreshnessWeight) || 0.1),
|
||
combineHomeToJumphostWeight: Math.max(
|
||
0,
|
||
parseFloat(aiHomeToJhWeight) || 0.45
|
||
),
|
||
combineJumphostToExitWeight: Math.max(
|
||
0,
|
||
parseFloat(aiJhToExitWeight) || 0.55
|
||
),
|
||
probabilityScale: Math.max(
|
||
0.5,
|
||
Math.min(20, parseFloat(aiProbabilityScale) || 5)
|
||
),
|
||
minProbabilityGainForSwitch: Math.max(
|
||
0,
|
||
Math.min(100, parseFloat(aiMinProbabilityGainForSwitch) || 10)
|
||
),
|
||
noPingScore: Math.max(
|
||
0,
|
||
Math.min(1, parseFloat(aiNoPingScore) || 0.2)
|
||
),
|
||
noSpeedScore: Math.max(
|
||
0,
|
||
Math.min(1, parseFloat(aiNoSpeedScore) || 0.15)
|
||
),
|
||
staleScore: Math.max(
|
||
0,
|
||
Math.min(1, parseFloat(aiStaleScore) || 0.35)
|
||
),
|
||
freshnessExcellentSeconds: Math.max(
|
||
10,
|
||
Math.min(86400, parseInt(aiFreshnessExcellentSeconds, 10) || 120)
|
||
),
|
||
freshnessGoodSeconds: Math.max(
|
||
10,
|
||
Math.min(86400, parseInt(aiFreshnessGoodSeconds, 10) || 600)
|
||
),
|
||
freshnessFairSeconds: Math.max(
|
||
10,
|
||
Math.min(86400, parseInt(aiFreshnessFairSeconds, 10) || 1800)
|
||
),
|
||
},
|
||
alertSettings: {
|
||
serverOffline: {
|
||
enabled: alertServerOffline,
|
||
offlineMinutes: Math.max(
|
||
1,
|
||
Math.min(1440, parseInt(alertServerOfflineMinutes, 10) || 5)
|
||
),
|
||
},
|
||
mikrotikUnreachable: {
|
||
enabled: alertMikrotikUnreachable,
|
||
durationMinutes: Math.max(
|
||
1,
|
||
Math.min(1440, parseInt(alertMikrotikUnreachableMinutes, 10) || 5)
|
||
),
|
||
},
|
||
highCpu: {
|
||
enabled: alertHighCpuEnabled,
|
||
thresholdPercent: Math.max(
|
||
1,
|
||
Math.min(100, parseInt(alertHighCpuThreshold, 10) || 85)
|
||
),
|
||
durationMinutes: Math.max(
|
||
1,
|
||
Math.min(1440, parseInt(alertHighCpuDurationMinutes, 10) || 10)
|
||
),
|
||
},
|
||
highRam: {
|
||
enabled: alertHighRamEnabled,
|
||
thresholdPercent: Math.max(
|
||
1,
|
||
Math.min(100, parseInt(alertHighRamThreshold, 10) || 85)
|
||
),
|
||
durationMinutes: Math.max(
|
||
1,
|
||
Math.min(1440, parseInt(alertHighRamDurationMinutes, 10) || 10)
|
||
),
|
||
},
|
||
highHdd: {
|
||
enabled: alertHighHddEnabled,
|
||
thresholdPercent: Math.max(
|
||
1,
|
||
Math.min(100, parseInt(alertHighHddThreshold, 10) || 90)
|
||
),
|
||
durationMinutes: Math.max(
|
||
1,
|
||
Math.min(1440, parseInt(alertHighHddDurationMinutes, 10) || 10)
|
||
),
|
||
},
|
||
networkMapNodeThresholds: {
|
||
lowSpeedMbps: Math.min(100000, parsedLow),
|
||
belowNormSpeedMbps: Math.min(100000, parsedBelowNorm),
|
||
normalSpeedMbps: Math.min(100000, parsedNormal),
|
||
},
|
||
tunnelThresholds: Array.isArray(tunnelThresholds)
|
||
? tunnelThresholds
|
||
.filter((t) => {
|
||
const fromKey = String(t.fromKey || '').trim();
|
||
const toKey = String(t.toKey || '').trim();
|
||
if (!fromKey || !toKey) return false;
|
||
if (t.enabled === false) return false;
|
||
const maxPing = parseInt(t.maxPingMs, 10) || 0;
|
||
const minDown = parseInt(t.minDownloadMbps, 10) || 0;
|
||
const minUp = parseInt(t.minUploadMbps, 10) || 0;
|
||
return maxPing > 0 || minDown > 0 || minUp > 0;
|
||
})
|
||
.map((t) => ({
|
||
fromKey: String(t.fromKey || '').trim(),
|
||
toKey: String(t.toKey || '').trim(),
|
||
interfaceName: String(t.interfaceName || '').trim() || null,
|
||
enabled: t.enabled !== false,
|
||
maxPingMs: Math.max(0, parseInt(t.maxPingMs, 10) || 0),
|
||
minDownloadMbps: Math.max(0, parseInt(t.minDownloadMbps, 10) || 0),
|
||
minUploadMbps: Math.max(0, parseInt(t.minUploadMbps, 10) || 0),
|
||
}))
|
||
: [],
|
||
},
|
||
};
|
||
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-12 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-12 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-12 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-12 col-md-6">
|
||
<FormField
|
||
label="Кеш пингов на карте сети (сек)"
|
||
name="networkMapPingCacheSeconds"
|
||
type="number"
|
||
value={networkMapPingCacheSeconds}
|
||
onChange={setNetworkMapPingCacheSeconds}
|
||
placeholder="0"
|
||
helpText="0 — без кеша. Иначе пинги между серверами на карте сети кешируются на указанный срок."
|
||
disabled={saving}
|
||
min={0}
|
||
/>
|
||
</div>
|
||
<div className="col-12 mt-3">
|
||
<h4 className="subheader">Измерение скорости (интерфейсы)</h4>
|
||
</div>
|
||
<div className="col-12 col-md-4">
|
||
<label className="form-label">Протокол измерения</label>
|
||
<select
|
||
className="form-select"
|
||
value={interfaceSpeedTestProtocol}
|
||
onChange={(e) =>
|
||
setInterfaceSpeedTestProtocol(e.target.value)
|
||
}
|
||
disabled={saving}
|
||
>
|
||
<option value="tcp">TCP</option>
|
||
<option value="udp">UDP</option>
|
||
</select>
|
||
<div className="form-text">
|
||
Тип теста скорости. Сейчас используется как настройка по
|
||
умолчанию для инструментов RouterOS.
|
||
</div>
|
||
</div>
|
||
<div className="col-12 col-md-4">
|
||
<FormField
|
||
label="Время замера (сек)"
|
||
name="interfaceSpeedTestDurationSeconds"
|
||
type="number"
|
||
value={interfaceSpeedTestDurationSeconds}
|
||
onChange={setInterfaceSpeedTestDurationSeconds}
|
||
placeholder="5"
|
||
helpText="Интервал, за который измеряется средняя скорость по интерфейсу."
|
||
disabled={saving}
|
||
min={1}
|
||
max={600}
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-4">
|
||
<FormField
|
||
label="Кеш результата замера (мин)"
|
||
name="interfaceSpeedTestCacheMinutes"
|
||
type="number"
|
||
value={interfaceSpeedTestCacheMinutes}
|
||
onChange={setInterfaceSpeedTestCacheMinutes}
|
||
placeholder="0"
|
||
helpText="0 — без кеша. При значении больше 0 результаты замеров скорости по интерфейсу кешируются в S3."
|
||
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>
|
||
<div className="mb-3">
|
||
<div className="form-check form-switch mb-2">
|
||
<input
|
||
className="form-check-input"
|
||
type="checkbox"
|
||
id="pingServicesSchedulerEnabled"
|
||
checked={pingServicesSchedulerEnabled}
|
||
onChange={(e) => setPingServicesSchedulerEnabled(e.target.checked)}
|
||
disabled={saving}
|
||
/>
|
||
<label className="form-check-label" htmlFor="pingServicesSchedulerEnabled">
|
||
Планировщик: обновлять кеш пингов по расписанию
|
||
</label>
|
||
</div>
|
||
<FormField
|
||
label="Интервал обновления (мин)"
|
||
name="pingServicesSchedulerIntervalMinutes"
|
||
type="number"
|
||
value={pingServicesSchedulerIntervalMinutes}
|
||
onChange={setPingServicesSchedulerIntervalMinutes}
|
||
placeholder="2"
|
||
helpText="Как часто планировщик обновляет кеш пингов (1–1440 мин). По умолчанию 2 мин."
|
||
disabled={saving}
|
||
min={1}
|
||
max={1440}
|
||
/>
|
||
</div>
|
||
{pingServicesSource === 'router' && (
|
||
<div className="row g-2">
|
||
<div className="col-12 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-12 col-md-6">
|
||
<FormField
|
||
label="IP шлюза (не обязательно)"
|
||
name="pingServicesGatewayIp"
|
||
type="text"
|
||
value={pingServicesGatewayIp}
|
||
onChange={setPingServicesGatewayIp}
|
||
placeholder="IP шлюза"
|
||
helpText="Пусто — первый шлюз выбранного роутера."
|
||
disabled={saving}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{activeSection === 'uptime-monitor' && (
|
||
<>
|
||
<SectionHeading title="Uptime Monitor" icon={IconClock} />
|
||
<p className="text-muted mb-3">
|
||
Настройки страницы «Uptime Monitor»: интервал проверки доступности Jumphost/Home и способ проверки.
|
||
</p>
|
||
<div className="row g-2">
|
||
<div className="col-12 col-md-6">
|
||
<FormField
|
||
label="Интервал проверки (сек)"
|
||
name="uptimeMonitorIntervalSeconds"
|
||
type="number"
|
||
value={uptimeMonitorIntervalSeconds}
|
||
onChange={setUptimeMonitorIntervalSeconds}
|
||
placeholder="120"
|
||
helpText="Как часто проверять доступность (по умолчанию 120 сек)."
|
||
disabled={saving}
|
||
min={30}
|
||
max={86400}
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-6">
|
||
<label className="form-label">Тип проверки</label>
|
||
<select
|
||
className="form-select"
|
||
value={uptimeMonitorCheckType}
|
||
onChange={(e) => setUptimeMonitorCheckType(e.target.value)}
|
||
disabled={saving}
|
||
>
|
||
<option value="http">HTTP — доступность MikroTik REST API</option>
|
||
<option value="internal-ping">Внутренний пинг — по внутренним адресам туннелей</option>
|
||
<option value="external-ping">Внешний пинг — по внешним IP серверов</option>
|
||
</select>
|
||
<div className="form-text">
|
||
HTTP: подключение к RouterOS API. Внутренний: пинг через туннель (как на карте сети). Внешний: пинг внешнего IP с другого jumphost.
|
||
</div>
|
||
</div>
|
||
<div className="col-12 col-md-6">
|
||
<FormField
|
||
label="Кеш результатов (сек)"
|
||
name="uptimeMonitorCacheSeconds"
|
||
type="number"
|
||
value={uptimeMonitorCacheSeconds}
|
||
onChange={setUptimeMonitorCacheSeconds}
|
||
placeholder="120"
|
||
helpText="При заходе на страницу показываются последние проверки, если кеш младше этого срока (0 — не показывать кеш)."
|
||
disabled={saving}
|
||
min={0}
|
||
max={86400}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 mb-2">
|
||
<div className="form-check form-switch mb-2">
|
||
<input
|
||
className="form-check-input"
|
||
type="checkbox"
|
||
id="uptimeMonitorSchedulerEnabled"
|
||
checked={uptimeMonitorSchedulerEnabled}
|
||
onChange={(e) => setUptimeMonitorSchedulerEnabled(e.target.checked)}
|
||
disabled={saving}
|
||
/>
|
||
<label className="form-check-label" htmlFor="uptimeMonitorSchedulerEnabled">
|
||
Планировщик: обновлять кеш проверок по расписанию (после перезапуска контейнера кеш обновится автоматически)
|
||
</label>
|
||
</div>
|
||
<FormField
|
||
label="Интервал планировщика (мин)"
|
||
name="uptimeMonitorSchedulerIntervalMinutes"
|
||
type="number"
|
||
value={uptimeMonitorSchedulerIntervalMinutes}
|
||
onChange={setUptimeMonitorSchedulerIntervalMinutes}
|
||
placeholder="2"
|
||
helpText="Как часто планировщик проверяет все jumphost/home (1–1440 мин). По умолчанию 2 мин."
|
||
disabled={saving}
|
||
min={1}
|
||
max={1440}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{activeSection === 'ptr-zone' && (
|
||
<>
|
||
<SectionHeading title="Настройка PTR зоны" icon={IconWorld} />
|
||
<div className="row g-2">
|
||
<div className="col-12 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-12 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>
|
||
</>
|
||
)}
|
||
|
||
{activeSection === 'route-ai' && (
|
||
<>
|
||
<SectionHeading title="AI оптимизация маршрутов" icon={IconBrain} />
|
||
<p className="text-muted mb-4">
|
||
Настройка правил локального AI: веса метрик, вероятностная модель и пороги решений
|
||
для рекомендаций по связке <code>community -> gateway</code>.
|
||
</p>
|
||
|
||
<div className="row g-2">
|
||
<div className="col-12"><h4 className="subheader">Веса метрик сегмента</h4></div>
|
||
<div className="col-12 col-md-4">
|
||
<FormField
|
||
label="Вес latency"
|
||
name="aiLatencyWeight"
|
||
type="number"
|
||
value={aiLatencyWeight}
|
||
onChange={setAiLatencyWeight}
|
||
helpText="Вклад пинга в итоговый score сегмента."
|
||
disabled={saving}
|
||
min={0}
|
||
step="0.01"
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-4">
|
||
<FormField
|
||
label="Вес bandwidth"
|
||
name="aiBandwidthWeight"
|
||
type="number"
|
||
value={aiBandwidthWeight}
|
||
onChange={setAiBandwidthWeight}
|
||
helpText="Вклад скорости (download/upload) в score."
|
||
disabled={saving}
|
||
min={0}
|
||
step="0.01"
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-4">
|
||
<FormField
|
||
label="Вес freshness"
|
||
name="aiFreshnessWeight"
|
||
type="number"
|
||
value={aiFreshnessWeight}
|
||
onChange={setAiFreshnessWeight}
|
||
helpText="Вклад свежести метрик в score."
|
||
disabled={saving}
|
||
min={0}
|
||
step="0.01"
|
||
/>
|
||
</div>
|
||
|
||
<div className="col-12 mt-2"><h4 className="subheader">Сборка полного маршрута</h4></div>
|
||
<div className="col-12 col-md-6">
|
||
<FormField
|
||
label="Вес Home -> Jumphost"
|
||
name="aiHomeToJhWeight"
|
||
type="number"
|
||
value={aiHomeToJhWeight}
|
||
onChange={setAiHomeToJhWeight}
|
||
helpText="Влияние сегмента Home->Jumphost на общий score."
|
||
disabled={saving}
|
||
min={0}
|
||
step="0.01"
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-6">
|
||
<FormField
|
||
label="Вес Jumphost -> Exit"
|
||
name="aiJhToExitWeight"
|
||
type="number"
|
||
value={aiJhToExitWeight}
|
||
onChange={setAiJhToExitWeight}
|
||
helpText="Влияние сегмента Jumphost->Exit на общий score."
|
||
disabled={saving}
|
||
min={0}
|
||
step="0.01"
|
||
/>
|
||
</div>
|
||
|
||
<div className="col-12 mt-2"><h4 className="subheader">Вероятности и решения</h4></div>
|
||
<div className="col-12 col-md-6">
|
||
<FormField
|
||
label="Коэффициент softmax (probability scale)"
|
||
name="aiProbabilityScale"
|
||
type="number"
|
||
value={aiProbabilityScale}
|
||
onChange={setAiProbabilityScale}
|
||
helpText="Чем выше значение, тем агрессивнее выделяется лучший маршрут (0.5-20)."
|
||
disabled={saving}
|
||
min={0.5}
|
||
max={20}
|
||
step="0.1"
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-6">
|
||
<FormField
|
||
label="Мин. прирост вероятности для switch (%)"
|
||
name="aiMinProbabilityGainForSwitch"
|
||
type="number"
|
||
value={aiMinProbabilityGainForSwitch}
|
||
onChange={setAiMinProbabilityGainForSwitch}
|
||
helpText="Рекомендовать переключение только если новый gateway лучше на этот %."
|
||
disabled={saving}
|
||
min={0}
|
||
max={100}
|
||
step="0.1"
|
||
/>
|
||
</div>
|
||
|
||
<div className="col-12 mt-2"><h4 className="subheader">Поведение при неполных данных</h4></div>
|
||
<div className="col-12 col-md-4">
|
||
<FormField
|
||
label="Score если нет ping"
|
||
name="aiNoPingScore"
|
||
type="number"
|
||
value={aiNoPingScore}
|
||
onChange={setAiNoPingScore}
|
||
helpText="Оценка latency при отсутствии пинга (0-1)."
|
||
disabled={saving}
|
||
min={0}
|
||
max={1}
|
||
step="0.01"
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-4">
|
||
<FormField
|
||
label="Score если нет speed"
|
||
name="aiNoSpeedScore"
|
||
type="number"
|
||
value={aiNoSpeedScore}
|
||
onChange={setAiNoSpeedScore}
|
||
helpText="Оценка bandwidth при отсутствии скорости (0-1)."
|
||
disabled={saving}
|
||
min={0}
|
||
max={1}
|
||
step="0.01"
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-4">
|
||
<FormField
|
||
label="Score устаревших метрик"
|
||
name="aiStaleScore"
|
||
type="number"
|
||
value={aiStaleScore}
|
||
onChange={setAiStaleScore}
|
||
helpText="Оценка freshness для старых данных (0-1)."
|
||
disabled={saving}
|
||
min={0}
|
||
max={1}
|
||
step="0.01"
|
||
/>
|
||
</div>
|
||
|
||
<div className="col-12 mt-2"><h4 className="subheader">Пороги свежести (сек)</h4></div>
|
||
<div className="col-12 col-md-4">
|
||
<FormField
|
||
label="Excellent"
|
||
name="aiFreshnessExcellentSeconds"
|
||
type="number"
|
||
value={aiFreshnessExcellentSeconds}
|
||
onChange={setAiFreshnessExcellentSeconds}
|
||
helpText="До этого порога freshness = 1.0."
|
||
disabled={saving}
|
||
min={10}
|
||
max={86400}
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-4">
|
||
<FormField
|
||
label="Good"
|
||
name="aiFreshnessGoodSeconds"
|
||
type="number"
|
||
value={aiFreshnessGoodSeconds}
|
||
onChange={setAiFreshnessGoodSeconds}
|
||
helpText="До этого порога freshness = 0.8."
|
||
disabled={saving}
|
||
min={10}
|
||
max={86400}
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-4">
|
||
<FormField
|
||
label="Fair"
|
||
name="aiFreshnessFairSeconds"
|
||
type="number"
|
||
value={aiFreshnessFairSeconds}
|
||
onChange={setAiFreshnessFairSeconds}
|
||
helpText="До этого порога freshness = 0.6, затем staleScore."
|
||
disabled={saving}
|
||
min={10}
|
||
max={86400}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{activeSection === 'alerts' && (
|
||
<>
|
||
<SectionHeading title="Настройки оповещений" icon={IconBell} />
|
||
<p className="text-muted mb-4">
|
||
Настройте пороги и длительность для каждого типа оповещений. Оповещения отображаются на панели и в колокольчике в шапке.
|
||
</p>
|
||
|
||
<div className="list-group list-group-flush">
|
||
{/* Статус: сервер недоступен */}
|
||
<div className="list-group-item d-flex flex-column flex-md-row align-items-stretch align-items-md-center gap-3 py-3">
|
||
<div className="flex-grow-1 min-w-0">
|
||
<div className="fw-semibold">Статус</div>
|
||
<div className="text-muted small">Система не в сети более указанного времени. Для jumphost/home — правила <a href="#uptime-monitor" onClick={(e) => { e.preventDefault(); goToSection('uptime-monitor'); }}>Uptime Monitor</a>.</div>
|
||
</div>
|
||
<div className="d-flex flex-wrap align-items-center gap-2">
|
||
<div className="input-group input-group-sm" style={{ width: 100 }}>
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={1}
|
||
max={1440}
|
||
value={alertServerOfflineMinutes}
|
||
onChange={(e) => setAlertServerOfflineMinutes(e.target.value)}
|
||
disabled={saving || !alertServerOffline}
|
||
/>
|
||
<span className="input-group-text">мин</span>
|
||
</div>
|
||
<div className="form-check form-switch mb-0">
|
||
<input
|
||
className="form-check-input"
|
||
type="checkbox"
|
||
id="alertServerOffline"
|
||
checked={alertServerOffline}
|
||
onChange={(e) => setAlertServerOffline(e.target.checked)}
|
||
disabled={saving}
|
||
/>
|
||
<label className="form-check-label small" htmlFor="alertServerOffline">Вкл</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* MikroTik недоступен */}
|
||
<div className="list-group-item d-flex flex-column flex-md-row align-items-stretch align-items-md-center gap-3 py-3">
|
||
<div className="flex-grow-1 min-w-0">
|
||
<div className="fw-semibold">MikroTik недоступен</div>
|
||
<div className="text-muted small">Ошибка доступа к RouterOS (таймаут, неверный пароль) в течение указанного времени.</div>
|
||
</div>
|
||
<div className="d-flex flex-wrap align-items-center gap-2">
|
||
<div className="input-group input-group-sm" style={{ width: 100 }}>
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={1}
|
||
max={1440}
|
||
value={alertMikrotikUnreachableMinutes}
|
||
onChange={(e) => setAlertMikrotikUnreachableMinutes(e.target.value)}
|
||
disabled={saving || !alertMikrotikUnreachable}
|
||
/>
|
||
<span className="input-group-text">мин</span>
|
||
</div>
|
||
<div className="form-check form-switch mb-0">
|
||
<input
|
||
className="form-check-input"
|
||
type="checkbox"
|
||
id="alertMikrotikUnreachable"
|
||
checked={alertMikrotikUnreachable}
|
||
onChange={(e) => setAlertMikrotikUnreachable(e.target.checked)}
|
||
disabled={saving}
|
||
/>
|
||
<label className="form-check-label small" htmlFor="alertMikrotikUnreachable">Вкл</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Использование CPU */}
|
||
<div className="list-group-item d-flex flex-column flex-md-row align-items-stretch align-items-md-center gap-3 py-3">
|
||
<div className="flex-grow-1 min-w-0">
|
||
<div className="fw-semibold d-flex align-items-center gap-2">
|
||
<IconCpu size={18} className="text-blue" />
|
||
Использование CPU
|
||
</div>
|
||
<div className="text-muted small">Среднее превышает порог в течение указанного времени.</div>
|
||
</div>
|
||
<div className="d-flex flex-wrap align-items-center gap-2">
|
||
<div className="input-group input-group-sm" style={{ width: 80 }}>
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={1}
|
||
max={100}
|
||
value={alertHighCpuThreshold}
|
||
onChange={(e) => setAlertHighCpuThreshold(e.target.value)}
|
||
disabled={saving || !alertHighCpuEnabled}
|
||
/>
|
||
<span className="input-group-text">%</span>
|
||
</div>
|
||
<div className="input-group input-group-sm" style={{ width: 100 }}>
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={1}
|
||
max={1440}
|
||
value={alertHighCpuDurationMinutes}
|
||
onChange={(e) => setAlertHighCpuDurationMinutes(e.target.value)}
|
||
disabled={saving || !alertHighCpuEnabled}
|
||
/>
|
||
<span className="input-group-text">мин</span>
|
||
</div>
|
||
<div className="form-check form-switch mb-0">
|
||
<input
|
||
className="form-check-input"
|
||
type="checkbox"
|
||
id="alertHighCpuEnabled"
|
||
checked={alertHighCpuEnabled}
|
||
onChange={(e) => setAlertHighCpuEnabled(e.target.checked)}
|
||
disabled={saving}
|
||
/>
|
||
<label className="form-check-label small" htmlFor="alertHighCpuEnabled">Вкл</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Использование памяти */}
|
||
<div className="list-group-item d-flex flex-column flex-md-row align-items-stretch align-items-md-center gap-3 py-3">
|
||
<div className="flex-grow-1 min-w-0">
|
||
<div className="fw-semibold d-flex align-items-center gap-2">
|
||
<IconDeviceDesktop size={18} className="text-green" />
|
||
Использование памяти
|
||
</div>
|
||
<div className="text-muted small">Среднее превышает порог в течение указанного времени.</div>
|
||
</div>
|
||
<div className="d-flex flex-wrap align-items-center gap-2">
|
||
<div className="input-group input-group-sm" style={{ width: 80 }}>
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={1}
|
||
max={100}
|
||
value={alertHighRamThreshold}
|
||
onChange={(e) => setAlertHighRamThreshold(e.target.value)}
|
||
disabled={saving || !alertHighRamEnabled}
|
||
/>
|
||
<span className="input-group-text">%</span>
|
||
</div>
|
||
<div className="input-group input-group-sm" style={{ width: 100 }}>
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={1}
|
||
max={1440}
|
||
value={alertHighRamDurationMinutes}
|
||
onChange={(e) => setAlertHighRamDurationMinutes(e.target.value)}
|
||
disabled={saving || !alertHighRamEnabled}
|
||
/>
|
||
<span className="input-group-text">мин</span>
|
||
</div>
|
||
<div className="form-check form-switch mb-0">
|
||
<input
|
||
className="form-check-input"
|
||
type="checkbox"
|
||
id="alertHighRamEnabled"
|
||
checked={alertHighRamEnabled}
|
||
onChange={(e) => setAlertHighRamEnabled(e.target.checked)}
|
||
disabled={saving}
|
||
/>
|
||
<label className="form-check-label small" htmlFor="alertHighRamEnabled">Вкл</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Использование диска */}
|
||
<div className="list-group-item d-flex flex-column flex-md-row align-items-stretch align-items-md-center gap-3 py-3">
|
||
<div className="flex-grow-1 min-w-0">
|
||
<div className="fw-semibold d-flex align-items-center gap-2">
|
||
<IconDatabase size={18} className="text-orange" />
|
||
Использование диска
|
||
</div>
|
||
<div className="text-muted small">Среднее превышает порог в течение указанного времени.</div>
|
||
</div>
|
||
<div className="d-flex flex-wrap align-items-center gap-2">
|
||
<div className="input-group input-group-sm" style={{ width: 80 }}>
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={1}
|
||
max={100}
|
||
value={alertHighHddThreshold}
|
||
onChange={(e) => setAlertHighHddThreshold(e.target.value)}
|
||
disabled={saving || !alertHighHddEnabled}
|
||
/>
|
||
<span className="input-group-text">%</span>
|
||
</div>
|
||
<div className="input-group input-group-sm" style={{ width: 100 }}>
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={1}
|
||
max={1440}
|
||
value={alertHighHddDurationMinutes}
|
||
onChange={(e) => setAlertHighHddDurationMinutes(e.target.value)}
|
||
disabled={saving || !alertHighHddEnabled}
|
||
/>
|
||
<span className="input-group-text">мин</span>
|
||
</div>
|
||
<div className="form-check form-switch mb-0">
|
||
<input
|
||
className="form-check-input"
|
||
type="checkbox"
|
||
id="alertHighHddEnabled"
|
||
checked={alertHighHddEnabled}
|
||
onChange={(e) => setAlertHighHddEnabled(e.target.checked)}
|
||
disabled={saving}
|
||
/>
|
||
<label className="form-check-label small" htmlFor="alertHighHddEnabled">Вкл</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Карта сети: глобальные пороги скорости */}
|
||
<div className="list-group-item d-flex flex-column flex-md-row align-items-stretch align-items-md-center gap-3 py-3">
|
||
<div className="flex-grow-1 min-w-0">
|
||
<div className="fw-semibold d-flex align-items-center gap-2">
|
||
<IconNetwork size={18} className="text-azure" />
|
||
Карта сети: пороги скорости
|
||
</div>
|
||
<div className="text-muted small">
|
||
Единые пороги для аналитики узлов на карте и классификации: «Низкая скорость», «Скорость ниже нормы», «Норма».
|
||
</div>
|
||
</div>
|
||
<div className="d-flex flex-wrap align-items-center gap-2">
|
||
<div className="input-group input-group-sm" style={{ width: 132 }}>
|
||
<span className="input-group-text">Низкая</span>
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={1}
|
||
max={100000}
|
||
value={alertMapLowSpeedMbps}
|
||
onChange={(e) => setAlertMapLowSpeedMbps(e.target.value)}
|
||
disabled={saving}
|
||
/>
|
||
</div>
|
||
<div className="input-group input-group-sm" style={{ width: 162 }}>
|
||
<span className="input-group-text">Ниже нормы</span>
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={1}
|
||
max={100000}
|
||
value={alertMapBelowNormSpeedMbps}
|
||
onChange={(e) => setAlertMapBelowNormSpeedMbps(e.target.value)}
|
||
disabled={saving}
|
||
/>
|
||
</div>
|
||
<div className="input-group input-group-sm" style={{ width: 120 }}>
|
||
<span className="input-group-text">Норма</span>
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={1}
|
||
max={100000}
|
||
value={alertMapNormalSpeedMbps}
|
||
onChange={(e) => setAlertMapNormalSpeedMbps(e.target.value)}
|
||
disabled={saving}
|
||
/>
|
||
<span className="input-group-text">Мбит/с</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Туннели (карта сети): индивидуальные пороги */}
|
||
<div className="list-group-item py-3">
|
||
<div className="fw-semibold mb-1">Туннели (карта сети)</div>
|
||
<div className="text-muted small mb-2">
|
||
Для каждого туннеля можно задать собственные пороги по пингу и скорости. Используются данные
|
||
с планировщика карты сети и раздела «Сетевые настройки».
|
||
</div>
|
||
{tunnelConnections.length === 0 && (
|
||
<div className="text-muted small">
|
||
Нет настроенных туннелей. Добавьте связи в разделе «Сетевые настройки».
|
||
</div>
|
||
)}
|
||
{tunnelConnections.length > 0 && (
|
||
<div className="table-responsive">
|
||
<table className="table table-sm table-transparent mb-0 align-middle">
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: '32%' }}>Туннель</th>
|
||
<th style={{ width: '16%' }}>Интерфейс</th>
|
||
<th style={{ width: '16%' }}>Макс. пинг (ms)</th>
|
||
<th style={{ width: '18%' }}>Мин. ↓ (Мбит/с)</th>
|
||
<th style={{ width: '18%' }}>Мин. ↑ (Мбит/с)</th>
|
||
<th style={{ width: '8%' }}>Вкл</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{tunnelConnections.map((conn) => {
|
||
const thr = findTunnelThreshold(conn) || {};
|
||
const enabled = thr.enabled === undefined ? false : thr.enabled;
|
||
const maxPingMs = thr.maxPingMs ?? '';
|
||
const minDown = thr.minDownloadMbps ?? '';
|
||
const minUp = thr.minUploadMbps ?? '';
|
||
return (
|
||
<tr key={`${conn.fromKey}-${conn.toKey}-${conn.interfaceName || ''}`}>
|
||
<td>
|
||
<div className="small">
|
||
<span className="fw-semibold">
|
||
{conn.fromLabel} ⇄ {conn.toLabel}
|
||
</span>
|
||
</div>
|
||
<div className="text-muted small font-monospace">
|
||
{conn.from} ⇄ {conn.to}
|
||
</div>
|
||
</td>
|
||
<td className="small">
|
||
{conn.interfaceName ? (
|
||
<span className="font-monospace">{conn.interfaceName}</span>
|
||
) : (
|
||
<span className="text-muted">—</span>
|
||
)}
|
||
</td>
|
||
<td>
|
||
<input
|
||
type="number"
|
||
className="form-control form-control-sm"
|
||
min={0}
|
||
max={5000}
|
||
value={maxPingMs}
|
||
onChange={(e) =>
|
||
upsertTunnelThreshold(conn, { maxPingMs: e.target.value })
|
||
}
|
||
disabled={saving || !enabled}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<input
|
||
type="number"
|
||
className="form-control form-control-sm"
|
||
min={0}
|
||
max={10000}
|
||
value={minDown}
|
||
onChange={(e) =>
|
||
upsertTunnelThreshold(conn, {
|
||
minDownloadMbps: e.target.value,
|
||
})
|
||
}
|
||
disabled={saving || !enabled}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<input
|
||
type="number"
|
||
className="form-control form-control-sm"
|
||
min={0}
|
||
max={10000}
|
||
value={minUp}
|
||
onChange={(e) =>
|
||
upsertTunnelThreshold(conn, {
|
||
minUploadMbps: e.target.value,
|
||
})
|
||
}
|
||
disabled={saving || !enabled}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<div className="form-check form-switch mb-0 d-inline-flex">
|
||
<input
|
||
className="form-check-input"
|
||
type="checkbox"
|
||
checked={enabled}
|
||
onChange={(e) =>
|
||
upsertTunnelThreshold(conn, { enabled: e.target.checked })
|
||
}
|
||
disabled={saving}
|
||
/>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|