feat: Рефакторинг модальных окон для улучшения UX, добавление базового компонента Modal и улучшение структуры кода. Обновление компонентов HistoryModal, ImportModal, SettingsModal и WsUpdateModal для повышения читаемости и удобства использования.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s

This commit is contained in:
2025-10-03 09:42:58 +07:00
parent 2ae26801c4
commit 41ef73376a
5 changed files with 561 additions and 363 deletions
+126 -97
View File
@@ -1,11 +1,16 @@
import { useEffect, useState } from 'react'; import Modal from './Modal';
import { useState } from 'react';
import api from '../lib/api.js'; import api from '../lib/api.js';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import ConfirmDialog from './ConfirmDialog.jsx'; import ConfirmDialog from './ConfirmDialog.jsx';
import { useNotify } from './NotifyProvider.jsx'; import { useNotify } from './NotifyProvider.jsx';
import { IconHistory, IconRefresh, IconDeviceFloppy, IconInfoCircle, IconRotate2 } from '@tabler/icons-react'; import { IconHistory, IconRefresh, IconInfoCircle, IconRotate2, IconClock } from '@tabler/icons-react';
import { formatDateTimeWithRelative } from '../lib/datetime.js'; import { formatDateTimeWithRelative } from '../lib/datetime.js';
/**
* HistoryModal - модальное окно истории версий
* Рефакторинг: теперь использует базовый Modal компонент
*/
export default function HistoryModal({ resource, show, onClose, onRolledBack }) { export default function HistoryModal({ resource, show, onClose, onRolledBack }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
@@ -15,123 +20,147 @@ export default function HistoryModal({ resource, show, onClose, onRolledBack })
queryKey: ['history', resource], queryKey: ['history', resource],
enabled: false, enabled: false,
queryFn: async () => { queryFn: async () => {
const res = await api.get(`/history/${resource}`) const res = await api.get(`/history/${resource}`);
const data = Array.isArray(res.data?.items) ? res.data.items : [] const data = Array.isArray(res.data?.items) ? res.data.items : [];
setItems(data) setItems(data);
return data return data;
}, },
}) });
useEffect(() => { if (show) refetch().catch(() => notify.error('Не удалось загрузить историю версий')); }, [show, resource]);
const [confirmState, setConfirmState] = useState({ open: false, onConfirm: null }); const [confirmState, setConfirmState] = useState({ open: false, onConfirm: null });
const rollback = async (versionId) => { const rollback = async (versionId) => {
if (!versionId) return; if (!versionId) return;
await new Promise((resolve) => { setConfirmState({
setConfirmState({ open: true,
open: true, onConfirm: async () => {
onConfirm: async () => { setConfirmState({ open: false, onConfirm: null });
setConfirmState({ open: false, onConfirm: null }); setLoading(true);
setLoading(true); try {
try { const res = await api.post(`/history/${resource}/rollback`, { versionId });
const res = await api.post(`/history/${resource}/rollback`, { versionId }); notify.success('Откат выполнен успешно');
notify.success('Откат выполнен'); onRolledBack?.(res.data || {});
onRolledBack && onRolledBack(res.data || {}); onClose?.();
onClose && onClose(); } catch (e) {
} catch (e) { notify.error('Не удалось выполнить откат');
notify.error('Не удалось выполнить откат'); } finally {
} finally { setLoading(false);
setLoading(false);
}
resolve();
} }
}); }
}); });
}; };
if (!show) return null; const handleOpen = () => {
if (show) {
refetch().catch(() => notify.error('Не удалось загрузить историю версий'));
}
};
return ( return (
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="history-title" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onClose?.() }}> <>
<div className="modal-dialog modal-lg modal-dialog-centered" role="document"> <Modal
<div className="modal-content" tabIndex={-1} onKeyDown={(e) => { show={show}
if (e.key === 'Tab') { onClose={onClose}
const c = e.currentTarget title={
const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])') <div className="d-flex align-items-center">
if (!focusable || focusable.length === 0) return <IconHistory className="me-2" size={24} />
const first = focusable[0] История версий
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}}>
<div className="modal-header">
<h5 id="history-title" className="modal-title d-flex align-items-center">
<IconHistory className="me-2" /> История версий
</h5>
<button type="button" className="btn-close" onClick={onClose}></button>
</div> </div>
<div className="modal-body"> }
<div className="d-flex justify-content-between align-items-center mb-2"> size="lg"
<div className="text-muted small d-flex align-items-center"> centered
<IconInfoCircle size={16} className="me-1" /> scrollable
Ресурс: <code className="ms-1">{resource}</code> onOpen={handleOpen}
</div> >
<button className="btn btn-outline-secondary btn-sm" onClick={() => refetch()} disabled={loading}> {/* Info bar */}
<IconRefresh className={loading ? 'spin' : ''} /> <div className="d-flex justify-content-between align-items-center mb-3 p-2 bg-blue-lt rounded">
<span className="ms-1">Обновить</span> <div className="text-muted small d-flex align-items-center">
</button> <IconInfoCircle size={16} className="me-2" />
</div> Ресурс: <code className="ms-1">{resource}</code>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th>Версия</th>
<th>Дата</th>
<th>Размер</th>
<th>ETag</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr><td colSpan="5" className="text-muted text-center py-4">Нет данных (возможно, версионирование бакета отключено)</td></tr>
) : items.map((v) => (
<tr key={v.versionId} className={v.isLatest ? 'table-info' : ''}>
<td><code>{v.versionId}</code></td>
<td>{v.lastModified ? formatDateTimeWithRelative(v.lastModified) : '—'}</td>
<td>{typeof v.size === 'number' ? `${v.size} байт` : '—'}</td>
<td><code>{v.etag || '—'}</code></td>
<td className="text-end">
{!v.isLatest && (
<button className="btn btn-outline-primary btn-sm" onClick={() => rollback(v.versionId)} disabled={loading} title="Восстановить содержимое файла до этой версии">
<IconRotate2 className="me-1" /> Восстановить
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="modal-footer">
<button className="btn" onClick={onClose}>Закрыть</button>
</div> </div>
<button
className="btn btn-outline-primary btn-sm"
onClick={() => refetch()}
disabled={loading}
>
<IconRefresh size={16} className={loading ? 'spin' : ''} />
<span className="ms-1">Обновить</span>
</button>
</div> </div>
</div>
{/* Table */}
<div className="table-responsive">
<table className="table card-table table-vcenter table-hover">
<thead>
<tr>
<th>Версия</th>
<th>Дата</th>
<th>Размер</th>
<th>ETag</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td colSpan="5" className="text-muted text-center py-5">
<div className="empty">
<div className="empty-icon">
<IconClock size={48} className="text-muted" />
</div>
<p className="empty-title">Нет данных</p>
<p className="empty-subtitle text-muted">
Версионирование бакета может быть отключено
</p>
</div>
</td>
</tr>
) : (
items.map((v) => (
<tr key={v.versionId} className={v.isLatest ? 'table-active' : ''}>
<td>
<code className="text-muted small">{v.versionId}</code>
{v.isLatest && (
<span className="badge bg-green-lt text-green ms-2">Текущая</span>
)}
</td>
<td className="text-nowrap">
{v.lastModified ? formatDateTimeWithRelative(v.lastModified) : '—'}
</td>
<td>{typeof v.size === 'number' ? `${v.size} байт` : '—'}</td>
<td><code className="text-muted small">{v.etag || '—'}</code></td>
<td className="text-end">
{!v.isLatest && (
<button
className="btn btn-primary btn-sm"
onClick={() => rollback(v.versionId)}
disabled={loading}
title="Восстановить содержимое файла до этой версии"
>
<IconRotate2 size={16} className="me-1" />
Восстановить
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Modal>
<ConfirmDialog <ConfirmDialog
open={confirmState.open} open={confirmState.open}
title="Восстановить версию файла?" title="Восстановить версию файла?"
message="Текущее содержимое будет заменено выбранной версией. Действие можно отменить, выбрав предыдущую версию." message="Текущее содержимое будет заменено выбранной версией. Действие можно отменить, выбрав предыдущую версию."
confirmText="Восстановить" confirmText="Восстановить"
destructive destructive
size={'sm'} size="sm"
loading={loading}
onCancel={() => setConfirmState({ open: false, onConfirm: null })} onCancel={() => setConfirmState({ open: false, onConfirm: null })}
onConfirm={confirmState.onConfirm} onConfirm={confirmState.onConfirm}
/> />
</div> </>
); );
} }
+141 -65
View File
@@ -1,5 +1,11 @@
import Modal from './Modal';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { IconUpload, IconFileText, IconAlertCircle, IconCheck, IconX } from '@tabler/icons-react';
/**
* ImportModal - модальное окно импорта данных
* Рефакторинг: использует базовый Modal, улучшенный UX
*/
function ImportModal({ function ImportModal({
show, show,
title = 'Импорт', title = 'Импорт',
@@ -23,12 +29,10 @@ function ImportModal({
setParsed({ items: [], invalid: [] }); setParsed({ items: [], invalid: [] });
setFileName(''); setFileName('');
setDragOver(false); setDragOver(false);
setTimeout(() => textAreaRef.current?.focus(), 0); setTimeout(() => textAreaRef.current?.focus(), 100);
} }
}, [show]); }, [show]);
if (!show) return null;
const parseText = (raw) => { const parseText = (raw) => {
const lines = String(raw || '') const lines = String(raw || '')
.split(/\r?\n/) .split(/\r?\n/)
@@ -38,7 +42,11 @@ function ImportModal({
const invalid = []; const invalid = [];
for (const line of lines) { for (const line of lines) {
const obj = parseLine(line); const obj = parseLine(line);
if (obj && validateItem(obj)) items.push(obj); else invalid.push(line); if (obj && validateItem(obj)) {
items.push(obj);
} else {
invalid.push(line);
}
} }
setParsed({ items, invalid }); setParsed({ items, invalid });
}; };
@@ -78,73 +86,141 @@ function ImportModal({
}; };
return ( return (
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1"> <Modal
<div className="modal-dialog modal-lg modal-dialog-centered" role="document"> show={show}
<div className="modal-content"> onClose={onClose}
<button type="button" className="btn-close" onClick={onClose}></button> title={
<div className="modal-header"> <div className="d-flex align-items-center">
<h3 className="modal-title">{title}</h3> <IconUpload className="me-2" size={24} />
</div> {title}
<div className="modal-body"> </div>
<div className="mb-2 text-muted">{description}</div> }
<div size="lg"
className={`mb-3 border-dashed rounded p-3 ${dragOver ? 'bg-blue-lt' : ''}`} centered
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} footer={
onDragLeave={() => setDragOver(false)} <>
onDrop={handleDrop} <button className="btn btn-outline-secondary" onClick={downloadSample}>
> <IconFileText size={16} className="me-1" />
<div className="d-flex align-items-center justify-content-between"> Скачать шаблон
<div className="me-3 text-muted"> </button>
{fileName ? `Файл: ${fileName}` : 'Перетащите TXT/CSV сюда или вставьте текст ниже'} <button className="btn" onClick={onClose}>
Отмена
</button>
<button
className="btn btn-primary"
disabled={parsed.items.length === 0}
onClick={handleConfirm}
>
<IconCheck size={16} className="me-1" />
Импортировать ({parsed.items.length})
</button>
</>
}
>
{/* Description */}
<div className="alert alert-info mb-3">
<div className="d-flex">
<IconAlertCircle className="me-2 flex-shrink-0" size={20} />
<div className="text-muted small">{description}</div>
</div>
</div>
{/* Drop zone */}
<div
className={`card mb-3 ${dragOver ? 'border-primary bg-blue-lt' : 'border-dashed'}`}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
<div className="card-body">
<div className="d-flex align-items-center justify-content-between">
<div className="text-muted">
{fileName ? (
<div className="d-flex align-items-center">
<IconFileText size={20} className="me-2 text-primary" />
<strong>Файл:</strong> {fileName}
</div> </div>
<label className="btn btn-outline-primary mb-0"> ) : (
Выбрать файл 'Перетащите TXT/CSV сюда или вставьте текст ниже'
<input type="file" accept=".txt,.csv,.log" hidden onChange={async (e) => { )}
const f = e.target.files?.[0];
if (!f) return;
setFileName(f.name);
const t = await f.text();
setText(t);
parseText(t);
}} />
</label>
</div>
</div> </div>
<textarea <label className="btn btn-outline-primary mb-0">
ref={textAreaRef} <IconUpload size={16} className="me-1" />
className="form-control" Выбрать файл
rows={8} <input
value={text} type="file"
onChange={(e) => handleChange(e.target.value)} accept=".txt,.csv,.log"
placeholder={placeholder} hidden
/> onChange={async (e) => {
<div className="row mt-3 g-3"> const f = e.target.files?.[0];
<div className="col-md-6"> if (!f) return;
<div className="card"><div className="card-body"> setFileName(f.name);
<div className="text-muted">Готово к импорту</div> const t = await f.text();
<div className="h2 m-0">{parsed.items.length}</div> setText(t);
</div></div> parseText(t);
</div> }}
<div className="col-md-6"> />
<div className="card"><div className="card-body"> </label>
<div className="text-muted">Пропущено (ошибки)</div>
<div className="h2 m-0">{parsed.invalid.length}</div>
</div></div>
</div>
</div>
<div className="form-text mt-2">Проверьте предпросмотр и нажмите «Импортировать».</div>
</div>
<div className="modal-footer">
<button className="btn btn-outline-secondary" onClick={downloadSample}>Шаблон CSV</button>
<button className="btn" onClick={onClose}>Отмена</button>
<button className="btn btn-primary" disabled={parsed.items.length === 0} onClick={handleConfirm}>Импортировать</button>
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Textarea */}
<div className="mb-3">
<textarea
ref={textAreaRef}
className="form-control font-monospace"
rows={8}
value={text}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
/>
</div>
{/* Stats */}
<div className="row g-2">
<div className="col-md-6">
<div className="card bg-green-lt">
<div className="card-body py-2">
<div className="d-flex align-items-center">
<IconCheck size={20} className="text-green me-2" />
<div>
<div className="text-muted small">Готово к импорту</div>
<div className="h3 m-0 text-green">{parsed.items.length}</div>
</div>
</div>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card bg-red-lt">
<div className="card-body py-2">
<div className="d-flex align-items-center">
<IconX size={20} className="text-red me-2" />
<div>
<div className="text-muted small">Пропущено (ошибки)</div>
<div className="h3 m-0 text-red">{parsed.invalid.length}</div>
</div>
</div>
</div>
</div>
</div>
</div>
{parsed.invalid.length > 0 && (
<div className="alert alert-warning mt-3">
<div className="text-muted small">
<strong>Некорректные строки (показаны первые 5):</strong>
<ul className="mb-0 mt-1">
{parsed.invalid.slice(0, 5).map((line, i) => (
<li key={i}><code>{line}</code></li>
))}
</ul>
</div>
</div>
)}
</Modal>
); );
} }
export default ImportModal; export default ImportModal;
+4 -1
View File
@@ -8,6 +8,7 @@ import { IconX } from '@tabler/icons-react';
function Modal({ function Modal({
show, show,
onClose, onClose,
onOpen, // колбэк при открытии модалки
title, title,
children, children,
footer, footer,
@@ -27,6 +28,8 @@ function Modal({
document.body.classList.add('modal-open'); document.body.classList.add('modal-open');
// Trap focus внутри модалки // Trap focus внутри модалки
modalRef.current?.focus(); modalRef.current?.focus();
// Вызов колбэка при открытии
onOpen?.();
} else { } else {
document.body.classList.remove('modal-open'); document.body.classList.remove('modal-open');
} }
@@ -34,7 +37,7 @@ function Modal({
return () => { return () => {
document.body.classList.remove('modal-open'); document.body.classList.remove('modal-open');
}; };
}, [show]); }, [show, onOpen]);
// Обработка ESC // Обработка ESC
useEffect(() => { useEffect(() => {
+159 -135
View File
@@ -1,157 +1,181 @@
import { useEffect, useRef, useState } from 'react' import Modal from './Modal';
import api from '../lib/api.js' import FormField from './FormField';
import { useEffect, useState } from 'react';
import api from '../lib/api.js';
import { IconSettings, IconDeviceFloppy } from '@tabler/icons-react';
import ErrorAlert from './ErrorAlert';
/**
* SettingsModal - модальное окно настроек интерфейса
* Рефакторинг: использует базовый Modal и FormField
*/
export default function SettingsModal({ open, onClose }) { export default function SettingsModal({ open, onClose }) {
const ref = useRef(null) const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false);
const [saving, setSaving] = useState(false) const [error, setError] = useState('');
const [error, setError] = useState('') const [success, setSuccess] = useState('');
const [success, setSuccess] = useState('') const [etag, setEtag] = useState('');
const [etag, setEtag] = useState('') const [dohServer, setDohServer] = useState('');
const [dohServer, setDohServer] = useState('') const [wsUrl, setWsUrl] = useState('');
const [wsUrl, setWsUrl] = useState('')
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return;
setError('') setError('');
setSuccess('') setSuccess('');
setLoading(true) setLoading(true);
;(async () => {
(async () => {
try { try {
const res = await api.get('/ui-settings') const res = await api.get('/ui-settings');
const data = res?.data || {} const data = res?.data || {};
setDohServer(String(data?.dohServer || '')) setDohServer(String(data?.dohServer || ''));
setWsUrl(String(data?.wsUpdateUrl || '')) setWsUrl(String(data?.wsUpdateUrl || ''));
const e = res?.headers?.etag || res?.headers?.ETag || '' const e = res?.headers?.etag || res?.headers?.ETag || '';
setEtag(e ? String(e) : '') setEtag(e ? String(e) : '');
// фокус на первом поле
setTimeout(() => { try { ref.current?.querySelector('input[data-primary]')?.focus() } catch {} }, 0)
} catch (e) { } catch (e) {
setError('Не удалось загрузить настройки') setError('Не удалось загрузить настройки');
} finally { } finally {
setLoading(false) setLoading(false);
} }
})() })();
}, [open]) }, [open]);
if (!open) return null
const validateDoh = (value) => { const validateDoh = (value) => {
if (!value) return true // допускаем пустое значение if (!value) return { valid: true, message: '' };
try { try {
const u = new URL(String(value)) const u = new URL(String(value));
return u.protocol === 'https:' return u.protocol === 'https:'
} catch { return false } ? { valid: true, message: 'Корректный HTTPS URL' }
} : { valid: false, message: 'Используйте HTTPS' };
} catch {
return { valid: false, message: 'Некорректный URL' };
}
};
const validateWs = (value) => { const validateWs = (value) => {
if (!value) return true // допускаем пустое значение if (!value) return { valid: true, message: '' };
try { try {
const u = new URL(String(value)) const u = new URL(String(value));
return u.protocol === 'ws:' || u.protocol === 'wss:' return u.protocol === 'ws:' || u.protocol === 'wss:'
} catch { return false } ? { valid: true, message: 'Корректный WebSocket URL' }
} : { valid: false, message: 'Используйте ws:// или wss://' };
} catch {
return { valid: false, message: 'Некорректный URL' };
}
};
const onSave = async () => { const onSave = async () => {
setError('') setError('');
setSuccess('') setSuccess('');
if (!validateDoh(dohServer)) { setError('Укажите корректный HTTPS URL для DoH'); return }
if (!validateWs(wsUrl)) { setError('Укажите корректный WebSocket URL (ws:// или wss://)'); return } const dohValidation = validateDoh(dohServer);
setSaving(true) const wsValidation = validateWs(wsUrl);
try {
const payload = { settings: { dohServer: String(dohServer || '').trim(), wsUpdateUrl: String(wsUrl || '').trim() }, etag } if (!dohValidation.valid) {
const res = await api.post('/ui-settings', payload) setError('Укажите корректный HTTPS URL для DoH');
const meta = res?.data || {} return;
setSuccess('Настройки сохранены')
setEtag(String(meta?.etag || ''))
setTimeout(() => setSuccess(''), 2500)
} catch (e) {
setError(e?.response?.data?.message || 'Ошибка при сохранении настроек')
} finally {
setSaving(false)
} }
}
if (!wsValidation.valid) {
setError('Укажите корректный WebSocket URL (ws:// или wss://)');
return;
}
setSaving(true);
try {
const payload = {
settings: {
dohServer: String(dohServer || '').trim(),
wsUpdateUrl: String(wsUrl || '').trim()
},
etag
};
const res = await api.post('/ui-settings', payload);
const meta = res?.data || {};
setSuccess('Настройки успешно сохранены');
setEtag(String(meta?.etag || ''));
setTimeout(() => setSuccess(''), 3000);
} catch (e) {
setError(e?.response?.data?.message || 'Ошибка при сохранении настроек');
} finally {
setSaving(false);
}
};
return ( return (
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="settings-title" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onClose?.() }}> <Modal
<div className="modal-dialog" role="document"> show={open}
<div className="modal-content" ref={ref} tabIndex={-1} onKeyDown={(e) => { onClose={onClose}
if (e.key === 'Tab') { title={
const focusable = ref.current?.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])') <div className="d-flex align-items-center">
if (!focusable || focusable.length === 0) return <IconSettings className="me-2" size={24} />
const first = focusable[0] Настройки интерфейса
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}}>
<div className="modal-header">
<h5 id="settings-title" className="modal-title">Настройки интерфейса</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={onClose}></button>
</div>
<div className="modal-body">
{error && (
<div className="alert alert-danger alert-dismissible" role="alert">
{error}
<button type="button" className="btn-close" onClick={() => setError('')}></button>
</div>
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
{success}
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
<div className="mb-3">
<label className="form-label">WebSocket URL (BGP Live)</label>
<input
type="text"
className={`form-control${wsUrl && !validateWs(wsUrl) ? ' is-invalid' : ''}`}
placeholder="ws://host:port/ws/update_bgp?api_key=..."
value={wsUrl}
onChange={(e) => setWsUrl(e.target.value)}
disabled={loading || saving}
/>
<div className="form-hint">URL для Live-обновления BGP (ws:// или wss://). Можно оставить пустым.</div>
{wsUrl && !validateWs(wsUrl) && (
<div className="invalid-feedback">Укажите корректный WebSocket URL</div>
)}
</div>
<div className="mb-3">
<label className="form-label">DoH сервер</label>
<input
type="text"
className={`form-control${dohServer && !validateDoh(dohServer) ? ' is-invalid' : ''}`}
placeholder="https://dns.google/dns-query"
value={dohServer}
onChange={(e) => setDohServer(e.target.value)}
disabled={loading || saving}
data-primary
/>
<div className="form-hint">HTTPS URL для DNS-over-HTTPS (например, https://dns.google/dns-query)</div>
{dohServer && !validateDoh(dohServer) && (
<div className="invalid-feedback">Укажите корректный HTTPS URL</div>
)}
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={saving}>Закрыть</button>
<button type="button" className="btn btn-primary" onClick={onSave} disabled={saving || loading}>
{saving ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" />
Сохранение...
</>
) : (
'Сохранить'
)}
</button>
</div>
</div> </div>
</div> }
</div> size="md"
) centered
footer={
<>
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={saving}
>
Закрыть
</button>
<button
type="button"
className="btn btn-primary"
onClick={onSave}
disabled={saving || loading}
>
{saving && <span className="spinner-border spinner-border-sm me-2" />}
<IconDeviceFloppy size={16} className="me-1" />
Сохранить
</button>
</>
}
>
{error && <ErrorAlert message={error} onClose={() => setError('')} />}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<div className="d-flex">
<div className="flex-grow-1">{success}</div>
</div>
<button
type="button"
className="btn-close"
onClick={() => setSuccess('')}
/>
</div>
)}
<FormField
label="WebSocket URL (BGP Live)"
name="wsUrl"
type="text"
value={wsUrl}
onChange={setWsUrl}
onValidate={validateWs}
placeholder="ws://host:port/ws/update_bgp?api_key=..."
helpText="URL для Live-обновления BGP (ws:// или wss://). Можно оставить пустым."
disabled={loading || saving}
autoFocus
/>
<FormField
label="DoH сервер"
name="dohServer"
type="text"
value={dohServer}
onChange={setDohServer}
onValidate={validateDoh}
placeholder="https://dns.google/dns-query"
helpText="HTTPS URL для DNS-over-HTTPS"
disabled={loading || saving}
/>
</Modal>
);
} }
+131 -65
View File
@@ -1,6 +1,19 @@
import Modal from './Modal';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { IconX, IconPlayerPlay, IconPlayerPause, IconPlugConnected, IconCopy, IconTrash, IconClock } from '@tabler/icons-react'; import {
IconX,
IconPlayerPlay,
IconPlayerPause,
IconPlugConnected,
IconCopy,
IconTrash,
IconClock
} from '@tabler/icons-react';
/**
* WsUpdateModal - модальное окно с логами WebSocket
* Рефакторинг: использует базовый Modal, улучшенный UX
*/
function WsUpdateModal({ show, url, onClose }) { function WsUpdateModal({ show, url, onClose }) {
const [rawMessages, setRawMessages] = useState([]); const [rawMessages, setRawMessages] = useState([]);
const [status, setStatus] = useState('connecting'); // connecting | open | closed | error const [status, setStatus] = useState('connecting'); // connecting | open | closed | error
@@ -11,16 +24,19 @@ function WsUpdateModal({ show, url, onClose }) {
useEffect(() => { useEffect(() => {
if (!show) return; if (!show) return;
try { try {
const ws = new WebSocket(url); const ws = new WebSocket(url);
wsRef.current = ws; wsRef.current = ws;
setStatus('connecting'); setStatus('connecting');
ws.onopen = () => setStatus('open'); ws.onopen = () => setStatus('open');
ws.onmessage = (evt) => { ws.onmessage = (evt) => {
const text = typeof evt.data === 'string' ? evt.data : ''; const text = typeof evt.data === 'string' ? evt.data : '';
// Ищем JSON-часть в сообщении (после `$ ` или без него)
let parsed = null; let parsed = null;
let jsonStart = text.indexOf('{'); let jsonStart = text.indexOf('{');
if (jsonStart >= 0) { if (jsonStart >= 0) {
const candidate = text.slice(jsonStart).trim(); const candidate = text.slice(jsonStart).trim();
try { try {
@@ -30,30 +46,29 @@ function WsUpdateModal({ show, url, onClose }) {
} }
} }
// Если пришёл JSON — показываем только поле `line`. Остальные события (start, etc.) не выводим в лог
if (parsed) { if (parsed) {
if (parsed.event === 'start' && parsed.ts) { if (parsed.event === 'start' && parsed.ts) {
setStartedAt(new Date(parsed.ts)); setStartedAt(new Date(parsed.ts));
return; // ничего не выводим return;
} }
if (typeof parsed.line === 'string' && parsed.line.trim().length > 0) { if (typeof parsed.line === 'string' && parsed.line.trim().length > 0) {
setRawMessages((prev) => [...prev, { text: parsed.line, json: parsed }]); setRawMessages((prev) => [...prev, { text: parsed.line, json: parsed }]);
return; return;
} }
// Нет поля line — игнорируем
return; return;
} }
// Если JSON не распарсился — выводим как есть (обрежем префикс `$ ` при наличии)
const clean = text.startsWith('$ ') ? text.slice(2) : text; const clean = text.startsWith('$ ') ? text.slice(2) : text;
if (clean.trim().length === 0) return; if (clean.trim().length === 0) return;
setRawMessages((prev) => [...prev, { text: clean, json: null }]); setRawMessages((prev) => [...prev, { text: clean, json: null }]);
}; };
ws.onerror = () => setStatus('error'); ws.onerror = () => setStatus('error');
ws.onclose = () => setStatus('closed'); ws.onclose = () => setStatus('closed');
} catch (e) { } catch (e) {
setStatus('error'); setStatus('error');
} }
return () => { return () => {
try { wsRef.current?.close(); } catch {} try { wsRef.current?.close(); } catch {}
wsRef.current = null; wsRef.current = null;
@@ -61,10 +76,15 @@ function WsUpdateModal({ show, url, onClose }) {
}, [show, url]); }, [show, url]);
useEffect(() => { useEffect(() => {
if (autoScroll) bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); if (autoScroll) {
}, [rawMessages, show, autoScroll]); bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [rawMessages, autoScroll]);
const plainLog = useMemo(() => rawMessages.map(m => (typeof m.text === 'string' ? m.text : '')).join('\n'), [rawMessages]); const plainLog = useMemo(
() => rawMessages.map(m => (typeof m.text === 'string' ? m.text : '')).join('\n'),
[rawMessages]
);
const elapsedMs = useMemo(() => { const elapsedMs = useMemo(() => {
if (!startedAt || rawMessages.length === 0) return null; if (!startedAt || rawMessages.length === 0) return null;
@@ -72,67 +92,113 @@ function WsUpdateModal({ show, url, onClose }) {
return lastTs - startedAt.getTime(); return lastTs - startedAt.getTime();
}, [startedAt, rawMessages]); }, [startedAt, rawMessages]);
if (!show) return null; const copyLog = async () => {
try {
await navigator.clipboard.writeText(plainLog);
} catch {}
};
const clearLog = () => {
setRawMessages([]);
};
const getStatusBadge = () => {
const variants = {
open: 'bg-green-lt text-green',
connecting: 'bg-orange-lt text-orange',
error: 'bg-red-lt text-red',
closed: 'bg-secondary-lt text-secondary'
};
return (
<span className={`badge ${variants[status] || variants.closed}`}>
{status}
</span>
);
};
return ( return (
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}> <Modal
<div className="modal-dialog modal-lg"> show={show}
<div className="modal-content"> onClose={onClose}
<div className="modal-header d-flex align-items-center justify-content-between"> title={
<h5 className="modal-title d-flex align-items-center m-0"> <div className="d-flex align-items-center">
<IconPlugConnected className="me-2" /> Логи запуска <IconPlugConnected className="me-2" size={24} />
<span className={`badge ms-2 ${status === 'open' ? 'bg-green-lt text-green' : status === 'connecting' ? 'bg-orange-lt text-orange' : status === 'error' ? 'bg-red-lt text-red' : 'bg-secondary-lt text-secondary'}`}>{status}</span> Логи запуска
</h5> {getStatusBadge()}
<div className="d-flex align-items-center gap-2">
<button className="btn btn-outline-secondary btn-sm" onClick={() => setAutoScroll(!autoScroll)} title={autoScroll ? 'Отключить автопрокрутку' : 'Включить автопрокрутку'}>
{autoScroll ? <IconPlayerPause className="me-1" /> : <IconPlayerPlay className="me-1" />} Автопрокрутка
</button>
<button className="btn btn-outline-primary btn-sm" onClick={async () => { try { await navigator.clipboard.writeText(plainLog); } catch {} }} title="Скопировать лог">
<IconCopy className="me-1" /> Копировать
</button>
<button className="btn btn-outline-secondary btn-sm" onClick={() => setRawMessages([])} title="Очистить лог">
<IconTrash className="me-1" /> Очистить
</button>
<button type="button" className="btn-close" onClick={onClose} aria-label="Close"></button>
</div>
</div>
<div className="modal-body">
<div className="bg-dark text-light p-3 rounded" style={{ maxHeight: '60vh', overflowY: 'auto', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace', fontSize: '0.875rem', border: '1px solid rgba(255,255,255,0.08)' }}>
{rawMessages.length === 0 ? (
<div className="text-muted">Ожидание сообщений от сервера...</div>
) : (
rawMessages.map((m, i) => {
const isErr = m?.json?.stream === 'stderr' || /\berror\b|\bошиб/i.test(String(m.text));
return (
<div key={i} className={`text-break ${isErr ? 'text-danger' : ''}`}>
<span className="text-secondary">$</span> {m.text}
</div>
);
})
)}
<div ref={bottomRef} />
</div>
</div>
<div className="modal-footer d-flex justify-content-between align-items-center">
<div className="text-muted small d-flex align-items-center">
<IconClock size={16} className="me-1" />
<span>
Начало {startedAt ? startedAt.toLocaleTimeString() : '—'}
{elapsedMs != null && (
<>
, прошло {elapsedMs} ms
</>
)}
</span>
</div>
<button className="btn btn-secondary" onClick={onClose}><IconX className="me-1" /> Закрыть</button>
</div>
</div> </div>
}
size="lg"
scrollable={false}
footer={
<div className="d-flex justify-content-between align-items-center w-100">
<div className="text-muted small d-flex align-items-center">
<IconClock size={16} className="me-2" />
<span>
Начало {startedAt ? startedAt.toLocaleTimeString() : '—'}
{elapsedMs != null && <> Прошло {elapsedMs} ms</>}
</span>
</div>
<button className="btn btn-secondary" onClick={onClose}>
<IconX size={16} className="me-1" />
Закрыть
</button>
</div>
}
>
{/* Controls */}
<div className="d-flex gap-2 mb-3">
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => setAutoScroll(!autoScroll)}
title={autoScroll ? 'Отключить автопрокрутку' : 'Включить автопрокрутку'}
>
{autoScroll ? <IconPlayerPause size={16} /> : <IconPlayerPlay size={16} />}
<span className="ms-1">Автопрокрутка</span>
</button>
<button
className="btn btn-outline-primary btn-sm"
onClick={copyLog}
title="Скопировать лог"
>
<IconCopy size={16} />
<span className="ms-1">Копировать</span>
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={clearLog}
title="Очистить лог"
>
<IconTrash size={16} />
<span className="ms-1">Очистить</span>
</button>
</div> </div>
</div>
{/* Log output */}
<div
className="bg-dark text-light p-3 rounded font-monospace"
style={{
maxHeight: '60vh',
overflowY: 'auto',
fontSize: '0.875rem',
border: '1px solid rgba(255,255,255,0.08)'
}}
>
{rawMessages.length === 0 ? (
<div className="text-muted">Ожидание сообщений от сервера...</div>
) : (
rawMessages.map((m, i) => {
const isErr = m?.json?.stream === 'stderr' || /\berror\b|\bошиб/i.test(String(m.text));
return (
<div key={i} className={`text-break ${isErr ? 'text-danger' : ''}`}>
<span className="text-secondary">$</span> {m.text}
</div>
);
})
)}
<div ref={bottomRef} />
</div>
</Modal>
); );
} }
export default WsUpdateModal; export default WsUpdateModal;