feat(SettingsPage, TrafficDashboard): add traffic interface settings and filtering for enhanced analytics functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
IconChartPie,
|
IconChartPie,
|
||||||
IconWorld,
|
IconWorld,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
|
IconChartBar,
|
||||||
} 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';
|
||||||
@@ -42,6 +43,12 @@ const SIDEBAR_GROUPS = [
|
|||||||
{ id: 'ptr-zone', title: 'PTR зона', icon: IconWorld },
|
{ id: 'ptr-zone', title: 'PTR зона', icon: IconWorld },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Аналитика',
|
||||||
|
items: [
|
||||||
|
{ id: 'traffic-interfaces', title: 'Настройка Аналитики', icon: IconChartBar },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const SIDEBAR_SECTIONS = SIDEBAR_GROUPS.flatMap((g) => g.items);
|
const SIDEBAR_SECTIONS = SIDEBAR_GROUPS.flatMap((g) => g.items);
|
||||||
|
|
||||||
@@ -65,6 +72,7 @@ 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 [serversList, setServersList] = useState([]);
|
const [serversList, setServersList] = useState([]);
|
||||||
const [sidebarSearch, setSidebarSearch] = useState('');
|
const [sidebarSearch, setSidebarSearch] = useState('');
|
||||||
const [activeSection, setActiveSection] = useState(() => {
|
const [activeSection, setActiveSection] = useState(() => {
|
||||||
@@ -141,6 +149,14 @@ export default function SettingsPage() {
|
|||||||
? String(data.pingServicesCacheSeconds)
|
? String(data.pingServicesCacheSeconds)
|
||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
|
const rawNames = data?.trafficInterfaceNames;
|
||||||
|
setTrafficInterfaceNames(
|
||||||
|
Array.isArray(rawNames)
|
||||||
|
? rawNames.filter((n) => n != null).map(String).join(', ')
|
||||||
|
: typeof rawNames === 'string'
|
||||||
|
? rawNames
|
||||||
|
: ''
|
||||||
|
);
|
||||||
const e =
|
const e =
|
||||||
settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
|
settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
|
||||||
setEtag(e ? String(e) : '');
|
setEtag(e ? String(e) : '');
|
||||||
@@ -213,6 +229,10 @@ export default function SettingsPage() {
|
|||||||
0,
|
0,
|
||||||
parseInt(pingServicesCacheSeconds, 10) || 0
|
parseInt(pingServicesCacheSeconds, 10) || 0
|
||||||
),
|
),
|
||||||
|
trafficInterfaceNames: (trafficInterfaceNames || '')
|
||||||
|
.split(/[\n,]+/)
|
||||||
|
.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);
|
||||||
@@ -532,6 +552,33 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeSection === 'traffic-interfaces' && (
|
||||||
|
<>
|
||||||
|
<SectionHeading title="Настройка Аналитики" icon={IconChartBar} />
|
||||||
|
<p className="text-muted mb-3">
|
||||||
|
Укажите, какие интерфейсы учитывать на странице <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>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import {
|
import {
|
||||||
IconChartPie,
|
IconChartPie,
|
||||||
@@ -158,17 +158,34 @@ function JumphostCard({ jumphost }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Нормализовать список имён интерфейсов из настроек (массив, пустой = все) */
|
||||||
|
function getTrafficInterfaceFilter(settings) {
|
||||||
|
const raw = settings?.trafficInterfaceNames;
|
||||||
|
if (!raw) return null;
|
||||||
|
const arr = Array.isArray(raw) ? raw : (typeof raw === 'string' ? raw.split(/[\n,]+/).map((s) => s.trim()) : []);
|
||||||
|
const names = arr.filter(Boolean);
|
||||||
|
return names.length > 0 ? new Set(names) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function TrafficDashboard() {
|
export default function TrafficDashboard() {
|
||||||
const [jumphosts, setJumphosts] = useState([]);
|
const [jumphosts, setJumphosts] = useState([]);
|
||||||
|
const [uiSettings, setUiSettings] = useState({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const interfaceFilter = getTrafficInterfaceFilter(uiSettings);
|
||||||
|
|
||||||
const fetchStats = useCallback(async () => {
|
const fetchStats = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/traffic/interface-stats');
|
const [statsRes, settingsRes] = await Promise.all([
|
||||||
|
api.get('/traffic/interface-stats'),
|
||||||
|
api.get('/ui-settings').catch(() => ({ data: {} })),
|
||||||
|
]);
|
||||||
|
const data = statsRes?.data || {};
|
||||||
setJumphosts(Array.isArray(data?.jumphosts) ? data.jumphosts : []);
|
setJumphosts(Array.isArray(data?.jumphosts) ? data.jumphosts : []);
|
||||||
|
setUiSettings(settingsRes?.data || {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Traffic stats:', e);
|
console.error('Traffic stats:', e);
|
||||||
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить статистику');
|
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить статистику');
|
||||||
@@ -182,9 +199,19 @@ export default function TrafficDashboard() {
|
|||||||
fetchStats();
|
fetchStats();
|
||||||
}, [fetchStats]);
|
}, [fetchStats]);
|
||||||
|
|
||||||
|
const filteredJumphosts = useMemo(() => {
|
||||||
|
if (!interfaceFilter) return jumphosts;
|
||||||
|
return jumphosts.map((jh) => ({
|
||||||
|
...jh,
|
||||||
|
interfaces: Array.isArray(jh.interfaces)
|
||||||
|
? jh.interfaces.filter((i) => i.name != null && interfaceFilter.has(String(i.name).trim()))
|
||||||
|
: [],
|
||||||
|
}));
|
||||||
|
}, [jumphosts, interfaceFilter]);
|
||||||
|
|
||||||
// Суммарная статистика по всем отображаемым системам (без ошибок, с данными)
|
// Суммарная статистика по всем отображаемым системам (без ошибок, с данными)
|
||||||
const summary = (() => {
|
const summary = (() => {
|
||||||
const withData = (jumphosts || []).filter((jh) => !jh.error && Array.isArray(jh.interfaces) && jh.interfaces.length > 0);
|
const withData = (filteredJumphosts || []).filter((jh) => !jh.error && Array.isArray(jh.interfaces) && jh.interfaces.length > 0);
|
||||||
let totalBytes = 0;
|
let totalBytes = 0;
|
||||||
let totalRx = 0;
|
let totalRx = 0;
|
||||||
let totalTx = 0;
|
let totalTx = 0;
|
||||||
@@ -334,7 +361,7 @@ export default function TrafficDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="row row-cards g-3">
|
<div className="row row-cards g-3">
|
||||||
{jumphosts.map((jh) => (
|
{filteredJumphosts.map((jh) => (
|
||||||
<div key={jh.serverId || jh.host || jh.name} className="col-12 col-md-6 col-xl-4">
|
<div key={jh.serverId || jh.host || jh.name} className="col-12 col-md-6 col-xl-4">
|
||||||
<JumphostCard jumphost={jh} />
|
<JumphostCard jumphost={jh} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user