Files
router-lists-ui/frontend/src/PingServicesManager.jsx
T

416 lines
16 KiB
React

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 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 (abortSignal) => {
setLoading(true);
setError('');
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const res = await api.get('/ui-settings', opts);
const data = res?.data || {};
setRawSettings(data);
setEtag(String(res?.headers?.etag || res?.headers?.ETag || ''));
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) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
console.error(e);
setError('Не удалось загрузить настройки');
} finally {
setLoading(false);
}
};
useEffect(() => {
const controller = new AbortController();
loadSettings(controller.signal);
return () => controller.abort();
}, []);
const saveSettings = async () => {
setSaving(true);
setError('');
setSuccess('');
const payloadToSave = {
...rawSettings,
pingServicesList: items.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
port: s.port,
icon: s.icon,
color: s.color,
})),
};
try {
let currentEtag = etag;
let res = await api.post('/ui-settings', { settings: payloadToSave, etag: currentEtag });
setRawSettings(payloadToSave);
setEtag(String(res?.data?.etag || currentEtag));
setSuccess('Список сервисов сохранён');
setTimeout(() => setSuccess(''), 3000);
} catch (e) {
if (e?.response?.status === 412) {
try {
const fresh = await api.get('/ui-settings');
const freshData = fresh?.data || {};
const freshEtag = String(fresh?.headers?.etag || fresh?.headers?.ETag || '');
const retryPayload = { ...freshData, pingServicesList: payloadToSave.pingServicesList };
const retryRes = await api.post('/ui-settings', { settings: retryPayload, etag: freshEtag });
setRawSettings(retryPayload);
setEtag(String(retryRes?.data?.etag || freshEtag));
setSuccess('Список сервисов сохранён (настройки были обновлены)');
setTimeout(() => setSuccess(''), 3000);
} catch (retryErr) {
setError(retryErr?.response?.data?.message || 'Настройки изменились. Обновите страницу и сохраните снова.');
}
} else {
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={(
<div className="btn-list">
<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>
</div>
)}
/>
{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 { Icon } = 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`}>
<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>
);
}