feat: Рефакторинг модальных окон для улучшения UX, добавление базового компонента Modal и улучшение структуры кода. Обновление компонентов HistoryModal, ImportModal, SettingsModal и WsUpdateModal для повышения читаемости и удобства использования.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
This commit is contained in:
@@ -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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 () => {
|
|
||||||
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)
|
|
||||||
} catch (e) {
|
|
||||||
setError('Не удалось загрузить настройки')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
if (!open) return null
|
(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) : '');
|
||||||
|
} catch (e) {
|
||||||
|
setError('Не удалось загрузить настройки');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user