-
{description}
-
{ e.preventDefault(); setDragOver(true); }}
- onDragLeave={() => setDragOver(false)}
- onDrop={handleDrop}
- >
-
-
- {fileName ? `Файл: ${fileName}` : 'Перетащите TXT/CSV сюда или вставьте текст ниже'}
+
+
+ {title}
+
+ }
+ size="lg"
+ centered
+ footer={
+ <>
+
+
+
+ >
+ }
+ >
+ {/* Description */}
+
+
+ {/* Drop zone */}
+
{ e.preventDefault(); setDragOver(true); }}
+ onDragLeave={() => setDragOver(false)}
+ onDrop={handleDrop}
+ >
+
+
+
+ {fileName ? (
+
+
+ Файл: {fileName}
-
-
+ ) : (
+ 'Перетащите TXT/CSV сюда или вставьте текст ниже'
+ )}
-
-
-
-
-
+
-
+
+ {/* Textarea */}
+
+
+
+ {/* Stats */}
+
+
+
+
+
+
+
+
Готово к импорту
+
{parsed.items.length}
+
+
+
+
+
+
+
+
+
+
+
+
Пропущено (ошибки)
+
{parsed.invalid.length}
+
+
+
+
+
+
+
+ {parsed.invalid.length > 0 && (
+
+
+
Некорректные строки (показаны первые 5):
+
+ {parsed.invalid.slice(0, 5).map((line, i) => (
+ {line}
+ ))}
+
+
+
+ )}
+
);
}
export default ImportModal;
-
-
diff --git a/frontend/src/components/Modal.jsx b/frontend/src/components/Modal.jsx
index d41a13d..713ca56 100644
--- a/frontend/src/components/Modal.jsx
+++ b/frontend/src/components/Modal.jsx
@@ -8,6 +8,7 @@ import { IconX } from '@tabler/icons-react';
function Modal({
show,
onClose,
+ onOpen, // колбэк при открытии модалки
title,
children,
footer,
@@ -27,6 +28,8 @@ function Modal({
document.body.classList.add('modal-open');
// Trap focus внутри модалки
modalRef.current?.focus();
+ // Вызов колбэка при открытии
+ onOpen?.();
} else {
document.body.classList.remove('modal-open');
}
@@ -34,7 +37,7 @@ function Modal({
return () => {
document.body.classList.remove('modal-open');
};
- }, [show]);
+ }, [show, onOpen]);
// Обработка ESC
useEffect(() => {
diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx
index 6c89bb0..09518a2 100644
--- a/frontend/src/components/SettingsModal.jsx
+++ b/frontend/src/components/SettingsModal.jsx
@@ -1,157 +1,181 @@
-import { useEffect, useRef, useState } from 'react'
-import api from '../lib/api.js'
+import Modal from './Modal';
+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 }) {
- const ref = useRef(null)
- const [loading, setLoading] = useState(false)
- const [saving, setSaving] = useState(false)
- const [error, setError] = useState('')
- const [success, setSuccess] = useState('')
- const [etag, setEtag] = useState('')
- const [dohServer, setDohServer] = useState('')
- const [wsUrl, setWsUrl] = useState('')
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState('');
+ const [etag, setEtag] = useState('');
+ const [dohServer, setDohServer] = useState('');
+ const [wsUrl, setWsUrl] = useState('');
useEffect(() => {
- if (!open) return
- setError('')
- setSuccess('')
- setLoading(true)
- ;(async () => {
+ if (!open) return;
+ setError('');
+ setSuccess('');
+ setLoading(true);
+
+ (async () => {
try {
- const res = await api.get('/ui-settings')
- const data = res?.data || {}
- setDohServer(String(data?.dohServer || ''))
- setWsUrl(String(data?.wsUpdateUrl || ''))
- const e = res?.headers?.etag || res?.headers?.ETag || ''
- setEtag(e ? String(e) : '')
- // фокус на первом поле
- setTimeout(() => { try { ref.current?.querySelector('input[data-primary]')?.focus() } catch {} }, 0)
+ const res = await api.get('/ui-settings');
+ const data = res?.data || {};
+ setDohServer(String(data?.dohServer || ''));
+ setWsUrl(String(data?.wsUpdateUrl || ''));
+ const e = res?.headers?.etag || res?.headers?.ETag || '';
+ setEtag(e ? String(e) : '');
} catch (e) {
- setError('Не удалось загрузить настройки')
+ setError('Не удалось загрузить настройки');
} finally {
- setLoading(false)
+ setLoading(false);
}
- })()
- }, [open])
-
- if (!open) return null
+ })();
+ }, [open]);
const validateDoh = (value) => {
- if (!value) return true // допускаем пустое значение
+ if (!value) return { valid: true, message: '' };
try {
- const u = new URL(String(value))
- return u.protocol === 'https:'
- } catch { return false }
- }
+ const u = new URL(String(value));
+ return u.protocol === 'https:'
+ ? { valid: true, message: 'Корректный HTTPS URL' }
+ : { valid: false, message: 'Используйте HTTPS' };
+ } catch {
+ return { valid: false, message: 'Некорректный URL' };
+ }
+ };
const validateWs = (value) => {
- if (!value) return true // допускаем пустое значение
+ if (!value) return { valid: true, message: '' };
try {
- const u = new URL(String(value))
+ const u = new URL(String(value));
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 () => {
- setError('')
- setSuccess('')
- if (!validateDoh(dohServer)) { setError('Укажите корректный HTTPS URL для DoH'); return }
- if (!validateWs(wsUrl)) { 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(''), 2500)
- } catch (e) {
- setError(e?.response?.data?.message || 'Ошибка при сохранении настроек')
- } finally {
- setSaving(false)
+ setError('');
+ setSuccess('');
+
+ const dohValidation = validateDoh(dohServer);
+ const wsValidation = validateWs(wsUrl);
+
+ if (!dohValidation.valid) {
+ setError('Укажите корректный HTTPS URL для DoH');
+ return;
}
- }
+
+ 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 (
-
{ if (e.key === 'Escape') onClose?.() }}>
-
-
{
- if (e.key === 'Tab') {
- const focusable = ref.current?.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
- if (!focusable || focusable.length === 0) return
- 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(); }
- }
- }}>
-
-
Настройки интерфейса
-
-
-
- {error && (
-
- {error}
-
-
- )}
- {success && (
-
- {success}
-
-
- )}
-
-
-
setWsUrl(e.target.value)}
- disabled={loading || saving}
- />
-
URL для Live-обновления BGP (ws:// или wss://). Можно оставить пустым.
- {wsUrl && !validateWs(wsUrl) && (
-
Укажите корректный WebSocket URL
- )}
-
-
-
-
-
setDohServer(e.target.value)}
- disabled={loading || saving}
- data-primary
- />
-
HTTPS URL для DNS-over-HTTPS (например, https://dns.google/dns-query)
- {dohServer && !validateDoh(dohServer) && (
-
Укажите корректный HTTPS URL
- )}
-
-
-
-
-
-
+
+
+ Настройки интерфейса
-
-
- )
+ }
+ size="md"
+ centered
+ footer={
+ <>
+
+
+ >
+ }
+ >
+ {error &&
setError('')} />}
+
+ {success && (
+
+
+
+ )}
+
+
+
+
+
+ );
}
-
-
diff --git a/frontend/src/components/WsUpdateModal.jsx b/frontend/src/components/WsUpdateModal.jsx
index dc485c7..970e8f6 100644
--- a/frontend/src/components/WsUpdateModal.jsx
+++ b/frontend/src/components/WsUpdateModal.jsx
@@ -1,6 +1,19 @@
+import Modal from './Modal';
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 }) {
const [rawMessages, setRawMessages] = useState([]);
const [status, setStatus] = useState('connecting'); // connecting | open | closed | error
@@ -11,16 +24,19 @@ function WsUpdateModal({ show, url, onClose }) {
useEffect(() => {
if (!show) return;
+
try {
const ws = new WebSocket(url);
wsRef.current = ws;
setStatus('connecting');
+
ws.onopen = () => setStatus('open');
+
ws.onmessage = (evt) => {
const text = typeof evt.data === 'string' ? evt.data : '';
- // Ищем JSON-часть в сообщении (после `$ ` или без него)
let parsed = null;
let jsonStart = text.indexOf('{');
+
if (jsonStart >= 0) {
const candidate = text.slice(jsonStart).trim();
try {
@@ -30,30 +46,29 @@ function WsUpdateModal({ show, url, onClose }) {
}
}
- // Если пришёл JSON — показываем только поле `line`. Остальные события (start, etc.) не выводим в лог
if (parsed) {
if (parsed.event === 'start' && parsed.ts) {
setStartedAt(new Date(parsed.ts));
- return; // ничего не выводим
+ return;
}
if (typeof parsed.line === 'string' && parsed.line.trim().length > 0) {
setRawMessages((prev) => [...prev, { text: parsed.line, json: parsed }]);
return;
}
- // Нет поля line — игнорируем
return;
}
- // Если JSON не распарсился — выводим как есть (обрежем префикс `$ ` при наличии)
const clean = text.startsWith('$ ') ? text.slice(2) : text;
if (clean.trim().length === 0) return;
setRawMessages((prev) => [...prev, { text: clean, json: null }]);
};
+
ws.onerror = () => setStatus('error');
ws.onclose = () => setStatus('closed');
} catch (e) {
setStatus('error');
}
+
return () => {
try { wsRef.current?.close(); } catch {}
wsRef.current = null;
@@ -61,10 +76,15 @@ function WsUpdateModal({ show, url, onClose }) {
}, [show, url]);
useEffect(() => {
- if (autoScroll) bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
- }, [rawMessages, show, autoScroll]);
+ if (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(() => {
if (!startedAt || rawMessages.length === 0) return null;
@@ -72,67 +92,113 @@ function WsUpdateModal({ show, url, onClose }) {
return lastTs - startedAt.getTime();
}, [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 (
+
+ {status}
+
+ );
+ };
return (
-
-
-
-
-
- Логи запуска
- {status}
-
-
-
-
-
-
-
-
-
-
- {rawMessages.length === 0 ? (
-
Ожидание сообщений от сервера...
- ) : (
- rawMessages.map((m, i) => {
- const isErr = m?.json?.stream === 'stderr' || /\berror\b|\bошиб/i.test(String(m.text));
- return (
-
- $ {m.text}
-
- );
- })
- )}
-
-
-
-
-
-
-
- Начало {startedAt ? startedAt.toLocaleTimeString() : '—'}
- {elapsedMs != null && (
- <>
- , прошло {elapsedMs} ms
- >
- )}
-
-
-
-
+
+
+ Логи запуска
+ {getStatusBadge()}
+ }
+ size="lg"
+ scrollable={false}
+ footer={
+
+
+
+
+ Начало {startedAt ? startedAt.toLocaleTimeString() : '—'}
+ {elapsedMs != null && <> • Прошло {elapsedMs} ms>}
+
+
+
+
+ }
+ >
+ {/* Controls */}
+
+
+
+
-
+
+ {/* Log output */}
+
+ {rawMessages.length === 0 ? (
+
Ожидание сообщений от сервера...
+ ) : (
+ rawMessages.map((m, i) => {
+ const isErr = m?.json?.stream === 'stderr' || /\berror\b|\bошиб/i.test(String(m.text));
+ return (
+
+ $ {m.text}
+
+ );
+ })
+ )}
+
+
+
);
}
export default WsUpdateModal;
-
-