feat(App, GraphView, SettingsPage, CommandPalette): add Network Map dashboard, enhance GraphView with ping data, and update SettingsPage for jumphost management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
This commit is contained in:
@@ -38,6 +38,7 @@ import NetworkConfigManager from './NetworkConfigManager';
|
||||
import MikrotikTools from './MikrotikTools.jsx';
|
||||
import Dashboard from './Dashboard';
|
||||
import TrafficDashboard from './TrafficDashboard.jsx';
|
||||
import NetworkMapDashboard from './NetworkMapDashboard.jsx';
|
||||
import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
|
||||
import PingServicesManager from './PingServicesManager.jsx';
|
||||
import SettingsPage from './SettingsPage.jsx';
|
||||
@@ -58,7 +59,7 @@ function LanguageProvider({ children }) {
|
||||
const dict = {
|
||||
ru: {
|
||||
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
|
||||
dashboard: 'Панель', trafficTraffic: 'Расход трафика', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
|
||||
dashboard: 'Панель', trafficTraffic: 'Расход трафика', networkMap: 'Карта сети', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
|
||||
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
|
||||
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов',
|
||||
light: 'Светлая', dark: 'Тёмная',
|
||||
@@ -66,7 +67,7 @@ function LanguageProvider({ children }) {
|
||||
},
|
||||
en: {
|
||||
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
|
||||
dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
|
||||
dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', networkMap: 'Network Map', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
|
||||
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
|
||||
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services',
|
||||
light: 'Light', dark: 'Dark',
|
||||
@@ -192,7 +193,8 @@ function MainLayout() {
|
||||
icon: IconHome,
|
||||
items: [
|
||||
{ id: 'dashboard', title: t('dashboard'), path: '/dashboard', icon: IconHome },
|
||||
{ id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie }
|
||||
{ id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie },
|
||||
{ id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -380,6 +382,7 @@ function MainLayout() {
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/traffic" element={<TrafficDashboard />} />
|
||||
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
||||
<Route path="/domains" element={<DomainsNewManager />} />
|
||||
<Route path="/ip-ranges" element={<IPRangesManager />} />
|
||||
<Route path="/asns" element={<ASNsNewManager />} />
|
||||
@@ -537,6 +540,7 @@ function MainLayout() {
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/traffic" element={<TrafficDashboard />} />
|
||||
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
||||
<Route path="/domains" element={<DomainsNewManager />} />
|
||||
<Route path="/ip-ranges" element={<IPRangesManager />} />
|
||||
<Route path="/asns" element={<ASNsNewManager />} />
|
||||
|
||||
@@ -14,7 +14,10 @@ import '@xyflow/react/dist/style.css';
|
||||
import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize, IconSearch, IconX, IconRefresh } from '@tabler/icons-react';
|
||||
import Tooltip from './components/Tooltip.jsx';
|
||||
|
||||
function GraphView({ servers, connections, onCreateConnection }) {
|
||||
/**
|
||||
* @param {Object} [pingMap] - опционально: ключ "fromIp:toIp" -> число (мс). Пинг между серверами для подписи на рёбрах.
|
||||
*/
|
||||
function GraphView({ servers, connections, onCreateConnection, pingMap }) {
|
||||
const flowRef = useRef(null);
|
||||
const instanceRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
@@ -144,17 +147,32 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
||||
}, [servers, connections]);
|
||||
|
||||
const initialEdgesData = useMemo(() => {
|
||||
const getPingLabel = (fromIp, toIp) => {
|
||||
if (!pingMap || typeof pingMap !== 'object') return '';
|
||||
const fwd = pingMap[`${fromIp}:${toIp}`];
|
||||
const rev = pingMap[`${toIp}:${fromIp}`];
|
||||
const parts = [];
|
||||
if (typeof fwd === 'number') parts.push(`→ ${fwd} ms`);
|
||||
if (typeof rev === 'number') parts.push(`← ${rev} ms`);
|
||||
if (parts.length === 0) return '';
|
||||
return parts.join(' · ');
|
||||
};
|
||||
return (connections || []).map((c, idx) => {
|
||||
const style = getTunnelStyle(c.tunnelType);
|
||||
const baseLabel = c.tunnelType || 'TUNNEL';
|
||||
const ipLabel = c.ipA && c.ipB ? `${c.ipA} ⇄ ${c.ipB}` : '';
|
||||
const pingLabel = getPingLabel(String(c.from), String(c.to));
|
||||
const labelParts = [baseLabel];
|
||||
if (ipLabel) labelParts.push(ipLabel);
|
||||
if (pingLabel) labelParts.push(pingLabel);
|
||||
const fullLabel = labelParts.join(' · ');
|
||||
const edgeId = `${c.from}-${c.to}-${idx}`;
|
||||
const isHighlighted = highlightedEdges.has(edgeId);
|
||||
return {
|
||||
id: edgeId,
|
||||
source: String(c.from),
|
||||
target: String(c.to),
|
||||
label: ipLabel ? `${baseLabel} · ${ipLabel}` : baseLabel,
|
||||
label: fullLabel,
|
||||
type: 'smoothstep',
|
||||
style: {
|
||||
stroke: style.color,
|
||||
@@ -176,7 +194,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
||||
animated: false,
|
||||
};
|
||||
});
|
||||
}, [connections, getTunnelStyle, highlightedEdges, highlightedNodeId, selectedNodeId]);
|
||||
}, [connections, getTunnelStyle, highlightedEdges, highlightedNodeId, selectedNodeId, pingMap]);
|
||||
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdgesData);
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import api from './lib/api.js';
|
||||
import { IconRefresh, IconTopologyRing } from '@tabler/icons-react';
|
||||
import PageHeader from './components/PageHeader.jsx';
|
||||
import GraphView from './GraphView.jsx';
|
||||
|
||||
/** Параллельно выполняем промисы с лимитом одновременных */
|
||||
async function runWithLimit(tasks, limit = 4) {
|
||||
const results = [];
|
||||
let index = 0;
|
||||
async function runNext() {
|
||||
const i = index++;
|
||||
if (i >= tasks.length) return;
|
||||
const task = tasks[i];
|
||||
try {
|
||||
const value = await task();
|
||||
results[i] = { value };
|
||||
} catch (err) {
|
||||
results[i] = { error: err };
|
||||
}
|
||||
await runNext();
|
||||
}
|
||||
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => runNext());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
export default function NetworkMapDashboard() {
|
||||
const [servers, setServers] = useState([]);
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [pingMap, setPingMap] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pingLoading, setPingLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const pingAbortRef = useRef(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [serversRes, connRes] = await Promise.all([
|
||||
api.get('/servers'),
|
||||
api.get('/server-connections'),
|
||||
]);
|
||||
const serversList = Array.isArray(serversRes?.data) ? serversRes.data : [];
|
||||
const connList = Array.isArray(connRes?.data) ? connRes.data : [];
|
||||
setServers(serversList);
|
||||
setConnections(connList);
|
||||
} catch (e) {
|
||||
console.error('NetworkMap fetch:', e);
|
||||
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить данные');
|
||||
setServers([]);
|
||||
setConnections([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const requestPings = useCallback(async () => {
|
||||
if (connections.length === 0) return;
|
||||
pingAbortRef.current = false;
|
||||
setPingLoading(true);
|
||||
const key = (a, b) => `${a}:${b}`;
|
||||
const newMap = {};
|
||||
|
||||
const tasks = [];
|
||||
connections.forEach((c) => {
|
||||
const from = String(c.from);
|
||||
const to = String(c.to);
|
||||
if (!from || !to || from === to) return;
|
||||
tasks.push(async () => {
|
||||
if (pingAbortRef.current) return;
|
||||
try {
|
||||
const { data } = await api.post('/mikrotik/ping', {
|
||||
serverId: from,
|
||||
target: to,
|
||||
count: 3,
|
||||
});
|
||||
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
|
||||
return { key: key(from, to), ms };
|
||||
} catch {
|
||||
return { key: key(from, to), ms: null };
|
||||
}
|
||||
});
|
||||
tasks.push(async () => {
|
||||
if (pingAbortRef.current) return;
|
||||
try {
|
||||
const { data } = await api.post('/mikrotik/ping', {
|
||||
serverId: to,
|
||||
target: from,
|
||||
count: 3,
|
||||
});
|
||||
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
|
||||
return { key: key(to, from), ms };
|
||||
} catch {
|
||||
return { key: key(to, from), ms: null };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const results = await runWithLimit(tasks, 4);
|
||||
if (pingAbortRef.current) return;
|
||||
results.forEach((r) => {
|
||||
const v = r?.value;
|
||||
if (v && v.key != null) newMap[v.key] = v.ms ?? null;
|
||||
});
|
||||
setPingMap((prev) => ({ ...prev, ...newMap }));
|
||||
setPingLoading(false);
|
||||
}, [connections]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && servers.length > 0 && connections.length > 0) {
|
||||
requestPings();
|
||||
}
|
||||
return () => {
|
||||
pingAbortRef.current = true;
|
||||
};
|
||||
}, [loading, connections.length]);
|
||||
|
||||
const handleRefreshPings = () => {
|
||||
requestPings();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Карта сети"
|
||||
icon={<IconTopologyRing size={24} />}
|
||||
meta="Серверы и пинг между ними"
|
||||
/>
|
||||
<div className="text-center py-5 text-muted">Загрузка серверов и связей…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Карта сети"
|
||||
icon={<IconTopologyRing size={24} />}
|
||||
meta="Серверы и пинг между ними"
|
||||
/>
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Карта сети"
|
||||
icon={<IconTopologyRing size={24} />}
|
||||
meta="Серверы и пинг между ними (на рёбрах — задержка в мс)"
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
onClick={handleRefreshPings}
|
||||
disabled={pingLoading}
|
||||
>
|
||||
<IconRefresh className={pingLoading ? 'spin me-2' : 'me-2'} size={18} />
|
||||
{pingLoading ? 'Пинг…' : 'Обновить пинг'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{servers.length === 0 ? (
|
||||
<div className="alert alert-info">
|
||||
Нет серверов. Добавьте серверы и связи в разделе «Серверы».
|
||||
</div>
|
||||
) : (
|
||||
<GraphView
|
||||
servers={servers}
|
||||
connections={connections}
|
||||
pingMap={pingMap}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+114
-44
@@ -12,6 +12,7 @@ import {
|
||||
IconSearch,
|
||||
IconChartBar,
|
||||
IconRefresh,
|
||||
IconServer,
|
||||
} from '@tabler/icons-react';
|
||||
import FormField from './components/FormField';
|
||||
import ErrorAlert from './components/ErrorAlert';
|
||||
@@ -74,7 +75,7 @@ export default function SettingsPage() {
|
||||
const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState('');
|
||||
const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState('');
|
||||
const [trafficInterfaceNamesSelected, setTrafficInterfaceNamesSelected] = useState([]);
|
||||
const [trafficInterfacesList, setTrafficInterfacesList] = useState([]);
|
||||
const [trafficJumphosts, setTrafficJumphosts] = useState([]);
|
||||
const [trafficInterfacesLoading, setTrafficInterfacesLoading] = useState(false);
|
||||
const [trafficInterfacesError, setTrafficInterfacesError] = useState('');
|
||||
const [serversList, setServersList] = useState([]);
|
||||
@@ -118,29 +119,41 @@ export default function SettingsPage() {
|
||||
try {
|
||||
const { data } = await api.get('/traffic/interface-stats');
|
||||
const jumphosts = Array.isArray(data?.jumphosts) ? data.jumphosts : [];
|
||||
const namesSet = new Set();
|
||||
for (const jh of jumphosts) {
|
||||
if (Array.isArray(jh.interfaces)) {
|
||||
for (const i of jh.interfaces) {
|
||||
if (i?.name != null && String(i.name).trim()) {
|
||||
namesSet.add(String(i.name).trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setTrafficInterfacesList(Array.from(namesSet).sort((a, b) => a.localeCompare(b)));
|
||||
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 || 'Не удалось загрузить список интерфейсов');
|
||||
setTrafficInterfacesList([]);
|
||||
setTrafficJumphosts([]);
|
||||
} finally {
|
||||
setTrafficInterfacesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== 'traffic-interfaces' || trafficInterfacesList.length > 0) return;
|
||||
if (activeSection !== 'traffic-interfaces' || trafficJumphosts.length > 0) return;
|
||||
fetchTrafficInterfaces();
|
||||
}, [activeSection, trafficInterfacesList.length, fetchTrafficInterfaces]);
|
||||
}, [activeSection, trafficJumphosts.length, fetchTrafficInterfaces]);
|
||||
|
||||
const trafficAllInterfaceNames = useMemo(() => {
|
||||
const set = new Set();
|
||||
for (const jh of trafficJumphosts) {
|
||||
for (const i of jh.interfaces || []) set.add(i.name);
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}, [trafficJumphosts]);
|
||||
|
||||
const goToSection = (id) => {
|
||||
setActiveSection(id);
|
||||
@@ -592,14 +605,14 @@ export default function SettingsPage() {
|
||||
Если ни один не выбран — учитываются все интерфейсы.
|
||||
</p>
|
||||
<div className="mb-3">
|
||||
<div className="form-label d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<span>Учитывать интерфейсы</span>
|
||||
<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={() => setTrafficInterfaceNamesSelected([...trafficInterfacesList])}
|
||||
disabled={saving || trafficInterfacesLoading || trafficInterfacesList.length === 0}
|
||||
onClick={() => setTrafficInterfaceNamesSelected([...trafficAllInterfaceNames])}
|
||||
disabled={saving || trafficInterfacesLoading || trafficAllInterfaceNames.length === 0}
|
||||
>
|
||||
Выбрать все
|
||||
</button>
|
||||
@@ -624,42 +637,99 @@ export default function SettingsPage() {
|
||||
</span>
|
||||
</div>
|
||||
{trafficInterfacesError && (
|
||||
<div className="alert alert-warning py-2 mb-2">
|
||||
<div className="alert alert-warning py-2 mb-3">
|
||||
{trafficInterfacesError}
|
||||
</div>
|
||||
)}
|
||||
{trafficInterfacesLoading && trafficInterfacesList.length === 0 && (
|
||||
{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 && trafficInterfacesList.length === 0 && !trafficInterfacesError && (
|
||||
{!trafficInterfacesLoading && trafficJumphosts.length === 0 && !trafficInterfacesError && (
|
||||
<div className="text-muted py-3">
|
||||
Нет доступных интерфейсов. Добавьте jumphost-серверы с MikroTik API и нажмите «Обновить».
|
||||
Нет доступных серверов. Добавьте jumphost-серверы с MikroTik API и нажмите «Обновить».
|
||||
</div>
|
||||
)}
|
||||
{trafficInterfacesList.length > 0 && (
|
||||
<div className="row g-2 mt-1">
|
||||
{trafficInterfacesList.map((name) => (
|
||||
<div key={name} className="col-12 col-sm-6 col-md-4 col-lg-3">
|
||||
<label className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
checked={trafficInterfaceNamesSelected.includes(name)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setTrafficInterfaceNamesSelected((prev) => [...prev, name].sort((a, b) => a.localeCompare(b)));
|
||||
} else {
|
||||
setTrafficInterfaceNamesSelected((prev) => prev.filter((n) => n !== name));
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
aria-label={`Интерфейс ${name}`}
|
||||
/>
|
||||
<span className="form-check-label font-monospace">{name}</span>
|
||||
</label>
|
||||
{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 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost-secondary"
|
||||
onClick={() => {
|
||||
const names = (jh.interfaces || []).map((i) => i.name);
|
||||
setTrafficInterfaceNamesSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
const allChecked = names.every((n) => next.has(n));
|
||||
if (allChecked) names.forEach((n) => next.delete(n));
|
||||
else names.forEach((n) => next.add(n));
|
||||
return Array.from(next).sort((a, b) => a.localeCompare(b));
|
||||
});
|
||||
}}
|
||||
disabled={saving}
|
||||
title="Выбрать / снять все на этом сервере"
|
||||
>
|
||||
{(jh.interfaces || []).every((i) => trafficInterfaceNamesSelected.includes(i.name))
|
||||
? 'Снять все'
|
||||
: 'Выбрать все'}
|
||||
</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) => (
|
||||
<div key={iface.name} className="col-12 col-sm-6">
|
||||
<label className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
checked={trafficInterfaceNamesSelected.includes(iface.name)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setTrafficInterfaceNamesSelected((prev) => [...prev, iface.name].sort((a, b) => a.localeCompare(b)));
|
||||
} else {
|
||||
setTrafficInterfaceNamesSelected((prev) => prev.filter((n) => n !== iface.name));
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
aria-label={`Интерфейс ${iface.name}`}
|
||||
/>
|
||||
<span className="form-check-label font-monospace">{iface.name}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,7 @@ function CommandPalette() {
|
||||
const commands = [
|
||||
{ icon: IconHome, label: 'Главная', description: 'Панель управления', action: () => navigate('/dashboard'), keywords: ['главная', 'панель', 'dashboard'] },
|
||||
{ icon: IconChartPie, label: 'Расход трафика', description: 'Статистика по интерфейсам MikroTik', action: () => navigate('/traffic'), keywords: ['трафик', 'traffic', 'mikrotik', 'интерфейсы'] },
|
||||
{ icon: IconNetwork, label: 'Карта сети', description: 'Граф серверов и пинг между ними', action: () => navigate('/network-map'), keywords: ['карта', 'сеть', 'network', 'map', 'пинг', 'ping'] },
|
||||
{ icon: IconWorld, label: 'Домены', description: 'Управление доменами', action: () => navigate('/domains'), keywords: ['домены', 'domains'] },
|
||||
{ icon: IconNetwork, label: 'IP-диапазоны', description: 'Управление IP диапазонами', action: () => navigate('/ip-ranges'), keywords: ['ip', 'диапазоны', 'ranges'] },
|
||||
{ icon: IconNetwork, label: 'ASN', description: 'Управление Autonomous Systems', action: () => navigate('/asns'), keywords: ['asn', 'as', 'autonomous'] },
|
||||
|
||||
Reference in New Issue
Block a user