feat(MikrotikTools): implement gateway selection feature with enhanced UI for improved user experience and server-specific filtering
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m24s
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 3m24s
This commit is contained in:
+145
-133
@@ -199,6 +199,16 @@ function MikrotikTools() {
|
|||||||
[jumphostServers, serverId]
|
[jumphostServers, serverId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const gatewaysForServer = useMemo(() => {
|
||||||
|
if (!serverId || !gateways.length) return [];
|
||||||
|
const ids = currentServer
|
||||||
|
? new Set(
|
||||||
|
[serverId, currentServer.id, currentServer.ip, currentServer.dns].filter(Boolean).map(String)
|
||||||
|
)
|
||||||
|
: new Set([String(serverId)]);
|
||||||
|
return gateways.filter((g) => g && g.ip && ids.has(String(g.serverId)));
|
||||||
|
}, [serverId, currentServer, gateways]);
|
||||||
|
|
||||||
const currentServerLabel =
|
const currentServerLabel =
|
||||||
currentServer?.dns || currentServer?.ip || makeServerId(currentServer || {}) || 'Не выбран';
|
currentServer?.dns || currentServer?.ip || makeServerId(currentServer || {}) || 'Не выбран';
|
||||||
|
|
||||||
@@ -304,58 +314,143 @@ function MikrotikTools() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Горизонтальная панель: режим (Traceroute/Ping) + основное действие — по образцу Tabler "page-header" с btn-list */}
|
{/* Шлюз и параметры проверки — только когда выбран сервер, в том же стиле что и блок серверов */}
|
||||||
<div className="card mb-3">
|
{serverId && (
|
||||||
<div className="card-body py-2">
|
<>
|
||||||
<div className="row align-items-center g-2">
|
{/* Выбор gateway в стиле карточек серверов */}
|
||||||
<div className="col">
|
<div className="card mb-3">
|
||||||
<ul className="nav nav-pills" role="tablist">
|
<div className="card-header">
|
||||||
<li className="nav-item" role="presentation">
|
<h3 className="card-title mb-1">Шлюз (gateway)</h3>
|
||||||
<button
|
<div className="text-muted small">
|
||||||
type="button"
|
{gatewaysForServer.length > 0
|
||||||
className={`nav-link ${mode === 'traceroute' ? 'active' : ''}`}
|
? 'Выберите шлюз для привязки проверки (опционально). Можно искать по IP или интерфейсу ниже.'
|
||||||
onClick={() => { setMode('traceroute'); setHops([]); setAnalysis([]); setPingResult(null); }}
|
: 'Нет настроенных шлюзов для этого сервера. Проверка пойдёт без привязки к gateway.'}
|
||||||
role="tab"
|
</div>
|
||||||
>
|
|
||||||
Traceroute
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className="nav-item" role="presentation">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`nav-link ${mode === 'ping' ? 'active' : ''}`}
|
|
||||||
onClick={() => { setMode('ping'); setHops([]); setAnalysis([]); setPingResult(null); }}
|
|
||||||
role="tab"
|
|
||||||
>
|
|
||||||
Ping
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto ms-auto">
|
<div className="card-body">
|
||||||
<div className="btn-list">
|
{loading && !networkConfig ? (
|
||||||
<button
|
<div className="text-muted small">Загрузка сетевых настроек…</div>
|
||||||
type="button"
|
) : (
|
||||||
className="btn btn-outline-secondary"
|
<>
|
||||||
onClick={handleReset}
|
{gatewaysForServer.length > 0 && (
|
||||||
disabled={running}
|
<div className="row row-cards g-2 mb-3">
|
||||||
>
|
{gatewaysForServer .map((g) => {
|
||||||
<IconRefresh size={16} className="me-1" />
|
const isSelected = gatewayRef === g.ip || gatewayRef === g.id;
|
||||||
Сбросить
|
const flag = g.country ? countryToFlag(g.country) : '';
|
||||||
</button>
|
const subtitle = [g.country, g.provider].filter(Boolean).join(' · ');
|
||||||
<button
|
return (
|
||||||
type="button"
|
<div className="col-12 col-sm-6 col-md-4 col-lg-3" key={g.id || g.ip}>
|
||||||
className="btn btn-primary"
|
<div
|
||||||
onClick={handleRunCheck}
|
className={`card card-sm h-100 cursor-pointer ${
|
||||||
disabled={running || loading}
|
isSelected ? 'border-primary card-hover' : 'card-hover'
|
||||||
>
|
}`}
|
||||||
{running ? 'Выполнение…' : (mode === 'traceroute' ? 'Запустить трассировку' : 'Запустить ping')}
|
onClick={() => {
|
||||||
</button>
|
const next = isSelected ? '' : (g.ip || g.id);
|
||||||
|
setGatewayRef(next);
|
||||||
|
if (!next) {
|
||||||
|
setGatewayMeta(null);
|
||||||
|
} else {
|
||||||
|
setGatewayMeta({ ip: g.ip, gateway: g });
|
||||||
|
if (!target && g.ip) setTarget(g.ip);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="card-body d-flex align-items-start gap-2">
|
||||||
|
<span className="avatar bg-primary-lt" title={g.country || ''}>
|
||||||
|
{flag || (g.ip ? g.ip.slice(0, 2) : '?')}
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow-1 min-w-0">
|
||||||
|
<div className="fw-bold text-truncate" title={g.ip || ''}>
|
||||||
|
{g.ip || '—'}
|
||||||
|
</div>
|
||||||
|
{(g.description || g.ip) && (
|
||||||
|
<div className="text-muted small text-truncate" title={g.description || g.ip}>
|
||||||
|
{g.description || g.ip}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-muted small mt-1">
|
||||||
|
{subtitle || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<GatewayAutocompleteInput
|
||||||
|
value={gatewayRef}
|
||||||
|
onChange={setGatewayRef}
|
||||||
|
gateways={gateways}
|
||||||
|
interfaces={interfaces}
|
||||||
|
serverId={serverId || null}
|
||||||
|
placeholder="IP gateway или remote IP туннеля…"
|
||||||
|
onSelectMeta={handleSelectGatewayMeta}
|
||||||
|
/>
|
||||||
|
<div className="text-muted small mt-2">
|
||||||
|
Текущий gateway:
|
||||||
|
<span className="fw-semibold">{gatewayRef || gatewayMeta?.ip || 'не выбран'}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Режим и кнопки запуска */}
|
||||||
|
<div className="card mb-3">
|
||||||
|
<div className="card-body py-2">
|
||||||
|
<div className="row align-items-center g-2">
|
||||||
|
<div className="col">
|
||||||
|
<ul className="nav nav-pills" role="tablist">
|
||||||
|
<li className="nav-item" role="presentation">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`nav-link ${mode === 'traceroute' ? 'active' : ''}`}
|
||||||
|
onClick={() => { setMode('traceroute'); setHops([]); setAnalysis([]); setPingResult(null); }}
|
||||||
|
role="tab"
|
||||||
|
>
|
||||||
|
Traceroute
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item" role="presentation">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`nav-link ${mode === 'ping' ? 'active' : ''}`}
|
||||||
|
onClick={() => { setMode('ping'); setHops([]); setAnalysis([]); setPingResult(null); }}
|
||||||
|
role="tab"
|
||||||
|
>
|
||||||
|
Ping
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto ms-auto">
|
||||||
|
<div className="btn-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={running}
|
||||||
|
>
|
||||||
|
<IconRefresh size={16} className="me-1" />
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleRunCheck}
|
||||||
|
disabled={running || loading}
|
||||||
|
>
|
||||||
|
{running ? 'Выполнение…' : (mode === 'traceroute' ? 'Запустить трассировку' : 'Запустить ping')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Параметры проверки + результаты */}
|
{/* Параметры проверки + результаты */}
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
@@ -367,93 +462,10 @@ function MikrotikTools() {
|
|||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
{loading && !networkConfig ? (
|
{loading && !networkConfig ? (
|
||||||
<div className="text-muted small">Загрузка сетевых настроек…</div>
|
<div className="text-muted small">Загрузка сетевых настроек…</div>
|
||||||
|
) : !serverId ? (
|
||||||
|
<div className="text-muted small">Выберите сервер выше — шлюз и параметры появятся там.</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Привязка к gateway / интерфейсу (опционально)</label>
|
|
||||||
{/* Карточки gateways выбранного сервера */}
|
|
||||||
{serverId && gateways.length > 0 && (
|
|
||||||
<div className="mb-2">
|
|
||||||
<div className="row row-cards g-2">
|
|
||||||
{gateways
|
|
||||||
.filter((g) => {
|
|
||||||
if (!g || !g.ip) return false;
|
|
||||||
if (!serverId) return false;
|
|
||||||
if (!currentServer) return g.serverId === serverId;
|
|
||||||
const serverIds = new Set([
|
|
||||||
serverId,
|
|
||||||
currentServer.id,
|
|
||||||
currentServer.ip,
|
|
||||||
currentServer.dns,
|
|
||||||
].filter(Boolean).map(String));
|
|
||||||
return serverIds.has(String(g.serverId));
|
|
||||||
})
|
|
||||||
.map((g) => {
|
|
||||||
const isSelected = gatewayRef === g.ip || gatewayRef === g.id;
|
|
||||||
const flag = g.country ? countryToFlag(g.country) : '';
|
|
||||||
// В подзаголовке показываем страну/провайдера, чтобы не дублировать description
|
|
||||||
const subtitle = [g.country, g.provider].filter(Boolean).join(' · ');
|
|
||||||
return (
|
|
||||||
// Показываем карточки одной колонкой (по одной в ряд), чтобы текст был читаемым
|
|
||||||
<div className="col-12" key={g.id || g.ip}>
|
|
||||||
<div
|
|
||||||
className={`card card-sm h-100 cursor-pointer ${
|
|
||||||
isSelected ? 'border-primary card-hover' : 'card-hover'
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
const next = isSelected ? '' : (g.ip || g.id);
|
|
||||||
setGatewayRef(next);
|
|
||||||
if (!next) {
|
|
||||||
setGatewayMeta(null);
|
|
||||||
} else {
|
|
||||||
setGatewayMeta({ ip: g.ip, gateway: g });
|
|
||||||
if (!target && g.ip) setTarget(g.ip);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="card-body py-2 px-3 d-flex align-items-start gap-2">
|
|
||||||
<span className="avatar bg-green-lt text-green">
|
|
||||||
{flag || (g.ip ? g.ip.split('.').slice(-1)[0] : '?')}
|
|
||||||
</span>
|
|
||||||
<div className="flex-grow-1 min-w-0">
|
|
||||||
<div className="fw-bold text-truncate" title={g.ip || g.description || ''}>
|
|
||||||
{g.ip || '—'}
|
|
||||||
</div>
|
|
||||||
{g.description && (
|
|
||||||
<div className="text-muted small text-truncate" title={g.description}>
|
|
||||||
{g.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{subtitle && (
|
|
||||||
<div className="text-muted small text-truncate">
|
|
||||||
{subtitle}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Расширенный выбор по IP/интерфейсу */}
|
|
||||||
<GatewayAutocompleteInput
|
|
||||||
value={gatewayRef}
|
|
||||||
onChange={setGatewayRef}
|
|
||||||
gateways={gateways}
|
|
||||||
interfaces={interfaces}
|
|
||||||
serverId={serverId || null}
|
|
||||||
placeholder="IP gateway или remote IP туннеля…"
|
|
||||||
onSelectMeta={handleSelectGatewayMeta}
|
|
||||||
/>
|
|
||||||
<div className="form-text">
|
|
||||||
Можно выбрать gateway карточкой выше или через поиск по IP/интерфейсу.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label">Домен или IP назначения</label>
|
<label className="form-label">Домен или IP назначения</label>
|
||||||
<div className="input-group input-group-flat">
|
<div className="input-group input-group-flat">
|
||||||
|
|||||||
Reference in New Issue
Block a user