Files
router-lists-ui/frontend/src/SettingsPage.jsx
T
denozord 7b7ef1ab45
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m35s
feat(route-optimizer): enhance AI settings management and UI integration for route optimization
2026-03-04 23:22:09 +07:00

2004 lines
92 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import api from './lib/api.js';
import {
IconSettings,
IconDeviceFloppy,
IconPlugConnected,
IconNetwork,
IconCloud,
IconChartPie,
IconWorld,
IconSearch,
IconChartBar,
IconRefresh,
IconServer,
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 (11440 мин). По умолчанию 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 -&gt; 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>
</>
);
}