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