feat(SettingsPage): implement traffic interface selection with loading state and error handling for enhanced user interaction
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m4s

This commit is contained in:
2026-02-17 16:00:10 +07:00
parent e0c7ec32d6
commit d6fc877e90
+116 -28
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from 'react';
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import api from './lib/api.js';
import {
@@ -11,6 +11,7 @@ import {
IconWorld,
IconSearch,
IconChartBar,
IconRefresh,
} from '@tabler/icons-react';
import FormField from './components/FormField';
import ErrorAlert from './components/ErrorAlert';
@@ -72,7 +73,10 @@ export default function SettingsPage() {
const [pingServicesServerId, setPingServicesServerId] = useState('');
const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState('');
const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState('');
const [trafficInterfaceNames, setTrafficInterfaceNames] = useState('');
const [trafficInterfaceNamesSelected, setTrafficInterfaceNamesSelected] = useState([]);
const [trafficInterfacesList, setTrafficInterfacesList] = useState([]);
const [trafficInterfacesLoading, setTrafficInterfacesLoading] = useState(false);
const [trafficInterfacesError, setTrafficInterfacesError] = useState('');
const [serversList, setServersList] = useState([]);
const [sidebarSearch, setSidebarSearch] = useState('');
const [activeSection, setActiveSection] = useState(() => {
@@ -108,6 +112,36 @@ export default function SettingsPage() {
}
}, [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 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)));
} catch (e) {
setTrafficInterfacesError(e?.response?.data?.message || e?.message || 'Не удалось загрузить список интерфейсов');
setTrafficInterfacesList([]);
} finally {
setTrafficInterfacesLoading(false);
}
}, []);
useEffect(() => {
if (activeSection !== 'traffic-interfaces' || trafficInterfacesList.length > 0) return;
fetchTrafficInterfaces();
}, [activeSection, trafficInterfacesList.length, fetchTrafficInterfaces]);
const goToSection = (id) => {
setActiveSection(id);
navigate(`/settings#${id}`, { replace: true });
@@ -150,12 +184,10 @@ export default function SettingsPage() {
: ''
);
const rawNames = data?.trafficInterfaceNames;
setTrafficInterfaceNames(
setTrafficInterfaceNamesSelected(
Array.isArray(rawNames)
? rawNames.filter((n) => n != null).map(String).join(', ')
: typeof rawNames === 'string'
? rawNames
: ''
? rawNames.filter((n) => n != null).map(String)
: []
);
const e =
settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
@@ -229,10 +261,9 @@ export default function SettingsPage() {
0,
parseInt(pingServicesCacheSeconds, 10) || 0
),
trafficInterfaceNames: (trafficInterfaceNames || '')
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean),
trafficInterfaceNames: Array.isArray(trafficInterfaceNamesSelected)
? trafficInterfaceNamesSelected.filter(Boolean)
: [],
};
const payload = { settings: mergedSettings, etag };
const res = await api.post('/ui-settings', payload);
@@ -557,25 +588,82 @@ export default function SettingsPage() {
<>
<SectionHeading title="Настройка Аналитики" icon={IconChartBar} />
<p className="text-muted mb-3">
Укажите, какие интерфейсы учитывать на странице <strong>Расход трафика</strong>.
Пустое поле учитываются все интерфейсы. Имена можно перечислить через запятую или с новой строки.
Отметьте интерфейсы, которые нужно учитывать на странице <strong>Расход трафика</strong>.
Если ни один не выбран учитываются все интерфейсы.
</p>
<div className="row g-2">
<div className="col-12">
<label className="form-label">Учитывать интерфейсы</label>
<textarea
className="form-control font-monospace"
rows={4}
value={trafficInterfaceNames}
onChange={(e) => setTrafficInterfaceNames(e.target.value)}
placeholder="ether1, ether2, pppoe-out1"
disabled={saving}
aria-label="Список имён интерфейсов"
/>
<div className="form-text">
Например: ether1, ether2, pppoe-out1. Пусто все интерфейсы.
</div>
<div className="mb-3">
<div className="form-label d-flex align-items-center justify-content-between flex-wrap gap-2">
<span>Учитывать интерфейсы</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}
>
Выбрать все
</button>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setTrafficInterfaceNamesSelected([])}
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-2">
{trafficInterfacesError}
</div>
)}
{trafficInterfacesLoading && trafficInterfacesList.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 && (
<div className="text-muted py-3">
Нет доступных интерфейсов. Добавьте 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>
</div>
))}
</div>
)}
</div>
</>
)}