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

This commit is contained in:
2026-02-17 00:15:32 +07:00
parent 17ab2a30ec
commit 89a86f7c72
7 changed files with 661 additions and 35 deletions
+45 -17
View File
@@ -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,
};
+1
View File
@@ -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 ===
+7 -3
View File
@@ -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
View File
@@ -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}
+395
View File
@@ -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>
);
}
+105
View File
@@ -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 };
+89
View File
@@ -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;