feat(PingServices): add endpoint for retrieving ping services list and update dashboard to display dynamic service configurations
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m12s
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 1m12s
This commit is contained in:
@@ -331,14 +331,29 @@ function measureTcpRtt(host, port = 443, timeoutMs = 6000) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Список целей для пинга с главной: id, имя, хост, порт */
|
||||
const PING_SERVICES = [
|
||||
{ id: 'google', name: 'Google', host: '8.8.8.8', port: 443 },
|
||||
{ id: 'cloudflare', name: 'Cloudflare', host: '1.1.1.1', port: 443 },
|
||||
{ id: 'yandex', name: 'Yandex', host: 'ya.ru', port: 443 },
|
||||
{ id: 'instagram', name: 'Instagram', host: 'instagram.com', port: 443 },
|
||||
/** Список целей для пинга по умолчанию (id, name, host, port, icon, color) */
|
||||
const PING_SERVICES_DEFAULT = [
|
||||
{ id: 'google', name: 'Google', host: '8.8.8.8', port: 443, icon: 'IconBrandGoogle', color: 'blue' },
|
||||
{ id: 'cloudflare', name: 'Cloudflare', host: '1.1.1.1', port: 443, icon: 'IconBrandCloudflare', color: 'orange' },
|
||||
{ id: 'yandex', name: 'Yandex', host: 'ya.ru', port: 443, icon: 'IconBrandYandex', color: 'red' },
|
||||
{ id: 'instagram', name: 'Instagram', host: 'instagram.com', port: 443, icon: 'IconBrandInstagram', color: 'pink' },
|
||||
];
|
||||
|
||||
function getPingServicesListFromSettings(uiSettings) {
|
||||
const raw = uiSettings.pingServicesList;
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
return raw.map((s) => ({
|
||||
id: String(s?.id ?? '').trim() || `svc-${Math.random().toString(36).slice(2, 9)}`,
|
||||
name: String(s?.name ?? '').trim() || 'Сервис',
|
||||
host: String(s?.host ?? '').trim() || '0.0.0.0',
|
||||
port: Math.max(1, parseInt(s?.port, 10) || 443),
|
||||
icon: typeof s?.icon === 'string' ? s.icon : 'IconWorld',
|
||||
color: typeof s?.color === 'string' ? s.color : 'primary',
|
||||
})).filter((s) => s.host !== '0.0.0.0');
|
||||
}
|
||||
return PING_SERVICES_DEFAULT;
|
||||
}
|
||||
|
||||
/** Загрузить UI-настройки из S3 (для pingServicesSource и др.) */
|
||||
async function loadUiSettingsSync() {
|
||||
try {
|
||||
@@ -353,16 +368,26 @@ async function loadUiSettingsSync() {
|
||||
|
||||
const PING_SERVICES_CACHE_KEY_PREFIX = 'ping-services/cache_';
|
||||
|
||||
// GET /api/ping-services — RTT до Google, Cloudflare, Yandex, Instagram (веб или через RouterOS по настройке)
|
||||
async function getPingServices(req, res) {
|
||||
const fallbackPayload = {
|
||||
google: { id: 'google', name: 'Google', host: '8.8.8.8', ms: null },
|
||||
cloudflare: { id: 'cloudflare', name: 'Cloudflare', host: '1.1.1.1', ms: null },
|
||||
yandex: { id: 'yandex', name: 'Yandex', host: 'ya.ru', ms: null },
|
||||
instagram: { id: 'instagram', name: 'Instagram', host: 'instagram.com', ms: null },
|
||||
};
|
||||
// GET /api/ping-services-list — список сервисов для пинга из настроек (для дашборда и редактора)
|
||||
async function getPingServicesList(req, res) {
|
||||
try {
|
||||
const uiSettings = await loadUiSettingsSync();
|
||||
const list = getPingServicesListFromSettings(uiSettings);
|
||||
return res.json({ list });
|
||||
} catch (e) {
|
||||
console.error('ping-services-list error', e);
|
||||
return res.json({ list: PING_SERVICES_DEFAULT });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/ping-services — RTT до сервисов из списка (веб или через RouterOS по настройке)
|
||||
async function getPingServices(req, res) {
|
||||
try {
|
||||
const uiSettings = await loadUiSettingsSync();
|
||||
const servicesList = getPingServicesListFromSettings(uiSettings);
|
||||
const fallbackPayload = {};
|
||||
servicesList.forEach((s) => { fallbackPayload[s.id] = { id: s.id, name: s.name, host: s.host, ms: null }; });
|
||||
|
||||
const viaRouter = String(uiSettings.pingServicesSource || 'web').toLowerCase() === 'router';
|
||||
const cacheSeconds = Math.max(0, parseInt(uiSettings.pingServicesCacheSeconds, 10) || 0);
|
||||
|
||||
@@ -414,7 +439,7 @@ async function getPingServices(req, res) {
|
||||
return res.json(fallbackPayload);
|
||||
}
|
||||
const results = await Promise.all(
|
||||
PING_SERVICES.map(async (svc) => {
|
||||
servicesList.map(async (svc) => {
|
||||
try {
|
||||
const result = await runPingViaRouter(serverId, gatewayIpResolved || null, svc.host, 3);
|
||||
const ms = typeof result.avgMs === 'number' ? Math.round(result.avgMs) : null;
|
||||
@@ -434,7 +459,7 @@ async function getPingServices(req, res) {
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
PING_SERVICES.map(async (svc) => {
|
||||
servicesList.map(async (svc) => {
|
||||
const ms = await measureTcpRtt(svc.host, svc.port, 6000);
|
||||
return { id: svc.id, name: svc.name, host: svc.host, ms };
|
||||
})
|
||||
@@ -447,7 +472,9 @@ async function getPingServices(req, res) {
|
||||
res.json(byId);
|
||||
} catch (e) {
|
||||
console.error('ping-services error', e);
|
||||
res.status(500).json(fallbackPayload);
|
||||
const fallback = {};
|
||||
PING_SERVICES_DEFAULT.forEach((s) => { fallback[s.id] = { id: s.id, name: s.name, host: s.host, ms: null }; });
|
||||
res.status(500).json(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,6 +693,7 @@ module.exports = {
|
||||
getWsUrl,
|
||||
getUiSettings,
|
||||
postUiSettings,
|
||||
getPingServicesList,
|
||||
getPingServices,
|
||||
};
|
||||
|
||||
|
||||
@@ -439,6 +439,7 @@ app.post('/api/update-bgp/background', bgpUpdateLimiter, miscRoutes.updateBgpBac
|
||||
app.get('/api/ws/url', miscRoutes.getWsUrl);
|
||||
app.get('/api/ui-settings', miscRoutes.getUiSettings);
|
||||
app.post('/api/ui-settings', miscRoutes.postUiSettings);
|
||||
app.get('/api/ping-services-list', miscRoutes.getPingServicesList);
|
||||
app.get('/api/ping-services', miscRoutes.getPingServices);
|
||||
|
||||
// === IPSEC PASSWORDS ===
|
||||
|
||||
@@ -37,6 +37,7 @@ import NetworkConfigManager from './NetworkConfigManager';
|
||||
import MikrotikTools from './MikrotikTools.jsx';
|
||||
import Dashboard from './Dashboard';
|
||||
import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
|
||||
import PingServicesManager from './PingServicesManager.jsx';
|
||||
import './App.css';
|
||||
import { NotifyProvider } from './components/NotifyProvider.jsx';
|
||||
import SettingsModal from './components/SettingsModal.jsx';
|
||||
@@ -57,7 +58,7 @@ function LanguageProvider({ children }) {
|
||||
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
|
||||
dashboard: 'Панель', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
|
||||
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
|
||||
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы',
|
||||
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов',
|
||||
light: 'Светлая', dark: 'Тёмная',
|
||||
layoutSidebar: 'Сайдбар', layoutHorizontal: 'Верхнее меню'
|
||||
},
|
||||
@@ -65,7 +66,7 @@ function LanguageProvider({ children }) {
|
||||
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
|
||||
dashboard: 'Dashboard', 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',
|
||||
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services',
|
||||
light: 'Light', dark: 'Dark',
|
||||
layoutSidebar: 'Sidebar', layoutHorizontal: 'Top menu'
|
||||
}
|
||||
@@ -221,7 +222,8 @@ function MainLayout() {
|
||||
items: [
|
||||
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload },
|
||||
{ id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase },
|
||||
{ id: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork }
|
||||
{ id: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork },
|
||||
{ id: 'ping-services', title: t('pingServices'), path: '/ping-services', icon: IconNetwork }
|
||||
]
|
||||
},
|
||||
// Убрали неиспользуемые/неработающие разделы
|
||||
@@ -380,6 +382,7 @@ function MainLayout() {
|
||||
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
||||
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
||||
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||
<Route path="/ping-services" element={<PingServicesManager />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
@@ -535,6 +538,7 @@ function MainLayout() {
|
||||
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
||||
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
||||
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||
<Route path="/ping-services" element={<PingServicesManager />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
+19
-15
@@ -16,11 +16,8 @@ import {
|
||||
IconDownload,
|
||||
IconCreditCard,
|
||||
IconSearch,
|
||||
IconBrandGoogle,
|
||||
IconBrandCloudflare,
|
||||
IconBrandYandex,
|
||||
IconBrandInstagram
|
||||
} from '@tabler/icons-react';
|
||||
import { getIconById } from './lib/brandIcons.js';
|
||||
import PageHeader from './components/PageHeader.jsx';
|
||||
import TopNStats from './components/TopNStats.jsx';
|
||||
import TrendIndicator from './components/TrendIndicator.jsx';
|
||||
@@ -78,16 +75,10 @@ function MetricCard({ title, value, icon: Icon, color, description }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Карточка сервиса с иконкой и пингом (как на референсном скрине) */
|
||||
const PING_SERVICES_CONFIG = [
|
||||
{ id: 'google', name: 'Google', host: '8.8.8.8', Icon: IconBrandGoogle, color: 'blue' },
|
||||
{ id: 'cloudflare', name: 'Cloudflare', host: '1.1.1.1', Icon: IconBrandCloudflare, color: 'orange' },
|
||||
{ id: 'yandex', name: 'Yandex', host: 'ya.ru', Icon: IconBrandYandex, color: 'red' },
|
||||
{ id: 'instagram', name: 'Instagram', host: 'instagram.com', Icon: IconBrandInstagram, color: 'pink' },
|
||||
];
|
||||
|
||||
/** Карточка сервиса с иконкой и пингом (config из API: id, name, host, icon, color) */
|
||||
function PingServiceCard({ config, ms, loading }) {
|
||||
const { name, host, Icon, color } = config;
|
||||
const { name, host, color } = config;
|
||||
const { Icon } = getIconById(config.icon);
|
||||
const value = loading ? '...' : (ms != null ? `${ms} мс` : '—');
|
||||
return (
|
||||
<div className="card h-100 position-relative">
|
||||
@@ -125,6 +116,7 @@ function Dashboard() {
|
||||
const [error, setError] = useState(null);
|
||||
const [lastFetchTime, setLastFetchTime] = useState(null);
|
||||
const [pingServices, setPingServices] = useState(null);
|
||||
const [pingServicesConfig, setPingServicesConfig] = useState([]);
|
||||
const [pingLoading, setPingLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -224,6 +216,18 @@ function Dashboard() {
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.get('/ping-services-list')
|
||||
.then(({ data }) => {
|
||||
if (!cancelled && Array.isArray(data?.list)) setPingServicesConfig(data.list);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPingServicesConfig([]);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPingLoading(true);
|
||||
@@ -270,9 +274,9 @@ function Dashboard() {
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Пинг до сервисов: Google, Cloudflare, Yandex, Instagram */}
|
||||
{/* Пинг до сервисов (список из настроек / Пинг сервисов) */}
|
||||
<div className="row g-3 mb-4">
|
||||
{PING_SERVICES_CONFIG.map((config) => (
|
||||
{pingServicesConfig.map((config) => (
|
||||
<div key={config.id} className="col-6 col-md-3">
|
||||
<PingServiceCard
|
||||
config={config}
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from './lib/api.js';
|
||||
import {
|
||||
IconPlus,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconDeviceFloppy,
|
||||
IconCloud,
|
||||
IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
import PageHeader from './components/PageHeader.jsx';
|
||||
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
||||
import FormModal from './components/FormModal.jsx';
|
||||
import FormField from './components/FormField.jsx';
|
||||
import ConfirmModal from './components/ConfirmModal.jsx';
|
||||
import ErrorAlert from './components/ErrorAlert.jsx';
|
||||
import IconPicker from './components/IconPicker.jsx';
|
||||
import { getIconById } from './lib/brandIcons.js';
|
||||
|
||||
const COLOR_OPTIONS = [
|
||||
{ value: 'primary', label: 'Основной' },
|
||||
{ value: 'blue', label: 'Синий' },
|
||||
{ value: 'green', label: 'Зелёный' },
|
||||
{ value: 'orange', label: 'Оранжевый' },
|
||||
{ value: 'red', label: 'Красный' },
|
||||
{ value: 'pink', label: 'Розовый' },
|
||||
{ value: 'purple', label: 'Фиолетовый' },
|
||||
{ value: 'cyan', label: 'Голубой' },
|
||||
{ value: 'yellow', label: 'Жёлтый' },
|
||||
];
|
||||
|
||||
function slugId(name) {
|
||||
const s = String(name || '').trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
return s || `svc-${Date.now()}`;
|
||||
}
|
||||
|
||||
export default function PingServicesManager() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [rawSettings, setRawSettings] = useState({});
|
||||
const [etag, setEtag] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
|
||||
const [newItem, setNewItem] = useState({
|
||||
id: '',
|
||||
name: '',
|
||||
host: '',
|
||||
port: '443',
|
||||
icon: 'IconBrandGoogle',
|
||||
color: 'blue',
|
||||
});
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [editDraft, setEditDraft] = useState({ id: '', name: '', host: '', port: '443', icon: 'IconBrandGoogle', color: 'blue' });
|
||||
|
||||
const loadSettings = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await api.get('/ui-settings');
|
||||
const data = res?.data || {};
|
||||
setRawSettings(data);
|
||||
setEtag(String(res?.headers?.etag || res?.headers?.ETag || '').replace(/"/g, ''));
|
||||
const list = Array.isArray(data.pingServicesList) ? data.pingServicesList : [];
|
||||
setItems(list.map((s) => ({
|
||||
id: String(s?.id ?? '').trim() || slugId(s?.name),
|
||||
name: String(s?.name ?? '').trim(),
|
||||
host: String(s?.host ?? '').trim(),
|
||||
port: Math.max(1, parseInt(s?.port, 10) || 443),
|
||||
icon: typeof s?.icon === 'string' ? s.icon : 'IconWorld',
|
||||
color: typeof s?.color === 'string' ? s.color : 'primary',
|
||||
})).filter((s) => s.host));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('Не удалось загрузить настройки');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const saveSettings = async () => {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
const payload = {
|
||||
...rawSettings,
|
||||
pingServicesList: items.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
port: s.port,
|
||||
icon: s.icon,
|
||||
color: s.color,
|
||||
})),
|
||||
};
|
||||
const res = await api.post('/ui-settings', { settings: payload, etag });
|
||||
setRawSettings(payload);
|
||||
setEtag(String(res?.data?.etag || etag));
|
||||
setSuccess('Список сервисов сохранён');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} catch (e) {
|
||||
setError(e?.response?.data?.message || 'Не удалось сохранить');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openAdd = () => {
|
||||
setNewItem({
|
||||
id: '',
|
||||
name: '',
|
||||
host: '',
|
||||
port: '443',
|
||||
icon: 'IconBrandGoogle',
|
||||
color: 'blue',
|
||||
});
|
||||
setAddModalOpen(true);
|
||||
};
|
||||
|
||||
const submitAdd = () => {
|
||||
const name = String(newItem.name || '').trim();
|
||||
const host = String(newItem.host || '').trim();
|
||||
if (!name) { setError('Укажите название'); return; }
|
||||
if (!host) { setError('Укажите хост'); return; }
|
||||
const id = newItem.id?.trim() || slugId(name);
|
||||
if (items.some((i) => i.id === id)) { setError('Такой ID уже есть'); return; }
|
||||
setError('');
|
||||
setItems((prev) => [...prev, {
|
||||
id,
|
||||
name,
|
||||
host,
|
||||
port: Math.max(1, parseInt(newItem.port, 10) || 443),
|
||||
icon: newItem.icon || 'IconWorld',
|
||||
color: newItem.color || 'primary',
|
||||
}]);
|
||||
setAddModalOpen(false);
|
||||
setSuccess('Сервис добавлен. Нажмите «Сохранить».');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
};
|
||||
|
||||
const openEdit = (item) => {
|
||||
setEditingItem(item);
|
||||
setEditDraft({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
host: item.host,
|
||||
port: String(item.port),
|
||||
icon: item.icon || 'IconWorld',
|
||||
color: item.color || 'primary',
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const submitEdit = () => {
|
||||
const name = String(editDraft.name || '').trim();
|
||||
const host = String(editDraft.host || '').trim();
|
||||
if (!name) { setError('Укажите название'); return; }
|
||||
if (!host) { setError('Укажите хост'); return; }
|
||||
const id = editDraft.id?.trim() || editingItem.id;
|
||||
if (id !== editingItem.id && items.some((i) => i.id === id)) { setError('Такой ID уже есть'); return; }
|
||||
setError('');
|
||||
setItems((prev) => prev.map((i) => (i.id === editingItem.id ? {
|
||||
id,
|
||||
name,
|
||||
host,
|
||||
port: Math.max(1, parseInt(editDraft.port, 10) || 443),
|
||||
icon: editDraft.icon || 'IconWorld',
|
||||
color: editDraft.color || 'primary',
|
||||
} : i)));
|
||||
setEditModalOpen(false);
|
||||
setEditingItem(null);
|
||||
setSuccess('Сервис обновлён. Нажмите «Сохранить».');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
};
|
||||
|
||||
const openDelete = (item) => {
|
||||
setItemToDelete(item);
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (itemToDelete) {
|
||||
setItems((prev) => prev.filter((i) => i.id !== itemToDelete.id));
|
||||
setDeleteModalOpen(false);
|
||||
setItemToDelete(null);
|
||||
setSuccess('Сервис удалён. Нажмите «Сохранить».');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const filtered = items.filter((i) => {
|
||||
const q = searchTerm.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (i.name || '').toLowerCase().includes(q) || (i.host || '').toLowerCase().includes(q) || (i.id || '').toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Пинг сервисов"
|
||||
icon={<IconCloud size={24} />}
|
||||
actions={(
|
||||
<PageHeaderActions>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={openAdd}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconPlus size={18} className="me-1" />
|
||||
Добавить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
onClick={saveSettings}
|
||||
disabled={saving || loading}
|
||||
>
|
||||
{saving && <span className="spinner-border spinner-border-sm me-2" />}
|
||||
<IconDeviceFloppy size={18} className="me-1" />
|
||||
Сохранить
|
||||
</button>
|
||||
</PageHeaderActions>
|
||||
)}
|
||||
/>
|
||||
|
||||
{error && <ErrorAlert message={error} onClose={() => setError('')} onRetry={loadSettings} />}
|
||||
{success && (
|
||||
<div className="alert alert-success alert-dismissible">
|
||||
{success}
|
||||
<button type="button" className="btn-close" onClick={() => setSuccess('')} aria-label="Закрыть" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header d-flex align-items-center gap-2 flex-wrap">
|
||||
<div className="input-group input-group-flat" style={{ maxWidth: 280 }}>
|
||||
<span className="input-group-text">
|
||||
<IconSearch size={16} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Поиск по названию, хосту, ID..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted small ms-2">
|
||||
{filtered.length} из {items.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 56 }}>Иконка</th>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Хост</th>
|
||||
<th>Порт</th>
|
||||
<th>Цвет</th>
|
||||
<th className="w-1">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-4 text-muted">Загрузка...</td>
|
||||
</tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-4 text-muted">
|
||||
Нет сервисов. Нажмите «Добавить» или сохраните список — на главной будут использоваться сервисы по умолчанию.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((item) => {
|
||||
const iconInfo = getIconById(item.icon);
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<span className={`avatar avatar-sm bg-${item.color}-lt text-${item.color} rounded d-inline-flex align-items-center justify-content-center`}>
|
||||
<iconInfo.Icon size={18} stroke={1.5} />
|
||||
</span>
|
||||
</td>
|
||||
<td><code className="small">{item.id}</code></td>
|
||||
<td>{item.name}</td>
|
||||
<td><code className="small">{item.host}</code></td>
|
||||
<td>{item.port}</td>
|
||||
<td>
|
||||
<span className={`badge bg-${item.color}-lt text-${item.color}`}>{item.color}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="btn-list">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={() => openEdit(item)}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => openDelete(item)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormModal
|
||||
show={addModalOpen}
|
||||
onClose={() => setAddModalOpen(false)}
|
||||
onSubmit={submitAdd}
|
||||
title="Добавить сервис для пинга"
|
||||
submitLabel="Добавить"
|
||||
>
|
||||
<FormField label="Название" name="name" value={newItem.name} onChange={(v) => setNewItem((p) => ({ ...p, name: v }))} placeholder="Google" />
|
||||
<FormField label="Хост (IP или домен)" name="host" value={newItem.host} onChange={(v) => setNewItem((p) => ({ ...p, host: v }))} placeholder="8.8.8.8" />
|
||||
<FormField label="Порт" name="port" type="number" value={newItem.port} onChange={(v) => setNewItem((p) => ({ ...p, port: v }))} min={1} max={65535} />
|
||||
<IconPicker value={newItem.icon} onChange={(id) => setNewItem((p) => ({ ...p, icon: id }))} />
|
||||
<div className="mb-2">
|
||||
<label className="form-label small">Цвет</label>
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
value={newItem.color}
|
||||
onChange={(e) => setNewItem((p) => ({ ...p, color: e.target.value }))}
|
||||
>
|
||||
{COLOR_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<FormField label="ID (необязательно)" name="id" value={newItem.id} onChange={(v) => setNewItem((p) => ({ ...p, id: v }))} placeholder="auto из названия" helpText="Уникальный ключ. Пусто — из названия." />
|
||||
</FormModal>
|
||||
|
||||
<FormModal
|
||||
show={editModalOpen}
|
||||
onClose={() => { setEditModalOpen(false); setEditingItem(null); }}
|
||||
onSubmit={submitEdit}
|
||||
title="Редактировать сервис"
|
||||
>
|
||||
<FormField label="ID" name="id" value={editDraft.id} onChange={(v) => setEditDraft((p) => ({ ...p, id: v }))} />
|
||||
<FormField label="Название" name="name" value={editDraft.name} onChange={(v) => setEditDraft((p) => ({ ...p, name: v }))} />
|
||||
<FormField label="Хост" name="host" value={editDraft.host} onChange={(v) => setEditDraft((p) => ({ ...p, host: v }))} />
|
||||
<FormField label="Порт" name="port" type="number" value={editDraft.port} onChange={(v) => setEditDraft((p) => ({ ...p, port: v }))} min={1} max={65535} />
|
||||
<IconPicker value={editDraft.icon} onChange={(id) => setEditDraft((p) => ({ ...p, icon: id }))} />
|
||||
<div className="mb-2">
|
||||
<label className="form-label small">Цвет</label>
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
value={editDraft.color}
|
||||
onChange={(e) => setEditDraft((p) => ({ ...p, color: e.target.value }))}
|
||||
>
|
||||
{COLOR_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
<ConfirmModal
|
||||
show={deleteModalOpen}
|
||||
title="Удалить сервис?"
|
||||
message={itemToDelete ? `Сервис «${itemToDelete.name}» (${itemToDelete.host}) будет удалён из списка.` : ''}
|
||||
confirmLabel="Удалить"
|
||||
variant="danger"
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => { setDeleteModalOpen(false); setItemToDelete(null); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import BRAND_ICONS, { getIconById } from '../lib/brandIcons.js';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* Выбор иконки бренда с поиском и предпросмотром.
|
||||
* value — id иконки (например IconBrandGoogle), onChange(id) — при выборе.
|
||||
*/
|
||||
export default function IconPicker({ value, onChange, disabled = false, label = 'Иконка бренда' }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selected = getIconById(value);
|
||||
const filtered = useMemo(() => {
|
||||
const q = (search || '').trim().toLowerCase();
|
||||
if (!q) return BRAND_ICONS;
|
||||
return BRAND_ICONS.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) || item.id.toLowerCase().includes(q)
|
||||
);
|
||||
}, [search]);
|
||||
|
||||
const handleSelect = (item) => {
|
||||
onChange?.(item.id);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const panelRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClickOutside = (e) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target) && !e.target.closest('button[aria-expanded]')) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<label className="form-label small">{label}</label>
|
||||
<div className="d-flex align-items-center gap-2 flex-wrap" ref={panelRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-2"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
disabled={disabled}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<span className="avatar avatar-sm bg-primary-lt text-primary rounded d-flex align-items-center justify-content-center">
|
||||
<selected.Icon size={18} stroke={1.5} />
|
||||
</span>
|
||||
<span>{selected.label}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border rounded p-2 shadow-sm bg-body" style={{ minWidth: 280, maxWidth: 360 }}>
|
||||
<div className="input-group input-group-sm mb-2">
|
||||
<span className="input-group-text">
|
||||
<IconSearch size={14} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Поиск по названию..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="d-flex flex-wrap gap-1 overflow-auto"
|
||||
style={{ maxHeight: 200 }}
|
||||
role="listbox"
|
||||
>
|
||||
{filtered.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`btn btn-sm d-flex align-items-center justify-content-center rounded ${
|
||||
value === item.id ? 'btn-primary' : 'btn-outline-secondary'
|
||||
}`}
|
||||
style={{ width: 40, height: 40 }}
|
||||
onClick={() => handleSelect(item)}
|
||||
title={item.label}
|
||||
role="option"
|
||||
aria-selected={value === item.id}
|
||||
>
|
||||
<item.Icon size={22} stroke={1.5} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filtered.length === 0 && (
|
||||
<div className="small text-muted py-2">Ничего не найдено</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { getIconById };
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Реестр брендовых иконок (Tabler Icons) для выбора в настройках пинг-сервисов.
|
||||
* id — имя компонента (например IconBrandGoogle), label — для поиска и отображения.
|
||||
*/
|
||||
import {
|
||||
IconBrandGoogle,
|
||||
IconBrandCloudflare,
|
||||
IconBrandYandex,
|
||||
IconBrandInstagram,
|
||||
IconBrandGithub,
|
||||
IconBrandTelegram,
|
||||
IconBrandWhatsapp,
|
||||
IconBrandFacebook,
|
||||
IconBrandX,
|
||||
IconBrandYoutube,
|
||||
IconBrandTiktok,
|
||||
IconBrandAmazon,
|
||||
IconBrandApple,
|
||||
IconBrandMicrosoft,
|
||||
IconBrandDiscord,
|
||||
IconBrandSpotify,
|
||||
IconBrandVk,
|
||||
IconBrandReddit,
|
||||
IconBrandLinkedin,
|
||||
IconBrandPinterest,
|
||||
IconBrandTwitch,
|
||||
IconBrandSteam,
|
||||
IconBrandNginx,
|
||||
IconBrandDocker,
|
||||
IconBrandChrome,
|
||||
IconBrandFirefox,
|
||||
IconBrandSafari,
|
||||
IconBrandEdge,
|
||||
IconBrandSlack,
|
||||
IconBrandZoom,
|
||||
IconBrandSkype,
|
||||
IconBrandPaypal,
|
||||
IconBrandStripe,
|
||||
IconBrandVisa,
|
||||
IconBrandMastercard,
|
||||
IconWorld,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
const BRAND_ICONS = [
|
||||
{ id: 'IconBrandGoogle', label: 'Google', Icon: IconBrandGoogle },
|
||||
{ id: 'IconBrandCloudflare', label: 'Cloudflare', Icon: IconBrandCloudflare },
|
||||
{ id: 'IconBrandYandex', label: 'Yandex', Icon: IconBrandYandex },
|
||||
{ id: 'IconBrandInstagram', label: 'Instagram', Icon: IconBrandInstagram },
|
||||
{ id: 'IconBrandGithub', label: 'GitHub', Icon: IconBrandGithub },
|
||||
{ id: 'IconBrandTelegram', label: 'Telegram', Icon: IconBrandTelegram },
|
||||
{ id: 'IconBrandWhatsapp', label: 'WhatsApp', Icon: IconBrandWhatsapp },
|
||||
{ id: 'IconBrandFacebook', label: 'Facebook', Icon: IconBrandFacebook },
|
||||
{ id: 'IconBrandX', label: 'X (Twitter)', Icon: IconBrandX },
|
||||
{ id: 'IconBrandYoutube', label: 'YouTube', Icon: IconBrandYoutube },
|
||||
{ id: 'IconBrandTiktok', label: 'TikTok', Icon: IconBrandTiktok },
|
||||
{ id: 'IconBrandAmazon', label: 'Amazon', Icon: IconBrandAmazon },
|
||||
{ id: 'IconBrandApple', label: 'Apple', Icon: IconBrandApple },
|
||||
{ id: 'IconBrandMicrosoft', label: 'Microsoft', Icon: IconBrandMicrosoft },
|
||||
{ id: 'IconBrandDiscord', label: 'Discord', Icon: IconBrandDiscord },
|
||||
{ id: 'IconBrandSpotify', label: 'Spotify', Icon: IconBrandSpotify },
|
||||
{ id: 'IconBrandVk', label: 'VK', Icon: IconBrandVk },
|
||||
{ id: 'IconBrandReddit', label: 'Reddit', Icon: IconBrandReddit },
|
||||
{ id: 'IconBrandLinkedin', label: 'LinkedIn', Icon: IconBrandLinkedin },
|
||||
{ id: 'IconBrandPinterest', label: 'Pinterest', Icon: IconBrandPinterest },
|
||||
{ id: 'IconBrandTwitch', label: 'Twitch', Icon: IconBrandTwitch },
|
||||
{ id: 'IconBrandSteam', label: 'Steam', Icon: IconBrandSteam },
|
||||
{ id: 'IconBrandNginx', label: 'Nginx', Icon: IconBrandNginx },
|
||||
{ id: 'IconBrandDocker', label: 'Docker', Icon: IconBrandDocker },
|
||||
{ id: 'IconBrandChrome', label: 'Chrome', Icon: IconBrandChrome },
|
||||
{ id: 'IconBrandFirefox', label: 'Firefox', Icon: IconBrandFirefox },
|
||||
{ id: 'IconBrandSafari', label: 'Safari', Icon: IconBrandSafari },
|
||||
{ id: 'IconBrandEdge', label: 'Edge', Icon: IconBrandEdge },
|
||||
{ id: 'IconBrandSlack', label: 'Slack', Icon: IconBrandSlack },
|
||||
{ id: 'IconBrandZoom', label: 'Zoom', Icon: IconBrandZoom },
|
||||
{ id: 'IconBrandSkype', label: 'Skype', Icon: IconBrandSkype },
|
||||
{ id: 'IconBrandPaypal', label: 'PayPal', Icon: IconBrandPaypal },
|
||||
{ id: 'IconBrandStripe', label: 'Stripe', Icon: IconBrandStripe },
|
||||
{ id: 'IconBrandVisa', label: 'Visa', Icon: IconBrandVisa },
|
||||
{ id: 'IconBrandMastercard', label: 'Mastercard', Icon: IconBrandMastercard },
|
||||
{ id: 'IconWorld', label: 'Сеть (World)', Icon: IconWorld },
|
||||
];
|
||||
|
||||
const ICON_BY_ID = Object.fromEntries(BRAND_ICONS.map((item) => [item.id, item]));
|
||||
|
||||
export function getIconById(id) {
|
||||
return ICON_BY_ID[id] || BRAND_ICONS[0];
|
||||
}
|
||||
|
||||
export default BRAND_ICONS;
|
||||
Reference in New Issue
Block a user