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
+131 -65
View File
@@ -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 (
<span className={`badge ${variants[status] || variants.closed}`}>
{status}
</span>
);
};
return (
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header d-flex align-items-center justify-content-between">
<h5 className="modal-title d-flex align-items-center m-0">
<IconPlugConnected className="me-2" /> Логи запуска
<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>
<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>
<Modal
show={show}
onClose={onClose}
title={
<div className="d-flex align-items-center">
<IconPlugConnected className="me-2" size={24} />
Логи запуска
{getStatusBadge()}
</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>
{/* 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;