416 lines
16 KiB
React
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>
|
|
);
|
|
}
|